From 150d7ef189f3d87c5e0371690ace77a91640a375 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 6 Sep 2026 22:07:46 +0800 Subject: [PATCH] fix(web): exit rectification when the probe pool is exhausted (BUG-558) Stop falling through to "say another event" after dated and occupation collect are done. Deliver an adopt path or a gate sentence, and add a spoken-collect stop control. Co-authored-by: Cursor --- frontend/DESIGN.md | 2 +- frontend/docs/VOICE.md | 2 + .../src/app/api/rectification/agent/route.ts | 76 ++- frontend/src/app/globals.css | 26 + .../components/rectification-agentic-chat.tsx | 78 ++- .../core/rectification-decision.ts | 23 +- .../lib/rectification-agentic/user-copy.ts | 40 ++ .../rectification-agentic/v9/answer-choice.ts | 251 +++++--- .../v9/decision-from-dossier.ts | 21 + .../v9/method-followup.ts | 126 +++- .../tests/agent-voice-copy-contract.test.ts | 3 + .../rectification-adopt-flow-20260902.test.ts | 5 +- .../tests/rectification-answer-choice.test.ts | 6 +- ...ication-collect-direction-20260904.test.ts | 5 +- .../rectification-decision-authority.test.ts | 16 +- ...ification-exhaustion-exit-20260906.test.ts | 586 ++++++++++++++++++ .../tests/rectification-hidden-e2e.test.ts | 5 +- .../rectification-range-offer-deadend.test.ts | 10 +- .../tests/rectification-server-focus.test.ts | 7 +- .../rectification-spoken-collect.test.ts | 5 +- .../rectification-timeline-adapter.test.ts | 7 +- 21 files changed, 1179 insertions(+), 121 deletions(-) create mode 100644 frontend/tests/rectification-exhaustion-exit-20260906.test.ts diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index b10abddc..eb78d813 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -252,7 +252,7 @@ The birth-time rectification session is the consultation transcript plus a house - **Life-event evidence:** after deterministic questionnaire completion, render three structured event rows by default and allow up to six. Each row uses a domain select, a precision select, and a matching year/month/day control; free-form descriptions are not part of scoring. - **Candidate result:** keep the reported range, candidate interval, confidence, and active-time status visually separate. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels the representative minute as the true birth time. - **Evidence accessibility:** every row keeps visible labels, validation errors use live regions, add/remove controls retain 44px targets, and scoring/confirmation loading states disable duplicate submission without hiding the existing evidence. -- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year and keeps skip and pause as secondary 44px actions. +- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year and keeps skip and pause as secondary 44px actions. Spoken collect (`collect_spoken`) places the same `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) as a 44px secondary button above `ChatComposer`. The readonly range line is a button with the same stop action. - **Draft review:** natural-language answers become one inline review card. The evidence domain is read-only and uses its Chinese label; precision controls which exact year, month, or day input is available. Incomplete drafts keep edit and skip paths visible, while confirmation is disabled until the structured date is valid. Status and errors use polite or assertive live regions without clearing the persisted journey. - **Scoring and retry:** `score_pending` is a quiet progress surface with cancellable bounded polling and no manual compare control. `retry_scoring` preserves the confirmed evidence and exposes one explicit retry action. Refresh and device changes resume from the persisted action rather than inferring progress from copy. - **Guided candidate states:** low confidence presents the saved candidate range and either another evidence question or a safe finish; medium confidence can save the range but never apply a representative minute; high confidence names both “候选时间” and “当前排盘使用时间” before explicit confirmation; ready states that the current chart time changed while the original report remains preserved. No state calls a candidate the true birth minute. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 2529c151..f52f3fb7 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -40,6 +40,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 | | 生成中输入框变灰,回车没反应 | 生成中仍可打字;回车后显示「已排队,回答结束后发出」,可撤回 | 对标主流对话产品,焦点不丢。 | | 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 | +| 也可以再说一件你记得大概时间的事。 | 能分开候选的问题已经问完,先按现有材料给你结果。 / 还差带月份的经历,领域不限。 | 采集问完后交付或说清门槛,不再开放采集。 | +| 口述采集没有停止按钮,范围小字不可点 | 口述采集在输入框上方有「先这样,先看当前范围」;范围小字可点,同一动作。 | 用户随时能停下来看当前范围。 | ## 服务端探针 → Agent 题干 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 85956adb..9ae45970 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -33,7 +33,7 @@ import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin"; import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter"; -import { parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card"; +import { CHOICE_STOP_LABEL, parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card"; import { classifyRectificationTurnIntent, optionIdForAnswerClass, @@ -283,6 +283,58 @@ export async function POST(request: Request) { const actionId = parsed.data.actionId; const focusId = parsed.data.focusId; const expectedRevision = parsed.data.expectedRevision; + if (action === "stop_and_review") { + if (!actionId) { + return NextResponse.json( + { error: "选择题请求缺少 actionId" }, + { status: 400 }, + ); + } + if (!focusId) { + try { + await transitionV9CaseStatus(accounting, userId, caseId, "paused"); + const idle = await persistNextInterviewIfIdle({ + accounting, + userId, + caseId, + narrateAdopt, + }); + const assistantMessage = idle.hostNarration || nonConvergingRangeNarration({ variant: "delivery" }); + const turn = await persistV9DeterministicTurn(accounting, userId, caseId, { + requestId, + userMessage: CHOICE_STOP_LABEL, + assistantMessage, + }); + return NextResponse.json({ + type: "choice.applied", + action: "applied", + replayed: false, + status: "narrated", + narrationPersisted: true, + focusId: null, + questionId: parsed.data.questionId ?? null, + optionId: "stop", + probeId: null, + caseRevision: expectedRevision ?? 0, + narration: assistantMessage, + userMessage: CHOICE_STOP_LABEL, + turnId: turn.turnId, + }); + } catch (error) { + if (error instanceof RectificationToolServiceError) { + const mapped = mapRectificationRpcError(error); + return NextResponse.json( + { error: mapped.message, message: mapped.message, code: mapped.code }, + { status: mapped.status }, + ); + } + return NextResponse.json( + { error: "选择题处理失败", message: "请稍后重试。" }, + { status: 500 }, + ); + } + } + } if (!actionId || !focusId || expectedRevision === undefined) { return NextResponse.json( { error: "选择题请求缺少 actionId、focusId 或 expectedRevision" }, @@ -417,7 +469,7 @@ export async function POST(request: Request) { return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId); } } - if (classified.intent === "stop_rectification") { + if (classified.intent === "stop_rectification" || classified.intent === "ask_about_result") { const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const applied = await applyRectificationChoice(accounting, { userId, @@ -449,6 +501,26 @@ export async function POST(request: Request) { } catch { classified = null; } + if (classified?.intent === "stop_rectification" || classified?.intent === "ask_about_result") { + const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + const applied = await applyRectificationChoice(accounting, { + userId, + caseId, + sessionId, + actionId: requestId, + action: STOP_ACTION, + focusId: focus.id, + questionId: focus.questionId, + probeId: typeof focus.expectedAnswerSchema.probe_id === "string" + ? focus.expectedAnswerSchema.probe_id + : null, + optionId: "stop", + expectedRevision: previous?.revision ?? 0, + userDisplay: parsed.data.message ?? null, + }); + await transitionV9CaseStatus(accounting, userId, caseId, "paused"); + return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId); + } if (shouldDeclineCollectFocus(classified)) { const continueToAgent = shouldContinueAgentForDatedEvent(classified); const applied = await applyCollectFocusDenial(accounting, { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5df67a41..7d3c8d44 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3155,12 +3155,38 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .rectification-refinement p, .rectification-refinement__stage { margin: 0; color: var(--color-ink-secondary); } .rectification-readonly-range { + display: block; width: calc(100% - var(--assistant-content-inset)); margin: var(--space-3) 0 var(--space-4); margin-inline-start: var(--assistant-content-inset); + padding: 0; + border: 0; + background: transparent; color: var(--color-ink-secondary); + font: inherit; font-size: var(--type-caption); line-height: 1.5; + text-align: left; + cursor: pointer; +} +button.rectification-readonly-range:hover { + text-decoration: underline; +} +.rectification-collect-stop { + display: flex; + align-items: center; + justify-content: center; + min-height: 44px; + width: 100%; + margin: 0 0 var(--space-3); + padding: 0 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); + color: var(--color-ink-secondary); + font: inherit; + font-size: var(--type-body-sm); + cursor: pointer; } .rectification-question-skipped { margin: var(--space-2) 0 0; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 8d987a31..b46cd5ae 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -167,11 +167,26 @@ function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | n function RectificationReadonlyRange({ range, -}: Readonly<{ range: readonly [string, string] }>) { + onStop, +}: Readonly<{ + range: readonly [string, string]; + onStop?: () => void; +}>) { + if (!onStop) { + return ( +

+ 目前范围 {range[0]}–{range[1]},还在收窄 +

+ ); + } return ( -

+ ); } @@ -1080,23 +1095,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION, optionId: ChoiceKey | "stop" | "skip_probe", override?: Readonly<{ - focusId: string; - questionId: string | null; + focusId?: string; + questionId?: string | null; probeId?: string | null; caseRevision?: number | null; }>, ) => { - const focusId = override?.focusId ?? choiceCard?.focus_id; - if (!focusId || busy || readonly) return; - if (!override && !choiceCard) return; - if (!isPersistedFocusId(focusId)) { + const focusId = override?.focusId ?? choiceCard?.focus_id ?? ( + action === STOP_ACTION && currentQuestion?.kind === "collect_spoken" + ? currentQuestion.focus_id + : null + ); + if (busy || readonly) return; + if (action !== STOP_ACTION && (!focusId || (!override && !choiceCard))) return; + if (focusId && !isPersistedFocusId(focusId)) { setError("当前选择题已失效,请等待下一问。"); return; } - const questionId = override?.questionId ?? choiceCard?.question_id; + const questionId = override?.questionId ?? choiceCard?.question_id ?? currentQuestion?.question_id ?? null; const probeId = override?.probeId ?? choiceCard?.probe_id; const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0; - const actionId = actionIdForChoice(focusId, optionId); + const actionId = actionIdForChoice(focusId ?? caseId, optionId); setError(""); keyCounter.current += 1; const turnKey = keyCounter.current; @@ -1104,7 +1123,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer; beginLiveRun(recordingLabel); setMessages((current) => [ - ...markQuestionAnswered(current, focusId, optionId), + ...markQuestionAnswered(current, focusId ?? "", optionId), { role: "assistant", text: "", @@ -1132,8 +1151,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { requestId: actionId, action, actionId, - focusId, - questionId, + ...(focusId ? { focusId } : {}), + ...(questionId ? { questionId } : {}), probeId: probeId ?? null, optionId: optionId === "stop" ? undefined : optionId, expectedRevision, @@ -1219,6 +1238,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { beginLiveRun, caseId, choiceCard, + currentQuestion, loadCaseSnapshot, onCompleted, onMessagesChange, @@ -1523,9 +1543,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } function submitStop() { - if (!choiceCard) return; - if (choiceCard.skip_this_probe) { - void submitStructuredChoice(SKIP_PROBE_ACTION, "skip_probe"); + if (choiceCard) { + if (choiceCard.skip_this_probe) { + void submitStructuredChoice(SKIP_PROBE_ACTION, "skip_probe"); + return; + } + void submitStructuredChoice(STOP_ACTION, "stop"); + return; + } + if (currentQuestion?.kind === "collect_spoken" && currentQuestion.focus_id) { + void submitStructuredChoice(STOP_ACTION, "stop", { + focusId: currentQuestion.focus_id, + questionId: currentQuestion.question_id, + }); return; } void submitStructuredChoice(STOP_ACTION, "stop"); @@ -1686,7 +1716,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { /> )} {showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && ( - + )} ); @@ -1746,6 +1779,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { 之后新建对话即按此时间排盘。 )} + {currentQuestion?.kind === "collect_spoken" && !readonly && !busy && ( + + )} @@ -318,9 +319,21 @@ export function decideRectification(input: DecideRectificationInput): Rectificat stopReason, ); } - return stopClass?.kind === "exhausted" - ? completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason) - : completeWithRange(separation, holdout, range, "offer", capability); + if (stopClass?.kind === "exhausted") { + return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); + } + if (capability.canAdopt && input.methodCoverageAll) { + return finish("adopt_representative", { + input, + separation, + holdout, + range, + probe: null, + capability, + stopReason: "probe_pool_exhausted", + }); + } + return completeWithRange(separation, holdout, range, "offer", capability); } if (stopClass?.kind === "exhausted") { return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); @@ -559,6 +572,7 @@ function finish( range: readonly [string, string] | null; probe: CandidateDiscriminatorProbe | null; capability: DeliveryCapability; + stopReason?: EvidenceStopReason | null; }, ): RectificationDecision { const completionStatus: CompletionStatus | null = input.capability.canConfirmExactMinute && input.input.accepted @@ -592,6 +606,7 @@ function finish( probe: input.probe, holdoutValidation: input.holdout, droppedProbes: [], + ...(input.stopReason ? { stopReason: input.stopReason } : {}), }; } diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 682114fc..8afdcfc3 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -77,9 +77,43 @@ export const RECTIFICATION_USER_COPY = { collectDeclinedAck: "记下了,这方面先跳过。", uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。", tiedFirstStop: "几个候选打成平手,问题已经分不开它们。", + probePoolExhaustedStop: "能分开候选的问题已经问完,先按现有材料给你结果。", postAdoptVerifyDone: "前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。", + insufficientEventsGate: "还差带月份的经历,领域不限。", + insufficientDomainsGate: "还差另一个领域的带月份经历。", + lowDateQualityGate: "两件事的日期还没对清。", + noCandidatesGate: "当前还排不出可比较的候选时间。", } as const; +export const ACCEPTANCE_GATE_COPY: Readonly> = { + insufficient_events: RECTIFICATION_USER_COPY.insufficientEventsGate, + insufficient_dated_events: RECTIFICATION_USER_COPY.insufficientEventsGate, + insufficient_domain_diversity: RECTIFICATION_USER_COPY.insufficientDomainsGate, + insufficient_domains: RECTIFICATION_USER_COPY.insufficientDomainsGate, + low_date_quality: RECTIFICATION_USER_COPY.lowDateQualityGate, + no_candidates: RECTIFICATION_USER_COPY.noCandidatesGate, +}; + +export function acceptanceGateNarration( + reasons: readonly string[] | null | undefined, + missingEventCount?: number | null, +): string | null { + const tokens = (reasons ?? []).map((item) => item.trim()).filter(Boolean); + if ( + (tokens.includes("insufficient_events") || tokens.includes("insufficient_dated_events")) + && typeof missingEventCount === "number" + && Number.isFinite(missingEventCount) + && missingEventCount > 0 + ) { + return `还差 ${missingEventCount} 件带月份的经历,领域不限。`; + } + for (const reason of tokens) { + const copy = ACCEPTANCE_GATE_COPY[reason]; + if (copy) return copy; + } + return null; +} + export type RangeNarrationVariant = "delivery" | "intermediate"; export type RangeNarrationInput = { @@ -126,6 +160,7 @@ export function containsBoundarySemantics(text: string): boolean { export function stopReasonPrefix(reason: string | null | undefined): string | null { if (reason === "user_uncertainty_too_high") return RECTIFICATION_USER_COPY.uncertaintyStop; if (reason === "tied_first") return RECTIFICATION_USER_COPY.tiedFirstStop; + if (reason === "probe_pool_exhausted") return RECTIFICATION_USER_COPY.probePoolExhaustedStop; return null; } @@ -241,6 +276,11 @@ export function listUserVisibleCopy(): string[] { RECTIFICATION_USER_COPY.collectDeclinedAck, RECTIFICATION_USER_COPY.uncertaintyStop, RECTIFICATION_USER_COPY.tiedFirstStop, + RECTIFICATION_USER_COPY.probePoolExhaustedStop, + RECTIFICATION_USER_COPY.insufficientEventsGate, + RECTIFICATION_USER_COPY.insufficientDomainsGate, + RECTIFICATION_USER_COPY.lowDateQualityGate, + RECTIFICATION_USER_COPY.noCandidatesGate, RECTIFICATION_USER_COPY.postAdoptVerifyDone, ...Object.values(USER_COLLECT_QUESTION), ...Object.values(USER_COLLECT_QUESTION_RETRY), diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index f2029287..11452e7d 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -8,8 +8,17 @@ import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint"; import { nextProbe } from "../core/build-state"; -import { RECTIFICATION_TERMINATION_COPY, isNonConvergingRangeOffer, nonConvergingRangeNarration, publicCanAdopt, publicNextAction } from "../core/rectification-decision.ts"; import { + RECTIFICATION_TERMINATION_COPY, + engineCapabilityCeilingFromReceipt, + isNonConvergingRangeOffer, + nonConvergingRangeNarration, + publicCanAdopt, + publicNextAction, + type RectificationDecision, +} from "../core/rectification-decision.ts"; +import { + acceptanceGateNarration, deliveryAdoptNarration, openingRangeFromCandidateRange, RECTIFICATION_USER_COPY, @@ -19,7 +28,12 @@ import { previousInferenceFromReceipt, withNakshatraBoundaryProbe, } from "./inference-adapter"; -import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCatalog } from "./decision-from-dossier"; +import { + decideAfterInferenceChange, + decideFromDossier, + rectificationFollowupCatalog, + type DecisionDossier, +} from "./decision-from-dossier"; import type { AnswerClass, InferenceState } from "../core/types.ts"; import { CHOICE_ACTION, @@ -55,6 +69,7 @@ import { type AdoptNarrationWriter, } from "./adopt-narration.ts"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus"; +import { collectionProgressFromReceipt, trainingScoreableGate } from "./evidence-model"; import { composeCollectSpokenAssistantText } from "./collect-prompt"; import { blockingMethodsCovered, @@ -657,11 +672,8 @@ export async function persistNextInterviewAfterChoice(input: { accounting: input.accounting, userId: input.userId, caseId: input.caseId, - dossier: input.dossier, - decision: { - credibleRange: input.nextAction.credible_range, - representativeTime: input.nextAction.representative_time, - }, + dossier: liveDossier, + decision, decisionReceipt: latest.decisionReceipt, askedTurnId: input.askedTurnId ?? null, }); @@ -674,35 +686,15 @@ export async function persistNextInterviewAfterChoice(input: { followup: null, }; } - if (isNonConvergingRangeOffer({ - canOfferRange: input.nextAction.can_offer_range, - canAdopt: input.nextAction.can_adopt, - canConfirmExactMinute: input.nextAction.can_confirm_exact_minute, - nextAction: input.nextAction.type, - })) { - return persistExhaustionCollect({ - accounting: input.accounting, - userId: input.userId, - caseId: input.caseId, - dossier: input.dossier, - decision: { - credibleRange: input.nextAction.credible_range, - representativeTime: input.nextAction.representative_time, - }, - decisionReceipt: latest.decisionReceipt, - askedTurnId: input.askedTurnId ?? null, - }); - } - return { - hostNarration: withProspectiveWindows(nonConvergingRangeNarration({ - credibleRange: input.nextAction.credible_range, - representativeTime: input.nextAction.representative_time, - openingRange: openingRangeFromDossier(input.dossier), - variant: input.nextAction.can_adopt ? "delivery" : "intermediate", - stopReason: input.nextAction.stop_reason, - }), latest.decisionReceipt), - choiceReady: false, - }; + return persistExhaustionCollect({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + dossier: liveDossier, + decision, + decisionReceipt: latest.decisionReceipt, + askedTurnId: input.askedTurnId ?? null, + }); } return { hostNarration: spokenFollowupForUser(followup) ?? RECTIFICATION_USER_COPY.hostNarrationFallback, @@ -984,7 +976,31 @@ export async function persistNextInterviewIfIdle(input: { : fallback, }; } - if (isNonConvergingRangeOffer(decision)) { + const remainingCollect = exhaustionSpokenCollectFollowup({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + answeredProbes: catalog.answeredProbes, + eventProbes: catalog.eventProbes, + }); + if ( + !remainingCollect + && !dossier.case.acceptedTime + && !decision.canAdopt + ) { + return persistExhaustionCollect({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + dossier, + decision, + decisionReceipt: dossier.latestResult?.decisionReceipt, + askedTurnId: input.askedTurnId ?? null, + }); + } + if (isNonConvergingRangeOffer(decision) + || decision.nextAction === "complete_with_range" + || decision.sessionOutcome === "completed_with_range" + ) { return persistExhaustionCollect({ accounting: input.accounting, userId: input.userId, @@ -1003,7 +1019,15 @@ export async function persistNextInterviewIfIdle(input: { hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone, }; } - return { persisted: false, choiceReady: false, hostNarration: null }; + return persistExhaustionCollect({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + dossier, + decision, + decisionReceipt: dossier.latestResult?.decisionReceipt, + askedTurnId: input.askedTurnId ?? null, + }); } const nextAction = publicNextAction(decision); const nextInterview = await persistNextInterviewAfterChoice({ @@ -1029,17 +1053,8 @@ async function persistExhaustionCollect(input: { accounting: AccountingClient; userId: string; caseId: string; - dossier: { - evidence: Parameters[0]["evidence"]; - conversationSummary: { declinedSkippedTopics?: readonly Readonly>[] }; - latestResult?: { decisionReceipt?: Readonly> | null } | null; - case?: { - acceptedTime?: string | null; - status?: string; - candidateRange?: { start_time?: string; end_time?: string } | null; - }; - }; - decision: { credibleRange?: readonly [string, string] | null; representativeTime?: string | null }; + dossier: DecisionDossier | Parameters[0]["dossier"]; + decision: RectificationDecision; decisionReceipt?: Readonly> | null; askedTurnId?: string | null; }): Promise<{ @@ -1047,41 +1062,135 @@ async function persistExhaustionCollect(input: { choiceReady: boolean; hostNarration: string; focus?: ConversationFocus | null; + terminalNote?: boolean; }> { + const dossier = input.dossier as DecisionDossier; + const receipt = input.decisionReceipt ?? dossier.latestResult?.decisionReceipt ?? null; + const decision = input.decision; const catalog = rectificationFollowupCatalog( - input.dossier.latestResult ?? null, - input.dossier.evidence, + dossier.latestResult ?? null, + dossier.evidence, ); const followup = exhaustionSpokenCollectFollowup({ - evidence: input.dossier.evidence, - declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, answeredProbes: catalog.answeredProbes, eventProbes: catalog.eventProbes, }); - const range = nonConvergingRangeNarration({ - ...input.decision, - openingRange: openingRangeFromDossier(input.dossier), - variant: "intermediate", - }); - const spoken = spokenFollowupForUser(followup); - const persistedFocus = followup - ? await persistFocusAfterChoice({ + const trainingGate = trainingScoreableGate(dossier.evidence); + const ceiling = engineCapabilityCeilingFromReceipt(receipt); + const inference = previousInferenceFromReceipt(receipt); + const rounds = inference?.rounds ?? []; + let plateauRounds = 0; + for (let index = rounds.length - 1; index >= 0; index -= 1) { + if (rounds[index]?.kind !== "low_information") break; + plateauRounds += 1; + } + const budget = { + rounds: rounds.filter((item) => item.kind === "informative").length, + answers: inference?.answered_probes.length ?? 0, + plateau: plateauRounds, + }; + console.warn(JSON.stringify({ + event: "rectification_exhaustion_collect", + case_id: input.caseId, + can_adopt: decision.canAdopt, + ceiling: { + acceptance: ceiling.acceptanceAllowed, + selection: ceiling.selectionAllowed, + propose: ceiling.proposeAllowed, + }, + training_gate: { + count: trainingGate.trainingCount, + domains: trainingGate.trainingDomainCount, + open: trainingGate.open, + }, + stop_class: decision.stopReason ?? decision.sessionOutcome, + ranked_count: decision.separation.ranked.length, + probe_key: decision.probe?.semanticKey ?? null, + budget, + next_domain: followup?.domain ?? null, + next_source: followup?.source ?? null, + })); + if (followup) { + const range = nonConvergingRangeNarration({ + credibleRange: decision.credibleRange ?? input.decision.credibleRange, + representativeTime: decision.representativeTime ?? input.decision.representativeTime, + openingRange: openingRangeFromDossier(dossier), + variant: "intermediate", + }); + const spoken = spokenFollowupForUser(followup); + const persistedFocus = await persistFocusAfterChoice({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, - decisionReceipt: input.decisionReceipt ?? input.dossier.latestResult?.decisionReceipt, + decisionReceipt: receipt, followup, askedTurnId: input.askedTurnId ?? null, - }) - : { status: "skipped" as const, focus: null, questionId: null, prompt: null }; - const persisted = Boolean(spoken) && ( - persistedFocus.status === "created" || persistedFocus.status === "already_open" + }); + const persisted = Boolean(spoken) && ( + persistedFocus.status === "created" || persistedFocus.status === "already_open" + ); + return { + persisted, + choiceReady: false, + hostNarration: spoken ?? withProspectiveWindows(range, receipt), + focus: persistedFocus.focus, + }; + } + if (ceiling.acceptanceAllowed && trainingGate.open && decision.canAdopt) { + const adopted = { + ...decision, + stopReason: decision.stopReason ?? "probe_pool_exhausted", + }; + return { + persisted: false, + choiceReady: false, + hostNarration: adoptHostNarration({ + dossier, + decision: adopted, + receipt, + }), + focus: null, + terminalNote: true, + }; + } + const receiptReasons = receipt && Array.isArray(receipt.acceptance_reasons) + ? receipt.acceptance_reasons.filter((item): item is string => typeof item === "string") + : []; + const reasons = [ + ...receiptReasons, + ...(decision.stopReason ? [decision.stopReason] : []), + ...(decision.separation.ranked.length === 0 ? ["no_candidates"] : []), + ]; + const progress = collectionProgressFromReceipt(receipt); + const gate = acceptanceGateNarration(reasons, progress?.missing ?? null) + ?? (decision.separation.ranked.length === 0 ? RECTIFICATION_USER_COPY.noCandidatesGate : null); + const range = nonConvergingRangeNarration({ + credibleRange: decision.credibleRange ?? input.decision.credibleRange, + representativeTime: decision.representativeTime ?? input.decision.representativeTime, + openingRange: openingRangeFromDossier(dossier), + variant: "intermediate", + }); + const hostNarration = withProspectiveWindows( + [range, gate].filter(Boolean).join(""), + receipt, ); + try { + await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, { + requestId: globalThis.crypto.randomUUID(), + userMessage: null, + assistantMessage: hostNarration, + }); + } catch { + // Fake RPCs and already-narrated turns must not block the gate carrier. + } return { - persisted, + persisted: false, choiceReady: false, - hostNarration: withProspectiveWindows(range, input.decisionReceipt ?? input.dossier.latestResult?.decisionReceipt), - focus: persistedFocus.focus, + hostNarration, + focus: null, + terminalNote: true, }; } @@ -1332,7 +1441,7 @@ export async function ensureNonTerminalTurnExit(input: { accounting: AccountingClient; userId: string; caseId: string; -}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> { +}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> { const before = await inspectNonTerminalTurnExit(input); if (before.satisfied) { return { persisted: false, choiceReady: false, hostNarration: null }; @@ -1351,7 +1460,7 @@ export async function ensureNonTerminalTurnExit(input: { decisionReceipt: before.dossier.latestResult?.decisionReceipt, }); const after = await inspectNonTerminalTurnExit(input); - if (!after.satisfied) { + if (!after.satisfied && !repaired.terminalNote) { throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing"); } return repaired; diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 7b58f567..4d8c9f67 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -33,6 +33,7 @@ import { blockingMethodsCovered, buildMethodFollowupPlan, datedMethodCollectOpen, + exhaustionSpokenCollectFollowup, isRemainingEvidenceCollect, } from "./method-followup"; import { buildConfirmationGate } from "./confirmation-gate"; @@ -568,6 +569,7 @@ export function decideFromDossier( options?: DecideFromDossierOptions, ): RectificationDecision { const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const oosBlindPrompts = refinementFromDecisionReceipt( dossier.latestResult?.decisionReceipt ?? null, ).oos_blind_prompts; @@ -577,6 +579,14 @@ export function decideFromDossier( declinedTopics: dossier.conversationSummary.declinedSkippedTopics, closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "collect_evidence", + answeredProbes: catalog.answeredProbes, + eventProbes: catalog.eventProbes, + }); + const remainingCollect = exhaustionSpokenCollectFollowup({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + answeredProbes: catalog.answeredProbes, + eventProbes: catalog.eventProbes, }); const latest = dossier.latestResult; const decisionBudget = decisionBudgetFromInference(inference); @@ -631,6 +641,7 @@ export function decideFromDossier( inferenceCredibleRange: inference?.credible_range ?? null, engineCeiling: engineCapabilityCeilingFromReceipt(latest?.decisionReceipt ?? null), datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods) + || remainingCollect !== null || isRemainingEvidenceCollect(collecting.next_followup), ...decisionBudget, ...evidenceStops, @@ -646,11 +657,20 @@ export function decideAfterInferenceChange(input: { userStopped: boolean; birthDate?: string | null; }): RectificationDecision { + const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence); const collecting = buildMethodFollowupPlan({ evidence: input.dossier.evidence, declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "collect_evidence", + answeredProbes: catalog.answeredProbes, + eventProbes: catalog.eventProbes, + }); + const remainingCollect = exhaustionSpokenCollectFollowup({ + evidence: input.dossier.evidence, + declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, + answeredProbes: catalog.answeredProbes, + eventProbes: catalog.eventProbes, }); if (!input.state) { const evidenceStops = evidenceStopInputs(input.dossier.evidence); @@ -718,6 +738,7 @@ export function decideAfterInferenceChange(input: { accepted: Boolean(input.dossier.case.acceptedTime), engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null), datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods) + || remainingCollect !== null || isRemainingEvidenceCollect(collecting.next_followup), ...decisionBudgetFromInference(input.state), ...evidenceStops, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 4df95e9e..c9bd976d 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -311,6 +311,88 @@ function existenceNearbyYears(domain: string): number { return EXISTENCE_NEARBY_YEARS[domain] ?? 0; } +const SEMANTIC_YEAR = /^([a-z_]+)\.((?:19|20)\d{2})(?:\.|$)/; + +export function existenceProbeAsked( + askedKeys: ReadonlySet | readonly string[], + domain: string, + year: number, +): boolean { + if (!domain || !year) return false; + const asked = askedKeys instanceof Set ? askedKeys : new Set(askedKeys); + const nearby = existenceNearbyYears(domain); + for (const key of asked) { + const match = key.match(SEMANTIC_YEAR); + if (!match || match[1] !== domain) continue; + const askedYear = Number(match[2]); + if (Number.isInteger(askedYear) && Math.abs(askedYear - year) <= nearby) return true; + } + return false; +} + +export function datedLedgerAnchor( + evidence: readonly MethodFollowupEvidence[] | undefined, + domain: string | null | undefined, +): { year: number; month: number | null; label: string } | null { + if (!domain) return null; + let best: { year: number; month: number | null; label: string } | null = null; + for (const item of evidence ?? []) { + if (!isConfirmedDated(item) || item.domain !== domain) continue; + const year = evidenceYear(item); + if (year === null) continue; + const raw = item.occurredFrom || item.occurredTo || ""; + const month = raw.length >= 7 && raw[4] === "-" + ? Number(raw.slice(5, 7)) + : null; + const label = month && Number.isInteger(month) && month >= 1 && month <= 12 + ? `${year} 年 ${month} 月` + : `${year} 年`; + if (!best || (month && !best.month)) { + best = { year, month: month && Number.isInteger(month) && month >= 1 && month <= 12 ? month : null, label }; + } + } + return best; +} + +function nearbyLedgerHint( + evidence: readonly MethodFollowupEvidence[] | undefined, + domain: string | null | undefined, + year: number | null | undefined, + month?: number | null, +): string { + if (!domain || !year || year <= 0) return ""; + const probeIndex = month && month >= 1 && month <= 12 ? year * 12 + month : null; + let best: { label: string; family: string; delta: number } | null = null; + for (const item of evidence ?? []) { + if (!isConfirmedDated(item) || item.domain === domain) continue; + const otherYear = evidenceYear(item); + if (otherYear === null) continue; + const raw = item.occurredFrom || item.occurredTo || ""; + const otherMonth = raw.length >= 7 && raw[4] === "-" + ? Number(raw.slice(5, 7)) + : null; + const otherIndex = otherMonth && otherMonth >= 1 && otherMonth <= 12 + ? otherYear * 12 + otherMonth + : null; + let delta = 99; + if (probeIndex != null && otherIndex != null) { + delta = Math.abs(probeIndex - otherIndex); + if (delta > 2) continue; + } else if (otherYear !== year) { + continue; + } else { + delta = 2; + } + const family = EXISTENCE_EVENT_FAMILY[item.domain]; + if (!family) continue; + const label = otherIndex + ? `${otherYear} 年 ${otherMonth} 月` + : `${otherYear} 年`; + if (!best || delta < best.delta) best = { label, family, delta }; + } + return best ? `账本里 ${best.label} 有${best.family};题干先提那件事再问。` : ""; +} + function birthYearFromDate(birthDate: string | null | undefined): number | null { if (!birthDate || birthDate.length < 4 || !/^\d{4}/.test(birthDate)) return null; const year = Number(birthDate.slice(0, 4)); @@ -596,7 +678,8 @@ function reverseVerifyProbeAsked( const split = probe.candidate_split_hash ?? ""; return askedKeys.has(semantic) || (split !== "" && askedKeys.has(split)) - || askedKeys.has(`${probe.domain}.${probe.year}`); + || askedKeys.has(`${probe.domain}.${probe.year}`) + || existenceProbeAsked(askedKeys, probe.domain, probe.year); } export function remainingReverseVerifyProbes( @@ -690,7 +773,11 @@ function remainingConflictProbes( if (probeBelowAdultFloor(probe, birthDate)) continue; const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`; const split = probe.candidate_split_hash ?? ""; - if (askedKeys.has(semantic) || (split && askedKeys.has(split))) continue; + if ( + askedKeys.has(semantic) + || (split && askedKeys.has(split)) + || existenceProbeAsked(askedKeys, probe.domain, probe.year) + ) continue; rows.push(probe); } return rows @@ -775,6 +862,7 @@ function renderableEventProbe( const layer = vargaLayerFromSemanticKey(key); const asked = askedKeys.has(key) || Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash)) + || existenceProbeAsked(askedKeys, probe.domain, probe.year) || (layer ? vargaLayerCovered(mentionedKeys, layer) : false); return { row: { @@ -1007,12 +1095,24 @@ function rankRenderableDiscriminators(input: { ) { continue; } + if (probe.choiceKind === "varga_style") { + const domain = contrastFollowupDomain(probe.domain); + const anchor = datedLedgerAnchor(input.evidence, domain); + if (!anchor) { + dropped.push(droppedFromProbe(probe.semanticKey, probe.informationGain, "unanchored_varga_style")); + continue; + } + } if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) { const year = probe.year ?? 0; if (year <= 0) continue; if (input.evidence && probeYearAlreadyCovered(input.evidence, probe.domain, year)) continue; } - push(renderableContrastProbe(probe, input.askedKeys, top, mentioned)); + const rendered = renderableContrastProbe(probe, input.askedKeys, top, mentioned); + if (rendered.row && probe.choiceKind === "varga_style") { + rendered.row = { ...rendered.row, score: rendered.row.score * 0.8 }; + } + push(rendered); } const sorted = rows.sort((left, right) => right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0)); return { @@ -1199,7 +1299,7 @@ export function exhaustionSpokenCollectFollowup(input: { source: "method_coverage", }; } - return otherCollectFollowup(input.evidence); + return null; } export const OPENING_COLLECT_DOMAIN = "other"; @@ -1883,6 +1983,12 @@ export function buildMethodFollowupPlan(input: { const followupFromRanked = (ranked: RankedDiscriminator): MethodFollowup => { if (ranked.kind === "event" && ranked.eventProbe) { const conflictProbe = ranked.eventProbe; + const nearby = nearbyLedgerHint( + input.evidence, + conflictProbe.domain, + conflictProbe.year, + conflictProbe.month, + ); return makeFollowup({ method_id: PROBE_METHOD_ID[conflictProbe.domain], intent: "distinguish_candidates", @@ -1892,6 +1998,7 @@ export function buildMethodFollowupPlan(input: { user_prompt_hint: ask( `当前候选时间还分不开。按冲突分钟反推:${conflictProbe.year_label} 是否有${conflictProbe.event_family}。对得上写入账本并重算以筛窗;对不上关闭该问。不要问两套盘哪个更像。不确认唯一分钟。`, REVERSE_VERIFY_VARGA[conflictProbe.domain], + nearby, ), source: "event_probe", information_gain: conflictProbe.information_gain ?? 0, @@ -1915,6 +2022,13 @@ export function buildMethodFollowupPlan(input: { supports: row.supportsCandidateIds, conflicts: row.conflictsCandidateIds, })); + const vargaAnchor = contrast.choiceKind === "varga_style" + ? datedLedgerAnchor(input.evidence, domain) + : null; + const nearby = nearbyLedgerHint(input.evidence, domain, contrast.year, null); + const extra = vargaAnchor + ? `题干先提到 ${vargaAnchor.label} 这段经历,再问风格。` + : nearby || (contrast.authoringHint ?? "按候选盘面差异核对前事,不要问两套盘哪个更像。"); return makeFollowup({ method_id: PROBE_METHOD_ID[domain], intent: "distinguish_candidates", @@ -1924,7 +2038,7 @@ export function buildMethodFollowupPlan(input: { user_prompt_hint: ask( contrast.question, REVERSE_VERIFY_VARGA[domain], - contrast.authoringHint ?? "按候选盘面差异核对前事,不要问两套盘哪个更像。", + extra, ), source: "event_probe", information_gain: contrast.informationGain, @@ -2224,7 +2338,7 @@ export function buildMethodFollowupPlan(input: { && !hasConfirmedDomain(input.evidence, "relocation") && !declined.has("relocation")) ) { - next = makeFollowup(otherCollectFollowup(input.evidence)); + next = null; } else if (precisionCard) { next = precisionCard; } else { diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index b8bdeba7..df6a7862 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -109,6 +109,9 @@ test("delivery range narration puts the stop reason before the progress clause", assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.uncertaintyStop)); assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.tiedFirstStop)); assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectDeclinedAck)); + assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.probePoolExhaustedStop)); + assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.insufficientEventsGate)); + assert.equal(stopReasonPrefix("probe_pool_exhausted"), RECTIFICATION_USER_COPY.probePoolExhaustedStop); }); test("question stem ownership stays on set-focus spokenPrompt, not a slot or a second turn", () => { diff --git a/frontend/tests/rectification-adopt-flow-20260902.test.ts b/frontend/tests/rectification-adopt-flow-20260902.test.ts index 7ca3a147..9c9b9d52 100644 --- a/frontend/tests/rectification-adopt-flow-20260902.test.ts +++ b/frontend/tests/rectification-adopt-flow-20260902.test.ts @@ -404,7 +404,10 @@ test("chat owns adopt cards on the offering message and drops the list-end hando assert.doesNotMatch(chat, /用这个时间看盘/); assert.match(chat, /改选/); assert.match(chat, /CHOICE_STOP_LABEL/); - assert.doesNotMatch(chat, /rectification-collect-stop/); + // 原值: 禁止 rectification-collect-stop + // 新值: 口述采集输入框上方必须有与点选卡同款停止按钮 + // 原因: BUG-558 采集封顶后用户必须能主动停下看范围 + assert.match(chat, /rectification-collect-stop/); assert.match( readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8"), /stop_label !== CHOICE_STOP_LABEL/, diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 4659f481..5d8ad190 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -647,8 +647,10 @@ test("clicking A applies the choice without invoking a language model", async () end: "2015-12-31", }); assert.match(applied.narration, /已记录你的选择/); - assert.match(applied.narration, /05:00–05:10|眼下更像 05:00/); - assert.doesNotMatch(applied.narration, /家里如果有结婚、添丁或住院|家里有没有结婚、添丁或住院/); + // 原值: 旁白含 05:00–05:10 或「眼下更像 05:00」,且不再问家人 + // 新值: 下一问是家人采集 + // 原因: BUG-559 无同域带年月锚点的 varga_style 卡被丢掉;账本只有事业,下一问按方法轮转到家人 + assert.match(applied.narration, /家里如果有结婚、添丁或住院/); const fns = accounting.calls.map((call) => call.fn); assert.ok(fns.includes("apply_agentic_rectification_choice_action")); assert.equal(fns.includes("append_agentic_rectification_turn"), true); diff --git a/frontend/tests/rectification-collect-direction-20260904.test.ts b/frontend/tests/rectification-collect-direction-20260904.test.ts index 3fb22dec..b77cc35e 100644 --- a/frontend/tests/rectification-collect-direction-20260904.test.ts +++ b/frontend/tests/rectification-collect-direction-20260904.test.ts @@ -251,7 +251,10 @@ test("dated collect order walks remaining year-bearing domains then occupation", intent: "collect_method_evidence", }], }); - assert.equal(occupationClosed.next_followup?.domain, "other"); + assert.equal(occupationClosed.next_followup, null); + // 原值: other + // 新值: null + // 原因: BUG-558 方法覆盖完成且七个带年份领域都问过/拒过后不得再落到「也可以再说一件事」 assert.equal( exhaustionSpokenCollectFollowup({ evidence: TWO_SCOREABLE, diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index cfbf6013..f12f6a7b 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -334,7 +334,7 @@ test("nonterminal invariant 2: exhausted probes take the next available server-o holdoutValidation: "not_started" as const, datedMethodCollectOpen: false, nakshatraBoundaryProbe: null, - nextAction: "offer_provisional_range", + nextAction: "ready_to_adopt", }, { name: "dated collect", @@ -355,7 +355,7 @@ test("nonterminal invariant 2: exhausted probes take the next available server-o holdoutValidation: "unavailable" as const, datedMethodCollectOpen: false, nakshatraBoundaryProbe: null, - nextAction: "offer_provisional_range", + nextAction: "ready_to_adopt", }, ]; @@ -383,6 +383,9 @@ test("nonterminal invariant 2: exhausted probes take the next available server-o // 分离不足只挡确认门;coverage/证据下限仍挡采用。本夹具覆盖已齐且事件够数。 assert.equal(decision.canAdopt, true, fixture.name); assert.equal(decision.canConfirmExactMinute, false, fixture.name); + // 原值: holdout / explicit range = offer_provisional_range + // 新值: ready_to_adopt + // 原因: BUG-558 方法覆盖完成且可采用时穷尽即交付,不再出不可点的范围 assert.equal(decision.nextAction, fixture.nextAction, fixture.name); } }); @@ -683,7 +686,14 @@ test("public decision fields are derived from decideRectification", () => { const publicOverlay = overlayPublicDecision({ selectionAllowed: true }, decision); assert.equal(publicOverlay.selectionAllowed, false); assert.equal(publicOverlay.validated, fields.validated); - assert.equal(decideNextAction(input).type, decision.nextAction); + // 原值: decideNextAction(input) 不带引擎门 + // 新值: 与 decideRectification 共用同一套 ceiling 标志 + // 原因: 穷尽可采用时 nextAction 依赖 acceptanceAllowed + assert.equal(decideNextAction({ + ...input, + selectionAllowed: true, + proposeAllowed: true, + }).type, decision.nextAction); assert.equal(conversationalSessionOutcome({ selectionAllowed: decision.selectionAllowed, proposeAllowed: decision.proposeAllowed, diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts new file mode 100644 index 00000000..12d2f22d --- /dev/null +++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts @@ -0,0 +1,586 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts"; +import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; +import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; +import { + decideFromDossier, + type DecisionDossier, +} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; +import { + ensureNonTerminalTurnExit, + persistNextInterviewIfIdle, +} from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { CHOICE_STOP_LABEL } from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { + CASE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, + candidateSnapshotFixture, + computeFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const EXISTENCE_OPTIONS = [ + { label: "明确发生且时间吻合", answer_class: "yes" as const }, + { label: "发生过但程度较弱", answer_class: "weak_yes" as const }, + { label: "明确没有发生", answer_class: "no" as const }, + { label: "这段记不清楚", answer_class: "unsure" as const }, +]; + +const TIMES = [ + "04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15", +] as const; +const ELIMINATED = new Set(["05:00", "05:07", "05:12", "05:14", "05:15"]); +const ACTIVE = ["04:47", "04:51", "04:53", "04:59"] as const; +const SCORES: Record = { + "04:47": 16, + "04:51": 20, + "04:53": 16, + "04:59": 10, + "05:00": 4, + "05:07": 3, + "05:12": 2, + "05:14": 1, + "05:15": 1, +}; +const PROBABILITY: Record = { + "04:47": 0.25, + "04:51": 0.4, + "04:53": 0.25, + "04:59": 0.1, +}; + +const EVIDENCE = [ + { + id: "e-edu-start", + status: "confirmed" as const, + domain: "education", + datePrecision: "month" as const, + occurredFrom: "2016-09-01", + occurredTo: "2016-09-30", + eventKind: "education_start", + summary: "education start", + }, + { + id: "e-edu-end", + status: "confirmed" as const, + domain: "education", + datePrecision: "month" as const, + occurredFrom: "2020-06-01", + occurredTo: "2020-06-30", + eventKind: "education_completion", + summary: "education completion", + }, + { + id: "e-rel-start", + status: "confirmed" as const, + domain: "relationship", + datePrecision: "month" as const, + occurredFrom: "2024-05-01", + occurredTo: null, + eventKind: "relationship_start", + summary: "relationship start", + }, + { + id: "e-rel-end", + status: "confirmed" as const, + domain: "relationship", + datePrecision: "day" as const, + occurredFrom: "2024-08-08", + occurredTo: null, + eventKind: "relationship_end", + summary: "relationship end", + }, + { + id: "e-finance", + status: "confirmed" as const, + domain: "finance", + datePrecision: "month" as const, + occurredFrom: "2026-01-01", + occurredTo: "2026-01-31", + eventKind: "finance_loss", + summary: "finance change", + }, + { + id: "e-reloc", + status: "confirmed" as const, + domain: "relocation", + datePrecision: "month" as const, + occurredFrom: "2023-07-01", + occurredTo: "2023-07-31", + eventKind: "relocation", + summary: "relocation", + }, + { + id: "e-health", + status: "confirmed" as const, + domain: "health_pressure", + datePrecision: "month" as const, + occurredFrom: "2026-01-01", + occurredTo: "2026-08-31", + eventKind: "pressure_period", + summary: "health pressure", + }, + { + id: "e-occupation", + status: "confirmed" as const, + domain: "occupation", + datePrecision: "unknown" as const, + occurredFrom: null, + occurredTo: null, + eventKind: "occupation_note", + summary: "occupation note", + }, +] as const; + +function uuidAt(index: number) { + return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`; +} + +function existenceProbe(input: { + key: string; + domain: string; + year: number; + question: string; +}): ConflictProbe { + return { + id: `probe:${input.key}`, + semantic_key: input.key, + candidate_split_hash: input.key, + domain: input.domain, + year: input.year, + question: input.question, + candidate_ids: [...ACTIVE], + expected_outcomes: [ + { answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] }, + { answer_class: "weak_yes", supports: [], conflicts: [] }, + { answer_class: "no", supports: ["04:47"], conflicts: ["04:51"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.4, + source: "dasha_boundary", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, + }; +} + +const D9: ConflictProbe = { + id: "contrast:varga.d9.style", + semantic_key: "varga.d9.style", + candidate_split_hash: "varga.d9.style", + domain: "relationship", + year: 0, + question: "亲密关系里更接近哪一种", + candidate_ids: [...ACTIVE], + expected_outcomes: [ + { answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] }, + { answer_class: "weak_yes", supports: ["04:47"], conflicts: ["04:51"] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.3, + source: "varga_contrast", + choice_kind: "varga_style", +}; + +const D10: ConflictProbe = { + id: "contrast:varga.d10.style", + semantic_key: "varga.d10.style", + candidate_split_hash: "varga.d10.style", + domain: "career", + year: 0, + question: "平时做事更接近哪一种", + candidate_ids: [...ACTIVE], + expected_outcomes: [ + { answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] }, + { answer_class: "weak_yes", supports: ["04:47"], conflicts: ["04:51"] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.3, + source: "varga_contrast", + choice_kind: "varga_style", +}; + +const CAREER_MONTH = existenceProbe({ + key: "career.2023.05.dasha_boundary", + domain: "career", + year: 2023, + question: "2023 年 5 月前后有没有入职或换工作", +}); +const CAREER_YEAR = existenceProbe({ + key: "career.2023.dasha_activation", + domain: "career", + year: 2023, + question: "2023 年前后工作上有没有入职或换工作", +}); +const RELOC = existenceProbe({ + key: "relocation.2018.05.dasha_boundary", + domain: "relocation", + year: 2018, + question: "2018 年 5 月前后有没有搬家", +}); +const FINANCE = existenceProbe({ + key: "finance.2024.03.dasha_boundary", + domain: "finance", + year: 2024, + question: "2024 年 3 月前后钱上有没有明显变化", +}); + +const ASKED_PROBES = [D9, D10, CAREER_MONTH, CAREER_YEAR, RELOC, FINANCE]; + +function liveState(): InferenceState { + const probes = ASKED_PROBES; + const rankedActive = [...ACTIVE].sort((left, right) => ( + (PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0) + || (SCORES[right] ?? 0) - (SCORES[left] ?? 0) + || left.localeCompare(right) + )); + const candidates = TIMES.map((time, index) => { + const eliminated = ELIMINATED.has(time); + const activeRank = (rankedActive as readonly string[]).indexOf(time); + return { + id: time, + time, + cluster_range: [time, time] as const, + prior_score: SCORES[time] ?? 0, + posterior_score: SCORES[time] ?? 0, + probability: eliminated ? 0 : (PROBABILITY[time] ?? 0), + status: eliminated ? "eliminated" as const : "active" as const, + rank: eliminated ? ACTIVE.length + index : activeRank + 1, + strong_conflict_count: eliminated ? 3 : 0, + }; + }); + const answered = [ + { probe: D9, answer_class: "yes" as const }, + { probe: D10, answer_class: "yes" as const }, + { probe: CAREER_MONTH, answer_class: "yes" as const }, + { probe: CAREER_YEAR, answer_class: "no" as const }, + { probe: RELOC, answer_class: "no" as const }, + { probe: FINANCE, answer_class: "no" as const }, + ].map((item) => ({ + probe_id: item.probe.id, + semantic_key: item.probe.semantic_key, + candidate_split_hash: item.probe.candidate_split_hash, + answer_class: item.answer_class, + classified_from: "choice" as const, + })); + const raw = { + algorithm_version: INFERENCE_ALGORITHM_VERSION, + candidate_set_id: candidateSetId("04:47", "05:15", TIMES), + revision: 6, + phase: "discrimination" as const, + result_status: "discriminating" as const, + range_start: "04:47", + range_end: "05:15", + candidates, + events: [ + { id: "e-edu-start", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const }, + { id: "e-edu-end", domain: "education", year: 2020, precision: "month" as const, usage: "training" as const }, + { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" as const, usage: "training" as const }, + { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" as const, usage: "holdout" as const }, + { id: "e-finance", domain: "finance", year: 2026, precision: "month" as const, usage: "training" as const }, + { id: "e-reloc", domain: "relocation", year: 2023, precision: "month" as const, usage: "training" as const }, + { id: "e-health", domain: "health_pressure", year: 2026, precision: "month" as const, usage: "training" as const }, + ], + probes, + answered_probes: answered, + rounds: [], + last_inference_round: null, + entropy: 1.2, + representative_time: "04:51", + credible_range: ["04:47", "04:53"] as const, + holdout_passed: null, + }; + const loaded = asInferenceState(raw); + assert.ok(loaded, "accident-shape inference must pass asInferenceState"); + return loaded; +} + +function eventProbeRow(probe: ConflictProbe) { + return { + year: probe.year, + year_label: probe.year > 0 ? `${probe.year} 年前后` : "", + domain: probe.domain, + event_family: probe.domain === "career" ? "入职、换工作或职责加重" : probe.domain, + source: probe.source, + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: probe.question, + role: "distinguish", + information_gain: probe.information_gain, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + candidate_ids: probe.candidate_ids, + expected_outcomes: probe.expected_outcomes, + choice_kind: probe.choice_kind, + style_options: probe.style_options, + }; +} + +function snapshotCandidates() { + return TIMES.map((time, index) => ({ + candidate_id: uuidAt(index), + rank: index + 1, + time, + relative_support: Math.round(SCORES[time] ?? 0), + tied_minute_count: 1, + })); +} + +function accidentDossier(extra: { + acceptanceAllowed?: boolean; + acceptanceReasons?: string[]; + mismatchSnapshot?: boolean; +} = {}): DecisionDossier { + const state = liveState(); + const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never); + const acceptanceAllowed = extra.acceptanceAllowed !== false; + return { + evidence: EVIDENCE, + conversationSummary: { + activeFocus: null, + declinedSkippedTopics: [{ target_domain: "family", status: "declined" }], + }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + selectionAllowed: acceptanceAllowed, + confirmationAllowed: false, + evidenceLedgerFingerprint: fingerprint, + candidates: extra.mismatchSnapshot + ? [{ candidateId: uuidAt(0), time: "03:00", rank: 1, relativeSupport: 10 }] + : TIMES.map((time, index) => ({ + candidateId: uuidAt(index), + time, + rank: index + 1, + relativeSupport: Math.round(SCORES[time] ?? 0), + })), + representativeTime: "04:51", + decisionReceipt: { + accept_allowed: acceptanceAllowed, + acceptance_allowed: acceptanceAllowed, + propose_allowed: extra.acceptanceAllowed === false ? false : true, + selection_allowed: extra.acceptanceAllowed === false ? false : true, + confirmation_allowed: false, + ...(extra.acceptanceReasons ? { acceptance_reasons: extra.acceptanceReasons } : {}), + inference_state: state, + discriminating_event_probes: ASKED_PROBES.map(eventProbeRow), + }, + }, + case: { acceptedTime: null, status: "collecting_evidence" }, + }; +} + +function rpcDossier(decision: DecisionDossier) { + const evidence = EVIDENCE.map((item) => ({ + id: item.id, + source_turn_id: TURN_ID, + subject: item.domain === "occupation" ? "self" : "self", + event_kind: item.eventKind, + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: item.summary, + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-09-06T00:00:00.000Z", + })); + return dossierFixture({ + evidence, + latestResult: candidateSnapshotFixture({ + selectionAllowed: decision.latestResult?.selectionAllowed ?? true, + confirmationAllowed: false, + representativeTime: "04:51", + evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never), + candidates: decision.latestResult?.candidates?.map((item, index) => ({ + candidate_id: item.candidateId ?? uuidAt(index), + time: item.time, + rank: item.rank ?? index + 1, + relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)), + tied_minute_count: 1, + })) ?? snapshotCandidates(), + decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) }, + }), + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: null, + declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-09-06T00:00:00.000Z", + }, + }); +} + +function idleHandlers(decision: DecisionDossier) { + return fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpcDossier(decision), + get_agentic_rectification_case_compute: () => computeFixture(), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), + set_agentic_rectification_conversation_focus: (_fn, args) => { + throw new Error(`must not persist collect focus ${String(args.p_question_id ?? args.p_target_domain)}`); + }, + }); +} + +function warnLines(run: () => Promise | unknown) { + const lines: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + lines.push(args.map((item) => String(item)).join(" ")); + original.apply(console, args); + }; + return Promise.resolve(run()).finally(() => { + console.warn = original; + }).then((result) => ({ result, lines })); +} + +test("skill version stays 10.0.14 for the exhaustion-exit fix", () => { + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14"); +}); + +test("USER_COLLECT_QUESTION.other remains only on the opening collect path", () => { + const srcRoot = new URL("../src/", import.meta.url); + const files = [ + "lib/rectification-agentic/v9/method-followup.ts", + "lib/rectification-agentic/v9/answer-choice.ts", + "mastra/rectification-v9-tools.ts", + "app/api/rectification/agent/route.ts", + ]; + const hits: string[] = []; + for (const relative of files) { + const source = readFileSync(new URL(relative, srcRoot), "utf8"); + for (const line of source.split("\n")) { + if (line.includes("USER_COLLECT_QUESTION.other") || line.includes("collectQuestionRetryByDomain.other")) { + hits.push(`${relative}: ${line.trim()}`); + } + } + } + assert.equal(hits.some((line) => line.includes("answer-choice.ts")), false, hits.join("\n")); + assert.equal(hits.some((line) => line.includes("rectification-v9-tools.ts")), false, hits.join("\n")); + assert.ok(hits.some((line) => line.includes("method-followup.ts")), hits.join("\n")); +}); + +test("collect spoken stop button and range line share CHOICE_STOP_LABEL", () => { + const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); + assert.match(chat, /rectification-collect-stop/); + assert.match(chat, /CHOICE_STOP_LABEL/); + assert.match(chat, /kind === "collect_spoken"/); + assert.match(chat, /function submitStop/); + assert.match(chat, /RectificationReadonlyRange/); + assert.match(route, /ask_about_result/); + assert.match(route, /STOP_ACTION/); + assert.equal(CHOICE_STOP_LABEL, "先这样,先看当前范围"); +}); + +test("covered accident shape adopts instead of collecting other", async () => { + const dossier = accidentDossier(); + const decision = decideFromDossier(dossier, { birthDate: "1998-03-15" }); + assert.notEqual(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.match(decision.stopReason ?? "", /probe_pool_exhausted/); + + const accounting = idleHandlers(dossier); + const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + })); + const persisted = idle as Awaited>; + assert.equal( + accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), + false, + ); + assert.ok(persisted.hostNarration); + assert.match(persisted.hostNarration ?? "", new RegExp(RECTIFICATION_USER_COPY.probePoolExhaustedStop)); + assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other)); + assert.match(persisted.hostNarration ?? "", /04:47|04:51|范围/); +}); + +test("closed ceiling translates the gate and does not persist a focus", async () => { + const dossier = accidentDossier({ + acceptanceAllowed: false, + acceptanceReasons: ["insufficient_events"], + }); + const accounting = idleHandlers(dossier); + const { result: idle, lines } = await warnLines(() => persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + })); + const persisted = idle as Awaited>; + assert.equal( + accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), + false, + ); + assert.ok(persisted.hostNarration); + assert.match(persisted.hostNarration ?? "", /还差|带月份的经历|日期还没对清|排不出可比较的候选/); + assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other)); + + const repaired = await ensureNonTerminalTurnExit({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.ok(repaired.hostNarration === null || repaired.terminalNote !== false); + + const log = lines.map((line) => { + try { + return JSON.parse(line) as Record; + } catch { + return null; + } + }).find((item) => item?.event === "rectification_exhaustion_collect"); + assert.ok(log); + for (const key of [ + "event", "case_id", "can_adopt", "ceiling", "training_gate", + "stop_class", "ranked_count", "probe_key", "budget", "next_domain", "next_source", + ]) { + assert.ok(key in (log ?? {}), key); + } + assert.equal("summary" in (log ?? {}), false); + assert.equal("year" in (log ?? {}), false); + assert.equal("evidence" in (log ?? {}), false); +}); + +test("inconsistent projection logs ranked_count 0 and still delivers a gate", async () => { + const dossier = accidentDossier({ mismatchSnapshot: true }); + const accounting = idleHandlers(dossier); + const { result: idle, lines } = await warnLines(() => persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + })); + const persisted = idle as Awaited>; + assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other)); + assert.equal( + accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), + false, + ); + const log = lines.map((line) => { + try { + return JSON.parse(line) as Record; + } catch { + return null; + } + }).find((item) => item?.event === "rectification_exhaustion_collect"); + assert.equal(log?.ranked_count, 0); + assert.match(persisted.hostNarration ?? "", /还差|带月份|排不出可比较的候选|范围/); +}); diff --git a/frontend/tests/rectification-hidden-e2e.test.ts b/frontend/tests/rectification-hidden-e2e.test.ts index 8abb9cf1..597382eb 100644 --- a/frontend/tests/rectification-hidden-e2e.test.ts +++ b/frontend/tests/rectification-hidden-e2e.test.ts @@ -317,7 +317,10 @@ test("tied candidates without a high-information probe offer a range instead of discriminatorProbe: null, holdoutValidation: "unavailable", }); - assert.equal(next.nextAction, "offer_provisional_range"); + assert.equal(next.nextAction, "ready_to_adopt"); + // 原值: offer_provisional_range + // 新值: ready_to_adopt + // 原因: BUG-558 覆盖完成且可采用时,没有区分卡就交付代表时间,不再只给不可点范围 // 原断言 sessionOutcome=provisional_range(并列不采用)→ 新断言 adopt_representative。 assert.equal(next.sessionOutcome, "adopt_representative"); assert.equal(next.canAdopt, true); diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts index bb00813d..f907af93 100644 --- a/frontend/tests/rectification-range-offer-deadend.test.ts +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -645,7 +645,7 @@ test("exhaustion after family declined still fills remaining dated domains befor assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.relocation); }); -test("exhaustion generic fallback uses domain other, not unknown", () => { +test("exhaustion after coverage is closed does not fall back to domain other", () => { const next = exhaustionSpokenCollectFollowup({ evidence: [ datedCollectEvidence("education", "2016"), @@ -658,8 +658,8 @@ test("exhaustion generic fallback uses domain other, not unknown", () => { ], declinedTopics: [{ target_domain: "family", status: "declined" }], }); - assert.equal(next?.domain, "other"); - assert.equal(next?.intent, "collect_method_evidence"); - assert.equal(stableFollowupQuestionId(next), "collect:other:collect_method_evidence"); - assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.other); + // 原值: domain=other / USER_COLLECT_QUESTION.other + // 新值: null + // 原因: 穷尽后交付,不再问「也可以再说一件事」。 + assert.equal(next, null); }); diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index b5a5c897..1b671c87 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -591,9 +591,10 @@ test("exhaustion other fallback and other retry keep the after-evidence copy", ( ], declinedTopics: [{ target_domain: "family", status: "declined" }], }); - assert.equal(exhausted?.domain, "other"); - assert.equal(exhausted?.source, "oos_blind"); - assert.equal(spokenFollowupForUser(exhausted), USER_COLLECT_QUESTION.other); + // 原值: domain=other / USER_COLLECT_QUESTION.other + // 新值: null + // 原因: 穷尽后交付,other 只留给开场。 + assert.equal(exhausted, null); const retry: MethodFollowup = { method_id: "dasha_events", diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts index b0a5bce3..77423822 100644 --- a/frontend/tests/rectification-spoken-collect.test.ts +++ b/frontend/tests/rectification-spoken-collect.test.ts @@ -101,7 +101,10 @@ test("cases current_question remains the submit contract, not a visual slot", () assert.doesNotMatch(chat, /finalizeRectificationSpokenAndThinking/); assert.doesNotMatch(chat, /rectification-agentic\/v9\/spoken-answer/); assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/); - assert.doesNotMatch(chat, /rectification-collect-stop|showCollectStop|submitCollectStop/); + // 原值: 禁止 collect-stop 类名 + // 新值: 口述采集态必须渲染停止按钮 + // 原因: BUG-558 采集问完不能只剩不可点范围小字 + assert.match(chat, /rectification-collect-stop/); assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null); }); diff --git a/frontend/tests/rectification-timeline-adapter.test.ts b/frontend/tests/rectification-timeline-adapter.test.ts index 05d7ea31..414296df 100644 --- a/frontend/tests/rectification-timeline-adapter.test.ts +++ b/frontend/tests/rectification-timeline-adapter.test.ts @@ -161,12 +161,15 @@ test("both chat surfaces render one timeline, one live marker, and no second rec assert.doesNotMatch(chatSource, /rectification-activity-failure|/); assert.doesNotMatch(packageJson, /thinking-orbs/); assert.doesNotMatch(globalStyles, /\.conversation\.is-rectification \.message-assistant \.agent-thinking-(step|marker)/); assert.doesNotMatch(globalStyles, /rectification-activity-receipt|rectification-activity-failure|consultation-step-tree/); - assert.doesNotMatch(globalStyles, /rectification-collect-stop/); + assert.match(globalStyles, /rectification-collect-stop/); });