From ff5998f1842bc01b5c997aae5bdd1eea69c80c2b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 31 Aug 2026 02:15:12 +0800 Subject: [PATCH] fix(rectification): render adoption carrier on deterministic choice path --- docs/BUG_HISTORY.md | 16 ++++ .../src/app/api/rectification/agent/route.ts | 5 - .../api/rectification/cases/[caseId]/route.ts | 27 +++++- .../components/rectification-agentic-chat.tsx | 4 +- .../rectification-agentic/v9/answer-choice.ts | 13 ++- .../v9/case-receipt-projection.ts | 53 +++++++++++ .../src/lib/rectification-candidate-result.ts | 2 + .../tests/rectification-agentic-entry.test.ts | 8 +- .../tests/rectification-answer-choice.test.ts | 92 +++++++++++++++++++ .../rectification-candidate-result.test.ts | 16 ++++ ...tification-case-receipt-projection.test.ts | 77 ++++++++++++++++ .../tests/rectification-collect-stall.test.ts | 2 - 12 files changed, 298 insertions(+), 17 deletions(-) create mode 100644 frontend/src/lib/rectification-agentic/v9/case-receipt-projection.ts create mode 100644 frontend/tests/rectification-case-receipt-projection.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b9d12e79..57910990 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6942,3 +6942,19 @@ - 相关记录:BUG-441、BUG-449、BUG-450 - 复发自:BUG-450 - 修复版本:`fix(rectification): prevent silent collect focus stalls`(Skill 保持 `10.0.13`) + +## BUG-453 | 可采用区间已由服务端放行但前端不渲染时间卡 + +- 状态:resolved +- 首次发现:2026-08-30 +- 最近更新:2026-08-30 +- 影响面:`rectification-agentic-chat.tsx` 时间卡渲染门控、`choice-action` 点选快路径、Case GET 默认投影 +- 用户现象:答完最后一道选择题后助手只说「已记录你的选择,并更新了候选比较。」;界面没有时间卡也没有下一问,流程停住。 +- 触发条件:服务端已给出 `can_adopt=true`、`selection_allowed=true`、`precision_stage=ready_to_adopt`,但当前轮是确定性点选路径,没有产生 `rectification-offer-candidates` 模型工具 receipt;前端仍以历史 `offeredSelectionOnce` 作为时间卡前置条件。 +- 根因:服务端允许采用,前端却把承载绑定到模型是否调用某个工具;确定性点选路径不会产生该 receipt step,因此 `selectionAllowed=true` 仍无法渲染时间卡。点选快路径原正文也只保留确认句,没有区间、代表分钟和采用提示。 +- 修复:P0:时间卡改为只依赖服务端 `selectionAllowed=true` 与 `canAdopt=true`,保留最新已结算助手消息、忙碌/只读/重生成状态和选择卡互斥门;不再依赖 `offeredSelectionOnce`。P1:点选进入 `offer_provisional_range + can_adopt=true` 时由服务端确定性写出可信区间、代表分钟、代表分钟免责声明和「可以从下面的时间里选一个采用」;该正文同时落入确定性 turn。P2:非终态轮末只看 `current_question` 或决策 `canAdopt`,缺失时确定性补口述采集焦点并记录结构化日志。P3:Case GET 默认只保留活跃候选/代表分钟对应宫位表与当前焦点 probe;`detail=full` 保留完整 receipt;`decision`、`gates`、`inference_state` 不被瘦身修改。模型侧继续使用既有的精简 projection。 +- 验证:新增/更新前端门控与 `canAdopt` camel/snake 解析回归;保留 duplicate focus、skipped focus、非终态出口和五证据 case 回归;P1 断言确定性落库正文包含区间、代表分钟、免责声明和采用提示;P3 断言默认投影保留决策字段与完整 `inference_state`,并移除非当前 probe 与非活跃宫位表。提交前运行 TypeScript、改动文件 ESLint、rectification/agentic 回归及全量测试;真实线上模型 74 秒路径耗时未在本轮复测。 +- 防复发:UI 承载只看服务端状态,不得绑定模型工具 receipt;ready-to-adopt 的确定性正文必须写区间、代表分钟和采用提示。非终态轮次必须有 `current_question` 或真实可采用承载;所有承载判断只看焦点与 decision 字段,不得用正文文本判断。不得为无选项问题新造视觉容器或复用选择卡样式,不得引入语义正则、关键词表或 A/B/C/D 位置推断;不得改变 confirmation gate、`resolved`/`declined` 语义或 Skill `10.0.13`。 +- 相关记录:BUG-441、BUG-449、BUG-450、BUG-452 +- 复发自:BUG-450 +- 修复版本:待发布 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 09a286ab..dac11234 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -5,7 +5,6 @@ import { evidenceLedgerFingerprint, loadV9CaseCompute, loadV9CaseDossier, - loadV9TurnReceipt, persistV9DeterministicTurn, RectificationToolServiceError, transitionV9CaseStatus, @@ -686,14 +685,10 @@ export async function POST(request: Request) { ); } try { - const receipt = await loadV9TurnReceipt(accounting as never, userId, caseId, result.turnId); const exit = await ensureNonTerminalTurnExit({ accounting: accounting as never, userId, caseId, - adoptCarrierReady: Boolean(receipt?.toolActivities.some((activity) => ( - activity.tool === "rectification-offer-candidates" && activity.status === "completed" - ))), }); idleHostNarration = exit.hostNarration ?? idleHostNarration; } catch (error) { diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index bdb4ffd9..834bc6e9 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -15,6 +15,7 @@ import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision } f import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-decision"; import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter"; import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectification-decision"; +import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection"; export const runtime = "nodejs"; @@ -49,7 +50,9 @@ export async function GET(request: Request, context: RouteContext) { if (!z.string().uuid().safeParse(caseId).success) { return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 }); } - const sessionId = new URL(request.url).searchParams.get("sessionId") ?? ""; + const searchParams = new URL(request.url).searchParams; + const sessionId = searchParams.get("sessionId") ?? ""; + const fullDetail = searchParams.get("detail") === "full"; try { const dossier = await loadV9CaseDossier(accounting, user.id, caseId); @@ -66,7 +69,7 @@ export async function GET(request: Request, context: RouteContext) { } }), ); - return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity)); + return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail })); } catch (error) { if (error instanceof RectificationToolServiceError) { const message = error.message; @@ -85,6 +88,7 @@ function dossierResponse( dossier: V9CaseDossier, receipts: Array>>, skillIdentity: Awaited>, + options: { fullDetail?: boolean } = {}, ) { const decision = decideFromDossier(dossier, { currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), @@ -115,13 +119,30 @@ function dossierResponse( receipt: turnReceipt(turn.id, receipts), })), evidence: dossier.evidence, - latest_result: dossier.latestResult ? overlayPublicDecision(dossier.latestResult, decision) : null, + latest_result: dossier.latestResult + ? publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true) + : null, interview: publicDecisionFields(decision), current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus), choice_card: choiceCardFromCaseDossier(dossier), }; } +function publicLatestResult( + latest: V9CaseDossier["latestResult"], + decision: ReturnType, + dossier: V9CaseDossier, + fullDetail: boolean, +) { + if (!latest) return null; + const projected = overlayPublicDecision(latest, decision); + if (fullDetail || !projected.decisionReceipt) return projected; + return { + ...projected, + decisionReceipt: slimDecisionReceipt(projected.decisionReceipt, dossier), + }; +} + function turnReceipt( turnId: string, receipts: Array>>, diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 2b536590..bc42c162 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1020,7 +1020,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { && !message.failed && Boolean(message.text) )); - const offeredSelectionOnce = messages.some(turnOfferedSelection); const answeredQuestionIds = new Set( messages.flatMap((message) => message.choiceAttachment ? [message.choiceAttachment.card.question_id] @@ -1049,10 +1048,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }, [showLiveChoiceCard, updateFollowState]); const showSelectionCards = Boolean( candidateResult?.selectionAllowed - && offeredSelectionOnce + && candidateResult?.canAdopt && latestSettledAssistant && !showLiveChoiceCard && !busy + && !readonly && regeneratingMessageKey === null, ); const selectionCardMessageKey = showSelectionCards && latestSettledAssistant diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 9233f29b..979c1aae 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -729,6 +729,14 @@ ${nextInterview.hostNarration}` nextInterviewPersisted = true; } } + const adoptionNarration = nextAction.type === "offer_provisional_range" && nextAction.can_adopt + ? `${nonConvergingRangeNarration({ + credibleRange: nextAction.credible_range, + representativeTime: nextAction.representative_time, + })} 可以从下面的时间里选一个采用。` + : null; + if (adoptionNarration) hostNarration = adoptionNarration; + if ( command.deferFollowup !== true && (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted })) @@ -745,7 +753,7 @@ ${nextInterview.hostNarration}` } } - const narration = (nextInterviewPersisted ? hostNarration : null) + const narration = (adoptionNarration || nextInterviewPersisted ? hostNarration : null) || (persisted.narration && persisted.narration.trim()) || hostNarration || composeChoiceNarration({ @@ -783,7 +791,6 @@ export async function ensureNonTerminalTurnExit(input: { accounting: AccountingClient; userId: string; caseId: string; - adoptCarrierReady: boolean; }): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> { const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); if (projectCurrentQuestion(dossier.conversationSummary.activeFocus)) { @@ -801,7 +808,7 @@ export async function ensureNonTerminalTurnExit(input: { dossier.case.acceptedTime || dossier.case.confirmedTime || decision.completionStatus === "provisional_range_user_stopped" - || (decision.canAdopt && input.adoptCarrierReady) + || decision.canAdopt ) { return { persisted: false, choiceReady: false, hostNarration: null }; } diff --git a/frontend/src/lib/rectification-agentic/v9/case-receipt-projection.ts b/frontend/src/lib/rectification-agentic/v9/case-receipt-projection.ts new file mode 100644 index 00000000..84922214 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/case-receipt-projection.ts @@ -0,0 +1,53 @@ +import { previousInferenceFromReceipt } from "./inference-adapter"; +import type { V9CaseDossier } from "./tool-service"; + +export function slimDecisionReceipt( + receipt: Readonly>, + dossier: Pick, +): Readonly> { + const activeTimes = new Set(); + const inference = previousInferenceFromReceipt(receipt); + const activeInferenceCandidates = inference?.candidates.filter((candidate) => candidate.status === "active") ?? []; + for (const candidate of activeInferenceCandidates.length > 0 + ? activeInferenceCandidates + : dossier.latestResult?.candidates ?? []) { + activeTimes.add(candidate.time); + } + for (const value of [inference?.representative_time, dossier.latestResult?.representativeTime, dossier.latestResult?.selectedTime]) { + if (typeof value === "string" && value.trim()) activeTimes.add(value.slice(0, 5)); + } + const focusSchema = dossier.conversationSummary.activeFocus?.expectedAnswerSchema ?? {}; + const currentProbeIds = new Set( + [focusSchema.probe_id, focusSchema.semantic_key, focusSchema.candidate_split_hash] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0), + ); + const result = { ...receipt }; + const tables = receipt.house_tables_by_time; + if (tables && typeof tables === "object" && !Array.isArray(tables)) { + result.house_tables_by_time = Object.fromEntries( + Object.entries(tables as Record) + .filter(([time]) => activeTimes.has(time.slice(0, 5))), + ); + } + if (receipt.house_table && typeof receipt.house_table === "object" && !Array.isArray(receipt.house_table)) { + const table = receipt.house_table as Record; + if (typeof table.time === "string" && !activeTimes.has(table.time.slice(0, 5))) { + delete result.house_table; + } + } + for (const key of [ + "discriminating_event_probes", + "event_clarification_probes", + "evidence_collection_probes", + ]) { + const probes = result[key]; + if (!Array.isArray(probes)) continue; + result[key] = probes.filter((probe) => { + if (!probe || typeof probe !== "object" || Array.isArray(probe)) return false; + const row = probe as Record; + return [row.id, row.probe_id, row.semantic_key, row.candidate_split_hash] + .some((value) => typeof value === "string" && currentProbeIds.has(value)); + }); + } + return result; +} diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index 117baf4e..ae0e5c05 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -53,6 +53,7 @@ export type RectificationCandidateResult = Readonly<{ candidates: readonly RectificationCandidate[]; overallConfidence: "low" | "medium" | "high"; selectionAllowed: boolean; + canAdopt: boolean; confirmationAllowed: boolean; representativeTime: string | null; selectedTime: string | null; @@ -267,6 +268,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification ? snapshot.overallConfidence : "low", selectionAllowed: snapshot.selectionAllowed === true || snapshot.selection_allowed === true, + canAdopt: snapshot.canAdopt === true || snapshot.can_adopt === true, confirmationAllowed: snapshot.confirmationAllowed === true, representativeTime, selectedTime, diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 17dedcd6..f68de6a8 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -531,7 +531,7 @@ test("conversation and house board reuse the quiet overlay scrollbar", () => { assert.doesNotMatch(styles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \{[^}]*scrollbar-gutter/); }); -test("time-selection cards appear under the latest settled agent bubble only after offer-candidates", () => { +test("time-selection cards use server adoption state and stay mutually exclusive with choice cards", () => { const messageLoop = chat.slice( chat.indexOf("{messages.map((message) => {"), chat.indexOf("{savedTime &&"), @@ -543,7 +543,11 @@ test("time-selection cards appear under the latest settled agent bubble only aft assert.match(chat, /turnOfferedSelection/); assert.match(chat, /rectification-offer-candidates/); assert.match(chat, /showLiveChoiceCard = Boolean\(\s*choiceCard[\s\S]*answeredQuestionIds[\s\S]*!busy/); - assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*offeredSelectionOnce[\s\S]*!showLiveChoiceCard[\s\S]*!busy/); + assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*candidateResult\?\.canAdopt[\s\S]*!showLiveChoiceCard[\s\S]*!busy[\s\S]*!readonly/); + assert.doesNotMatch( + chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const selectionCardMessageKey")), + /offeredSelectionOnce/, + ); assert.match( chat, /selectionCardMessageKey = showSelectionCards && latestSettledAssistant\s*\? latestSettledAssistant\.renderKey\s*: undefined/, diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index e1a70bcf..38587631 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -423,6 +423,74 @@ function familyCollectDossier() { }); } +function adoptionInference() { + return buildInferenceState({ + range_start: "04:45", + range_end: "05:15", + candidates: [ + { id: "05:00", time: "05:00", relative_support: 16 }, + { id: "05:10", time: "05:10", relative_support: 14 }, + ], + events: [ + { id: "e1", domain: "career", year: 2020, precision: "month" }, + { id: "e2", domain: "career", year: 2024, precision: "day" }, + { id: "e3", domain: "relationship", year: 2024, precision: "month" }, + { id: "e4", domain: "relationship", year: 2024, precision: "day" }, + ], + probes: [RELOCATION_2015_PROBE], + }); +} + +function adoptionDossier() { + const inference = adoptionInference(); + return dossierFixture({ + evidenceCount: 6, + evidence: [ + ...fourEventRows(), + { + id: "occupation-note", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "occupation_note", + domain: "occupation", + occurred_from: null, + occurred_to: null, + date_precision: "unknown", + summary: "长期从事技术工作", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-28T07:38:00.000Z", + }, + ], + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + representativeTime: "05:00", + decisionReceipt: { inference_state: inference }, + }), + conversationSummary: conversationSummaryFixture({ + declinedSkippedTopics: [{ target_domain: "family", status: "skipped" }], + activeFocus: activeFocusFixture({ + intent: "distinguish_candidates", + targetDomain: "relocation", + targetKind: "home_change", + expectedAnswerSchema: { + choice: { + prompt: RELOCATION_2015_PROBE.question, + options: STYLE_OPTIONS.map((option, index) => ({ + key: (["A", "B", "C", "D"] as const)[index]!, + label: option.label, + answer_class: option.answer_class, + })), + }, + probe_id: RELOCATION_2015_PROBE.id, + semantic_key: RELOCATION_2015_PROBE.semantic_key, + candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash, + }, + }), + }), + }); +} + function persistChoiceAccounting( dossier: ReturnType, extra: Parameters[0] = {}, @@ -515,6 +583,30 @@ test("synthetic choice taps are not kept as chat user lines", () => { assert.equal(isStructuredChoiceUserText("没有,那年很顺利"), false); }); +test("last structured choice emits the adoption range and persists the same narration", async () => { + const accounting = persistChoiceAccounting(adoptionDossier()); + const applied = await applyRectificationChoice(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: ACTION_ID, + action: CHOICE_ACTION, + focusId: FOCUS_ID, + questionId: QUESTION_ID, + probeId: RELOCATION_2015_PROBE.id, + optionId: "A", + expectedRevision: adoptionInference().revision, + }); + assert.equal(applied.nextAction.type, "offer_provisional_range"); + assert.equal(applied.nextAction.can_adopt, true); + assert.match(applied.narration, /当前可信区间是/); + assert.match(applied.narration, /代表分钟/); + assert.match(applied.narration, /代表分钟只是代表性候选,不是已确认的唯一出生分钟。/); + assert.match(applied.narration, /可以从下面的时间里选一个采用/); + const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn"); + assert.match(String(turn?.args.p_assistant_message ?? ""), /可以从下面的时间里选一个采用/); +}); + test("clicking A applies the choice without invoking a language model", async () => { const accounting = choiceAccounting(); const applied = await applyRectificationChoice(accounting.client, { diff --git a/frontend/tests/rectification-candidate-result.test.ts b/frontend/tests/rectification-candidate-result.test.ts index e07d24c7..86e58358 100644 --- a/frontend/tests/rectification-candidate-result.test.ts +++ b/frontend/tests/rectification-candidate-result.test.ts @@ -19,6 +19,7 @@ const camelCaseSnapshot = { ], overallConfidence: "low", selectionAllowed: true, + canAdopt: true, confirmationAllowed: false, representativeTime: null, selectedTime: null, @@ -34,9 +35,24 @@ test("parses the camelCase Candidate Snapshot returned by the Case API", () => { assert.equal(result.candidates[0]?.candidateId, CANDIDATE_ID); assert.equal(result.candidates[0]?.relativeSupport, 34); assert.equal(result.selectionAllowed, true); + assert.equal(result.canAdopt, true); assert.equal(result.confirmationAllowed, false); }); +test("adoption rendering state parses both API spellings and fails closed when absent", () => { + const snake = parseRectificationCandidateResult({ + ...camelCaseSnapshot, + can_adopt: true, + }); + assert.equal(snake?.canAdopt, true); + + const absent = parseRectificationCandidateResult({ + ...camelCaseSnapshot, + canAdopt: undefined, + }); + assert.equal(absent?.canAdopt, false); +}); + test("low-confidence near ties never receive a recommendation badge", () => { const result = parseRectificationCandidateResult(camelCaseSnapshot); diff --git a/frontend/tests/rectification-case-receipt-projection.test.ts b/frontend/tests/rectification-case-receipt-projection.test.ts new file mode 100644 index 00000000..4957960e --- /dev/null +++ b/frontend/tests/rectification-case-receipt-projection.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { slimDecisionReceipt } from "../src/lib/rectification-agentic/v9/case-receipt-projection.ts"; +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { + activeFocusFixture, + candidateSnapshotFixture, + conversationSummaryFixture, + dossierFixture, +} from "./rectification-v9-test-support.ts"; + +test("default Case receipt projection keeps decision and inference state while slimming render-only payloads", () => { + const inference = buildInferenceState({ + range_start: "04:53", + range_end: "05:07", + candidates: [ + { id: "05:00", time: "05:00", relative_support: 50 }, + { id: "05:07", time: "05:07", relative_support: 40 }, + { id: "04:00", time: "04:00", relative_support: 10 }, + ], + events: [], + probes: [], + }); + const fullInference = { + ...inference, + candidates: inference.candidates.map((candidate) => ( + candidate.time === "04:00" ? { ...candidate, status: "eliminated" as const } : candidate + )), + }; + const dossier = parseV9CaseDossier(dossierFixture({ + latestResult: candidateSnapshotFixture({ + representativeTime: "05:00", + decisionReceipt: { inference_state: fullInference }, + }), + conversationSummary: conversationSummaryFixture({ + activeFocus: activeFocusFixture({ + expectedAnswerSchema: { + collect: true, + probe_id: "probe-current", + semantic_key: "current.semantic", + candidate_split_hash: "current-split", + }, + }), + }), + })); + assert.ok(dossier); + const decision = { status: "nonterminal", next: "ask" }; + const receipt = { + decision, + gates: { can_adopt: false, selection_allowed: false }, + inference_state: fullInference, + house_tables_by_time: { + "04:00": { time: "04:00", houses: Array(12).fill({ sign: "x" }) }, + "05:00": { time: "05:00", houses: Array(12).fill({ sign: "x" }) }, + "05:07": { time: "05:07", houses: Array(12).fill({ sign: "x" }) }, + }, + house_table: { time: "05:00", houses: Array(12).fill({ sign: "x" }) }, + discriminating_event_probes: [ + { id: "probe-current", question: "当前" }, + { id: "probe-old", question: "旧" }, + ], + evidence_collection_probes: [ + { semantic_key: "current.semantic", question: "当前" }, + { semantic_key: "old.semantic", question: "旧" }, + ], + }; + + const slim = slimDecisionReceipt(receipt, dossier!); + assert.deepEqual(slim.decision, decision); + assert.deepEqual(slim.gates, receipt.gates); + assert.deepEqual(slim.inference_state, fullInference); + assert.deepEqual(Object.keys(slim.house_tables_by_time as Record).sort(), ["05:00", "05:07"]); + assert.deepEqual((slim.discriminating_event_probes as Array>).map((row) => row.id), ["probe-current"]); + assert.deepEqual((slim.evidence_collection_probes as Array>).map((row) => row.semantic_key), ["current.semantic"]); +}); diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index 87e238fd..819ce01e 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -894,7 +894,6 @@ test("nonterminal turn exit deterministically restores a spoken question", async accounting: ReturnType["client"]; userId: string; caseId: string; - adoptCarrierReady: boolean; }) => Promise<{ hostNarration: string | null; persisted: boolean }>); assert.equal(typeof ensureExit, "function"); const accounting = fakeAccounting({ @@ -908,7 +907,6 @@ test("nonterminal turn exit deterministically restores a spoken question", async accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, - adoptCarrierReady: false, }); assert.equal(repaired.persisted, true); assert.match(repaired.hostNarration ?? "", /当前可信区间/);