From 86d6923e6eb5abfc357943dbfe6d5eab0fbbdb94 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 29 Aug 2026 10:18:04 +0800 Subject: [PATCH] fix(rectification): stop discriminator followup from dropping user evidence Decision and question ranking now share contrast option completion, so a missing style card cannot deadlock the interview with a dead-end reply. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 + .../src/app/api/rectification/agent/route.ts | 19 +- .../core/candidate-contrast-packet.ts | 88 +++- .../core/probes-from-engine.ts | 15 + .../lib/rectification-agentic/core/types.ts | 5 + .../rectification-agentic/v9/answer-choice.ts | 74 ++- .../v9/decision-from-dossier.ts | 67 ++- .../v9/method-followup.ts | 177 ++++--- .../rectification-agentic/v9/server-focus.ts | 106 +++- .../rectification-agentic/v9/turn-decision.ts | 11 + .../tests/rectification-answer-choice.test.ts | 67 ++- ...discriminator-followup-consistency.test.ts | 474 ++++++++++++++++++ .../tests/rectification-server-focus.test.ts | 47 ++ ...ctification-turn-intent-classifier.test.ts | 5 +- .../rectification-varga-style-weight.test.ts | 7 + 15 files changed, 1054 insertions(+), 124 deletions(-) create mode 100644 frontend/tests/rectification-discriminator-followup-consistency.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b72634cc..609ad63d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6605,3 +6605,19 @@ - 复发自:BUG-010 - 修复版本:待发布 +## BUG-432 | 生时校正区分题出题层静默丢弃探针后无卡无记账死锁 + +- 状态:resolved +- 首次发现:2026-08-29 +- 最近更新:2026-08-29 +- 影响面:`POST /api/rectification/agent` 消息分支、`decideFromDossier`、`buildMethodFollowupPlan`、口述采集 focus +- 用户现象:点选两道区分题后,助手口述家人采集问但没有选项卡。用户用自由文本回答后,服务端回没有出路的终结话术;`current_question` 与 `choice_card` 均为 null,case 停在 `collecting_evidence`;该回合 receipt 的 phases / tools / tool_activities 全空,用户这句话里的新证据没有入账本。 +- 触发条件:训练证据已齐、已答 dasha 边界探针、剩余探针含无年份 varga 与缺 `style_options` 的 `varga_style`;方法覆盖未齐时出题层回落到口述采集;随后用户在无 active choice focus 时发自由文本。 +- 根因:`inspectDiscriminatorProbes` 用 `withCompletedContrastOptions` 把缺 `style_options` 的 `varga_style` 降级后仍视为可渲染,并选出无年份 varga 探针进入 `ask_candidate_discriminator`。`renderableContrastProbe` 仍走未降级的 `completeStyleOptions`,把同一探针静默丢弃。无年份桶只在覆盖齐时使用,于是回落到 `source=method_coverage` 的家人采集且不带 `choice_frame`。路由只信决策层:`persistServerOwnedFocus` skipped、`openQuestionFromPersistedFocus` 为 null 后直接返回终结话术,既不落证据也不跑 Agent。口述采集题不落 focus,刷新后问题丢失;卡片持久化失败时返回无正文下一步。 +- 修复:出题层与决策层共用 `withCompletedContrastOptions`;丢弃探针写入 `dropped_probes`。仅当出题层给出 `intent=distinguish_candidates` 且带 `choice_frame` 的 followup 时才进入 `ask_candidate_discriminator`,否则落到采集。消息分支拿不到可渲染卡且仍有 followup 时落入 `runV9AgentTurn`;仅当 `next_followup` 为 null 时才收口,话术给出继续采集或按区间看盘。口述采集持久化不带 choice 的 collect focus;卡片持久化失败时落到口述采集下一步。冲突探针写回时保留 `style_options`。Skill 版本保持 `10.0.13`。未放宽 confirmation gate,未增加「已确认唯一分钟」路径。 +- 验证:`frontend/tests/rectification-discriminator-followup-consistency.test.ts` 锁定同一探针池上决策层与出题层一致、缺 `style_options` 的 D9/D10 风格题在方法覆盖未齐时仍出区分卡、无星座时钟键 fail-closed 到采集、消息路径不再返回死路话术。`rectification-answer-choice.test.ts` 锁定口述采集后仍有 collect focus 与 `current_question`。`rectification-server-focus.test.ts` 锁定 collect schema 不是未渲染区分卡。`rectification-turn-intent-classifier.test.ts` 按新收口话术改断言。`npx tsc --noEmit` 与 `npx tsx --test tests/*.test.ts`。 +- 防复发:可渲染判定必须共用选项补全,不得一边降级接受一边静默丢弃。无卡时不得把用户消息当终结话术丢掉。有合法 choice schema 的 active focus 仍走轻量分类 + `applyRectificationChoice`。不得引入语义正则、A/B/C/D 位置推断或静态选项 fallback。 +- 相关记录:BUG-404、BUG-407、BUG-410、BUG-411 +- 复发自:BUG-404 +- 修复版本:待发布 + diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 140579ee..2bc3d841 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -36,6 +36,8 @@ import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-f export const runtime = "nodejs"; export const maxDuration = 240; +export const DISCRIMINATOR_EXHAUSTED_NARRATION = "当前几个候选已经构成可信区间。你可以再说一件记得住时间的经历,也可以先按这个区间看盘。"; + function completedMessageResponse(text: string, requestId: string, caseId: string) { const body = [ JSON.stringify({ type: "answer.delta", text }), @@ -414,7 +416,7 @@ export async function POST(request: Request) { followup: plan.next_followup, }); const open = openQuestionFromPersistedFocus(persisted); - if (open && persisted.focus) { + if (open && persisted.focus && open.unrenderable !== true) { let classified = null; try { classified = await classifyRectificationTurnIntent(selectedModel, { @@ -456,13 +458,14 @@ export async function POST(request: Request) { }); return completedMessageResponse(narration, requestId, caseId); } - const narration = "目前没有可继续区分的问题。当前几个候选构成可信区间,不再泛问已经覆盖过的经历。"; - await persistV9DeterministicTurn(accounting, userId, caseId, { - requestId, - userMessage: parsed.data.message ?? null, - assistantMessage: narration, - }); - return completedMessageResponse(narration, requestId, caseId); + if (!plan.next_followup) { + await persistV9DeterministicTurn(accounting, userId, caseId, { + requestId, + userMessage: parsed.data.message ?? null, + assistantMessage: DISCRIMINATOR_EXHAUSTED_NARRATION, + }); + return completedMessageResponse(DISCRIMINATOR_EXHAUSTED_NARRATION, requestId, caseId); + } } } } catch (error) { diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts index 854db58d..16215a4c 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -4,7 +4,13 @@ */ import type { AnswerClass, ConflictProbe } from "./types.ts"; -import { d9StyleLabel, d10StyleLabel } from "../v9/varga-type-tables.ts"; +import { + D9_TYPE_TABLE, + D10_TYPE_TABLE, + d9StyleLabel, + d10StyleLabel, + signKey, +} from "../v9/varga-type-tables.ts"; import { completeStyleOptions, isRenderableProbe, @@ -469,7 +475,7 @@ export function selectDiscriminatorProbe( return inspectDiscriminatorProbes(packet, options).selected; } -function withCompletedContrastOptions( +export function withCompletedContrastOptions( probe: CandidateDiscriminatorProbe, ): { ok: true; probe: CandidateDiscriminatorProbe } | { ok: false; reason: DroppedProbe["reason"] } { const mapped = probe.styleOptions?.map((item) => ({ @@ -522,22 +528,37 @@ function inferredVargaStyleIncoming( }); } +function knownVargaSigns(layer: "d9" | "d10", tokens: readonly string[]): string[] { + const table = layer === "d9" ? D9_TYPE_TABLE : D10_TYPE_TABLE; + return tokens.flatMap((token) => { + const key = signKey(token); + return table[key] ? [key] : []; + }); +} + function signsFromVargaProbe( probe: CandidateDiscriminatorProbe, ): { layer: "d9" | "d10"; signs: string[] } | null { const match = probe.semanticKey.match(/^varga\.(d9|d10)\.(.+)$/); const layer = match?.[1] === "d9" || match?.[1] === "d10" ? match[1] : null; - const fromKey = match?.[2] - ?.split(/[|/]/) - .map((item) => item.trim()) - .filter((item) => item && !/^\d{1,2}:\d{2}$/.test(item)) - ?? []; - const fromOutcomes = probe.expectedOutcomes.flatMap((row) => { - const token = row.outcomeId.replace(/^supports_/, "").trim(); - return token && !/^\d{1,2}:\d{2}$/.test(token) ? [token] : []; - }); + if (!layer) return null; + const fromKey = knownVargaSigns( + layer, + match?.[2] + ?.split(/[|/]/) + .map((item) => item.trim()) + .filter((item) => item && !/^\d{1,2}:\d{2}$/.test(item)) + ?? [], + ); + const fromOutcomes = knownVargaSigns( + layer, + probe.expectedOutcomes.flatMap((row) => { + const token = row.outcomeId.replace(/^supports_/, "").trim(); + return token && !ANSWER_CLASSES.has(token) && !/^\d{1,2}:\d{2}$/.test(token) ? [token] : []; + }), + ); const signs = (fromKey.length >= 2 ? fromKey : fromOutcomes).slice(0, 3); - if (!layer || signs.length < 2) return null; + if (signs.length < 2) return null; return { layer, signs }; } @@ -568,12 +589,29 @@ function withUnsureOutcome( return rows; } +function conflictStyleOptions( + probe: CandidateDiscriminatorProbe, +): ConflictProbe["style_options"] { + const rows = probe.styleOptions?.flatMap((item) => { + const label = item.label.trim(); + if (!label) return []; + return [{ + label, + answer_class: item.answerClass, + ...(item.sign ? { sign: item.sign } : {}), + }]; + }) ?? []; + return rows.length > 0 ? rows : undefined; +} + export function conflictProbesFromContrast( packet: CandidateContrastPacket | null | undefined, ): ConflictProbe[] { return (packet?.probes ?? []).flatMap((probe) => { if (!probe.semanticKey.startsWith("varga.")) return []; - const outcomes = probe.expectedOutcomes.flatMap((row, index) => { + const completed = withCompletedContrastOptions(probe); + const working = completed.ok ? completed.probe : probe; + const outcomes = working.expectedOutcomes.flatMap((row, index) => { const answer = ANSWER_CLASSES.has(row.outcomeId) ? row.outcomeId as AnswerClass : (["yes", "weak_yes", "no"][index] as AnswerClass | undefined); @@ -585,29 +623,33 @@ export function conflictProbesFromContrast( }]; }); if (outcomes.length < 2) return []; - if (probe.informationGain <= 0) return []; - const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [ + if (working.informationGain <= 0) return []; + const candidateIds = [...new Set(working.expectedOutcomes.flatMap((row) => [ ...row.supportsCandidateIds, ...row.conflictsCandidateIds, ]))]; if (candidateIds.length < 2) return []; - const choiceKind = effectiveContrastChoiceKind(probe); + const choiceKind = completed.ok + ? (working.choiceKind ?? effectiveContrastChoiceKind(working)) + : effectiveContrastChoiceKind(probe); + const styleOptions = conflictStyleOptions(working); return [{ - id: probe.probeId, - semantic_key: probe.semanticKey, - candidate_split_hash: probe.candidateSplitHash, - domain: probe.domain ?? "career", - year: probe.year ?? 0, - question: probe.question, + id: working.probeId, + semantic_key: working.semanticKey, + candidate_split_hash: working.candidateSplitHash, + domain: working.domain ?? "career", + year: working.year ?? 0, + question: working.question, candidate_ids: candidateIds, expected_outcomes: outcomes, - information_gain: probe.informationGain, + information_gain: working.informationGain, source: "varga_contrast", ...(choiceKind === "varga_style" || choiceKind === "event_quality" || choiceKind === "existence" ? { choice_kind: choiceKind } : {}), + ...(styleOptions ? { style_options: styleOptions } : {}), }]; }); } diff --git a/frontend/src/lib/rectification-agentic/core/probes-from-engine.ts b/frontend/src/lib/rectification-agentic/core/probes-from-engine.ts index 5b654501..8a633810 100644 --- a/frontend/src/lib/rectification-agentic/core/probes-from-engine.ts +++ b/frontend/src/lib/rectification-agentic/core/probes-from-engine.ts @@ -20,6 +20,7 @@ export function probeFromEngine(probe: EngineProbeFields): ConflictProbe | null if (outcomes.length < 2 || candidateIds.length < 2 || (probe.information_gain ?? 0) <= 0) { return null; } + const styleOptions = styleOptionsFromEngine(probe.style_options); return { id: `probe:${semanticKey}:${splitHash}`, semantic_key: semanticKey, @@ -36,9 +37,23 @@ export function probeFromEngine(probe: EngineProbeFields): ConflictProbe | null || probe.choice_kind === "existence" ? { choice_kind: probe.choice_kind } : {}), + ...(styleOptions.length > 0 ? { style_options: styleOptions } : {}), }; } +function styleOptionsFromEngine( + rows: DiscriminatingEventProbe["style_options"], +): NonNullable { + return (rows ?? []).flatMap((row) => { + if (!ANSWER_CLASSES.has(row.answer_class)) return []; + return [{ + label: row.label, + answer_class: row.answer_class as AnswerClass, + ...(row.sign ? { sign: row.sign } : {}), + }]; + }); +} + function outcomesFromEngine( rows: DiscriminatingEventProbe["expected_outcomes"], ): ProbeOutcome[] { diff --git a/frontend/src/lib/rectification-agentic/core/types.ts b/frontend/src/lib/rectification-agentic/core/types.ts index 894ba731..92b8aa5b 100644 --- a/frontend/src/lib/rectification-agentic/core/types.ts +++ b/frontend/src/lib/rectification-agentic/core/types.ts @@ -85,6 +85,11 @@ export type ConflictProbe = Readonly<{ information_gain: number; source: string; choice_kind?: ProbeChoiceKind; + style_options?: readonly Readonly<{ + label: string; + answer_class: AnswerClass; + sign?: string; + }>[]; }>; export type ProbeAnswer = Readonly<{ diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 2e5af223..78c2720e 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -39,7 +39,6 @@ import { import type { ChoiceKey } from "./choice-card"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus } from "./server-focus"; import { buildMethodFollowupPlan, spokenFollowupForUser } from "./method-followup"; -import { meetsAcceptanceEventQuality } from "./evidence-model"; import type { SessionOutcomeKind } from "./confirmation-gate"; export type ApplyChoiceCommand = Readonly<{ @@ -283,28 +282,83 @@ async function persistNextInterviewAfterChoice(input: { && input.nextAction.type !== "ask_holdout_validation", }); const followup = plan.next_followup; - const persistedFocus = await persistServerOwnedFocus({ + const persistedFocus = await persistFocusAfterChoice({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, - activeFocus: null, decisionReceipt: latest.decisionReceipt, followup, }); const open = openQuestionFromPersistedFocus(persistedFocus); - if (open) { + if (open && open.unrenderable !== true) { return { hostNarration: "接下来请点选下面这一问。", choiceReady: true }; } if (followup?.choice_frame) { + const spoken = "请再说一件记得大概时间的经历。"; + const fallback = await persistFocusAfterChoice({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + decisionReceipt: latest.decisionReceipt, + followup: { + ...followup, + intent: "collect_method_evidence", + choice_frame: null, + source: "method_coverage", + user_prompt_hint: spoken, + }, + }); + if (fallback.status === "created" || fallback.status === "already_open") { + return { hostNarration: spoken, choiceReady: false }; + } + return { hostNarration: spoken, choiceReady: false }; + } + if (followup?.intent === "collect_method_evidence") { + const spoken = spokenFollowupForUser(followup); + if ( + spoken + && (persistedFocus.status === "created" || persistedFocus.status === "already_open") + ) { + return { hostNarration: spoken, choiceReady: false }; + } return { hostNarration: null, choiceReady: false }; } - if ( - followup?.intent === "collect_method_evidence" - && meetsAcceptanceEventQuality(input.dossier.evidence) - ) { - return { hostNarration: spokenFollowupForUser(followup), choiceReady: false }; + if (!followup) { + return { + hostNarration: "当前几个候选已经构成可信区间。你可以再说一件记得住时间的经历,也可以先按这个区间看盘。", + choiceReady: false, + }; + } + return { + hostNarration: spokenFollowupForUser(followup) ?? "请再说一件记得大概时间的经历。", + choiceReady: false, + }; +} + +async function persistFocusAfterChoice(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + decisionReceipt: Readonly> | null | undefined; + followup: ReturnType["next_followup"]; +}) { + try { + return await persistServerOwnedFocus({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + activeFocus: null, + decisionReceipt: input.decisionReceipt, + followup: input.followup, + }); + } catch { + return { + status: "skipped" as const, + focus: null, + questionId: null, + prompt: null, + }; } - return { hostNarration: null, choiceReady: false }; } async function persistApplied( 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 5bf70d5e..46fe0efc 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -12,6 +12,7 @@ import { mentionedVargaKeysFromLedgerEvidence, volunteeredDomainsFromEvidence, type CandidateContrastPacket, + type CandidateDiscriminatorProbe, type EngineContrastProbe, } from "../core/candidate-contrast-packet.ts"; import { @@ -38,6 +39,7 @@ import { } from "./evidence-model"; import { refinementFromDecisionReceipt } from "./refinement-packet"; import { windowScanFromDecisionReceipt } from "./varga-observations"; +import type { DroppedProbe } from "./probe-question-contract.ts"; import { evidenceLedgerFingerprint } from "./tool-service"; import { candidateSnapshotSource, @@ -183,6 +185,7 @@ export function contrastPacketFromLatestResult( || probe.choice_kind === "existence" ? { choice_kind: probe.choice_kind } : {}), + ...(probe.style_options?.length ? { style_options: probe.style_options } : {}), }]; }); const merged = mergeEngineProbes( @@ -285,6 +288,7 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket || item.choice_kind === "existence" ? { choice_kind: item.choice_kind } : {}), + ...(item.style_options?.length ? { style_options: item.style_options } : {}), })), candidateTimes: state.candidates .filter((item) => item.status !== "eliminated") @@ -316,6 +320,50 @@ function scoreableSnapshotCurrentFromDossier( return storedSnapshotIsCurrent(stored, current); } +function mergeDroppedProbes( + ...groups: readonly (readonly DroppedProbe[] | undefined)[] +): DroppedProbe[] { + const byKey = new Map(); + for (const group of groups) { + for (const item of group ?? []) { + if (!byKey.has(item.semantic_key)) byKey.set(item.semantic_key, item); + } + } + return [...byKey.values()]; +} + +export function followupAsksRenderableDiscriminator( + followup: { intent?: string; choice_frame?: unknown } | null | undefined, +): boolean { + return followup?.intent === "distinguish_candidates" && Boolean(followup.choice_frame); +} + +function discriminatorProbeIfFollowupCanAsk(input: { + dossier: DecisionDossier; + inspected: ReturnType; + contrastPacket?: CandidateContrastPacket; + askedKeys?: readonly string[]; +}): { + probe: CandidateDiscriminatorProbe | null; + dropped: DroppedProbe[]; +} { + const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence); + const plan = buildMethodFollowupPlan({ + evidence: input.dossier.evidence, + declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: "discriminate_candidates", + ...catalog, + ...(input.contrastPacket ? { contrastPacket: input.contrastPacket } : {}), + ...(input.askedKeys ? { askedProbeKeys: input.askedKeys } : {}), + candidatesSeparated: false, + }); + const dropped = mergeDroppedProbes(input.inspected.dropped, plan.dropped_probes); + if (!followupAsksRenderableDiscriminator(plan.next_followup)) { + return { probe: null, dropped }; + } + return { probe: input.inspected.selected, dropped }; +} + export function decideFromDossier( dossier: DecisionDossier, options?: { currentEvidenceFingerprint?: string | null }, @@ -347,6 +395,7 @@ export function decideFromDossier( askedKeys, mentionedKeys, }); + const gated = discriminatorProbeIfFollowupCanAsk({ dossier, inspected, askedKeys }); return { ...decideRectification({ methodCoverageAll: blockingMethodsCovered(collecting.methods), @@ -355,12 +404,12 @@ export function decideFromDossier( userStopped: dossier.case.status === "paused", snapshotCurrent, candidateScores: candidateScoresFromDossier(dossier.latestResult), - discriminatorProbe: inspected.selected, + discriminatorProbe: gated.probe, holdoutValidation: holdoutStatusFromInference(inference, oosBlindPrompts), accepted: Boolean(dossier.case.acceptedTime), inferenceCredibleRange: inference?.credible_range ?? null, }), - droppedProbes: inspected.dropped, + droppedProbes: gated.dropped, }; } @@ -384,8 +433,16 @@ export function decideAfterInferenceChange(input: { } const training = input.state.events.filter((item) => item.usage === "training"); const trainingDomains = new Set(training.map((item) => item.domain)); - const inspected = inspectDiscriminatorProbes(contrastPacketFromState(input.state), { + const contrastPacket = contrastPacketFromState(input.state); + const inspected = inspectDiscriminatorProbes(contrastPacket, { mentionedKeys: mentionedVargaKeysFromLedgerEvidence(input.dossier.evidence), + askedKeys: input.state.answered_probes.map((item) => item.semantic_key), + }); + const gated = discriminatorProbeIfFollowupCanAsk({ + dossier: input.dossier, + inspected, + contrastPacket, + askedKeys: input.state.answered_probes.map((item) => item.semantic_key), }); return { ...decideRectification({ @@ -395,7 +452,7 @@ export function decideAfterInferenceChange(input: { candidateScores: input.state.candidates .filter((item) => item.status !== "eliminated") .map((item) => ({ time: item.time, score: item.posterior_score })), - discriminatorProbe: inspected.selected, + discriminatorProbe: gated.probe, holdoutValidation: holdoutStatusFromState( input.state, refinementFromDecisionReceipt(input.dossier.latestResult?.decisionReceipt ?? null).oos_blind_prompts, @@ -404,7 +461,7 @@ export function decideAfterInferenceChange(input: { userStopped: input.userStopped, accepted: Boolean(input.dossier.case.acceptedTime), }), - droppedProbes: inspected.dropped, + droppedProbes: gated.dropped, }; } diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 7a499623..3adff8fe 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -66,6 +66,7 @@ import { mentionedVargaKeysFromLedgerEvidence, vargaLayerCovered, vargaLayerFromSemanticKey, + withCompletedContrastOptions, type CandidateContrastPacket, type CandidateDiscriminatorProbe, } from "../core/candidate-contrast-packet.ts"; @@ -75,6 +76,7 @@ import { isRenderableProbe, rankDiscriminatorScore, EXISTENCE_STYLE_OPTIONS, + type DroppedProbe, type ProbeStyleOption, } from "./probe-question-contract.ts"; import type { SessionOutcomeKind } from "./confirmation-gate.ts"; @@ -147,6 +149,7 @@ export type MethodFollowupPlan = Readonly<{ stop_domain_rotation: true; do_not_poll: readonly []; not_in_rotation: readonly ["relocation"]; + dropped_probes: readonly DroppedProbe[]; }>; export type MethodFollowupEvidence = Readonly<{ @@ -374,43 +377,42 @@ function contrastFollowupDomain( function eventProbeFromContrast(probe: CandidateDiscriminatorProbe): DiscriminatingEventProbe | null { const domain = contrastFollowupDomain(probe.domain); if (!EVENT_PROBE_DOMAINS.includes(domain as EventProbeDomain)) return null; - const choiceKind = probe.choiceKind ?? "existence"; - const styleOptions = completeStyleOptions({ - choiceKind, - styleOptions: probe.styleOptions?.map((item) => ({ - label: item.label, - answer_class: item.answerClass, - ...(item.sign ? { sign: item.sign } : {}), - })), - }); - if (!styleOptions.ok) return null; - const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [ + const completed = withCompletedContrastOptions(probe); + if (!completed.ok) return null; + const working = completed.probe; + const choiceKind = working.choiceKind ?? "existence"; + const styleOptions = (working.styleOptions ?? []).map((item) => ({ + label: item.label, + answer_class: item.answerClass, + ...(item.sign ? { sign: item.sign } : {}), + })); + const candidateIds = [...new Set(working.expectedOutcomes.flatMap((row) => [ ...row.supportsCandidateIds, ...row.conflictsCandidateIds, ]))]; return { - year: probe.year ?? 0, - year_label: probe.year ? `${probe.year} 年前后` : "当前这几个候选", + year: working.year ?? 0, + year_label: working.year ? `${working.year} 年前后` : "当前这几个候选", domain: domain as EventProbeDomain, event_family: followupEventFamily(domain, choiceKind), source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, - user_meaning: probe.question, + user_meaning: working.question, role: "distinguish", phase: "candidate_discriminator", - information_gain: probe.informationGain, - semantic_key: probe.semanticKey, - candidate_split_hash: probe.candidateSplitHash, + information_gain: working.informationGain, + semantic_key: working.semanticKey, + candidate_split_hash: working.candidateSplitHash, candidate_ids: candidateIds, - expected_outcomes: probe.expectedOutcomes.map((row) => ({ + expected_outcomes: working.expectedOutcomes.map((row) => ({ answer_class: row.outcomeId, supports: row.supportsCandidateIds, conflicts: row.conflictsCandidateIds, })), choice_kind: choiceKind, - style_options: styleOptions.options, + style_options: styleOptions, }; } @@ -521,40 +523,63 @@ type RankedDiscriminator = Readonly<{ styleOptions: ProbeStyleOption[]; }>; +function droppedFromProbe( + semanticKey: string, + informationGain: number, + reason: DroppedProbe["reason"], +): DroppedProbe { + return { + semantic_key: semanticKey, + information_gain: informationGain, + reason, + }; +} + function renderableEventProbe( probe: DiscriminatingEventProbe, askedKeys: ReadonlySet, topCandidateTimes: readonly string[], mentionedKeys: ReadonlySet = new Set(), -): RankedDiscriminator | null { +): { row: RankedDiscriminator | null; dropped: DroppedProbe | null } { + const key = probe.semantic_key ?? `${probe.domain}.${probe.year}`; const candidateIds = probe.candidate_ids ?? candidateIdsFromProbe(probe); const styleOptions = completeStyleOptions({ choiceKind: probe.choice_kind, styleOptions: probe.style_options, }); - if (!styleOptions.ok || !isValidDistinguishProbe({ ...probe, role: "distinguish" })) return null; - if (!isRenderableProbe({ + if (!styleOptions.ok) { + return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, styleOptions.reason) }; + } + if (!isValidDistinguishProbe({ ...probe, role: "distinguish" })) { + return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "not_renderable") }; + } + const renderable = isRenderableProbe({ informationGain: probe.information_gain, candidateIds, expectedOutcomeCount: probe.expected_outcomes?.length, choiceKind: probe.choice_kind, styleOptions: styleOptions.options, - }).ok) return null; - const key = probe.semantic_key ?? `${probe.domain}.${probe.year}`; + }); + if (!renderable.ok) { + return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, renderable.reason) }; + } const layer = vargaLayerFromSemanticKey(key); const asked = askedKeys.has(key) || Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash)) || (layer ? vargaLayerCovered(mentionedKeys, layer) : false); return { - kind: "event", - eventProbe: probe, - styleOptions: styleOptions.options, - score: rankDiscriminatorScore({ - informationGain: probe.information_gain ?? 0, - asked, - candidateIds, - topCandidateTimes, - }), + row: { + kind: "event", + eventProbe: probe, + styleOptions: styleOptions.options, + score: rankDiscriminatorScore({ + informationGain: probe.information_gain ?? 0, + asked, + candidateIds, + topCandidateTimes, + }), + }, + dropped: null, }; } @@ -563,41 +588,55 @@ function renderableContrastProbe( askedKeys: ReadonlySet, topCandidateTimes: readonly string[], mentionedKeys: ReadonlySet = new Set(), -): RankedDiscriminator | null { - const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [ +): { row: RankedDiscriminator | null; dropped: DroppedProbe | null } { + const completed = withCompletedContrastOptions(probe); + if (!completed.ok) { + return { + row: null, + dropped: droppedFromProbe(probe.semanticKey, probe.informationGain, completed.reason), + }; + } + const working = completed.probe; + const candidateIds = [...new Set(working.expectedOutcomes.flatMap((row) => [ ...row.supportsCandidateIds, ...row.conflictsCandidateIds, ]))]; - const styleOptions = completeStyleOptions({ - choiceKind: probe.choiceKind, - styleOptions: probe.styleOptions?.map((item) => ({ - label: item.label, - answer_class: item.answerClass, - ...(item.sign ? { sign: item.sign } : {}), - })), - }); - if (!styleOptions.ok || !isRenderableProbe({ - informationGain: probe.informationGain, + const styleOptions = (working.styleOptions ?? []).map((item) => ({ + label: item.label, + answer_class: item.answerClass, + ...(item.sign ? { sign: item.sign } : {}), + })); + const renderable = isRenderableProbe({ + informationGain: working.informationGain, candidateIds, - expectedOutcomeCount: probe.expectedOutcomes.length, - choiceKind: probe.choiceKind, - styleOptions: styleOptions.options, - }).ok) return null; - const layer = vargaLayerFromSemanticKey(probe.semanticKey); - const asked = askedKeys.has(probe.semanticKey) - || askedKeys.has(probe.candidateSplitHash) - || askedKeys.has(probe.probeId) + expectedOutcomeCount: working.expectedOutcomes.length, + choiceKind: working.choiceKind, + styleOptions, + }); + if (!renderable.ok) { + return { + row: null, + dropped: droppedFromProbe(working.semanticKey, working.informationGain, renderable.reason), + }; + } + const layer = vargaLayerFromSemanticKey(working.semanticKey); + const asked = askedKeys.has(working.semanticKey) + || askedKeys.has(working.candidateSplitHash) + || askedKeys.has(working.probeId) || (layer ? vargaLayerCovered(mentionedKeys, layer) : false); return { - kind: "contrast", - contrastProbe: probe, - styleOptions: styleOptions.options, - score: rankDiscriminatorScore({ - informationGain: probe.informationGain, - asked, - candidateIds, - topCandidateTimes, - }), + row: { + kind: "contrast", + contrastProbe: working, + styleOptions, + score: rankDiscriminatorScore({ + informationGain: working.informationGain, + asked, + candidateIds, + topCandidateTimes, + }), + }, + dropped: null, }; } @@ -714,13 +753,16 @@ function rankRenderableDiscriminators(input: { topCandidateTimes?: readonly string[]; providedDomains?: readonly string[]; evidence?: readonly MethodFollowupEvidence[]; -}): { locked: RankedDiscriminator[]; yearless: RankedDiscriminator[] } { +}): { locked: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } { const top = input.topCandidateTimes ?? []; const provided = new Set(input.providedDomains ?? []); const mentioned = input.mentionedKeys ?? new Set(); const rows: RankedDiscriminator[] = []; + const dropped: DroppedProbe[] = []; const seen = new Set(); - const push = (row: RankedDiscriminator | null) => { + const push = (result: { row: RankedDiscriminator | null; dropped: DroppedProbe | null }) => { + if (result.dropped) dropped.push(result.dropped); + const row = result.row; if (!row) return; const key = row.eventProbe?.semantic_key ?? row.contrastProbe?.semanticKey @@ -740,6 +782,7 @@ function rankRenderableDiscriminators(input: { return { locked: sorted.filter((row) => discriminatorLocksScoringPeriod(row)), yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && discriminatorChoiceKind(row) !== "varga_style"), + dropped, }; } @@ -1174,7 +1217,7 @@ export function buildMethodFollowupPlan(input: { providedDomains: datedDomainsFromEvidence(input.evidence), evidence: input.evidence, }) - : { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[] }; + : { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[], dropped: [] as DroppedProbe[] }; const rankedDiscriminators = rankedCatalog.locked; const yearlessDiscriminators = rankedCatalog.yearless; const bestDiscriminator = rankedDiscriminators[0] ?? null; @@ -1259,6 +1302,7 @@ export function buildMethodFollowupPlan(input: { stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, + dropped_probes: rankedCatalog.dropped, }; } } @@ -1276,6 +1320,7 @@ export function buildMethodFollowupPlan(input: { stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, + dropped_probes: rankedCatalog.dropped, }; } @@ -1304,6 +1349,7 @@ export function buildMethodFollowupPlan(input: { stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, + dropped_probes: rankedCatalog.dropped, }; } @@ -1715,6 +1761,7 @@ export function buildMethodFollowupPlan(input: { stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, + dropped_probes: rankedCatalog.dropped, }; } diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 8d314cde..f5115a3f 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -9,7 +9,7 @@ import { stampChoiceSchemaWithProbe, previousInferenceFromReceipt, } from "./inference-adapter"; -import type { MethodFollowup } from "./method-followup"; +import { spokenFollowupForUser, type MethodFollowup } from "./method-followup"; import { setV10ConversationFocus, RectificationToolServiceError, @@ -112,6 +112,7 @@ export function openQuestionFromPersistedFocus(result: PersistServerFocusResult) || result.focus.questionId !== result.questionId ) return null; if (!result.prompt || !parseAgentChoiceCopy(result.focus.expectedAnswerSchema)) { + if (result.focus.expectedAnswerSchema?.collect === true) return null; return { question_id: result.questionId, prompt: null, @@ -127,6 +128,80 @@ export function openQuestionFromPersistedFocus(result: PersistServerFocusResult) }; } +export const COLLECT_FOCUS_SCHEMA_KEY = "collect"; + +function collectFocusSchema(followup: MethodFollowup): Record | null { + const prompt = spokenFollowupForUser({ ...followup, choice_frame: null }); + if (!prompt) return null; + return { + prompt, + [COLLECT_FOCUS_SCHEMA_KEY]: true, + semantic_key: followup.semantic_key ?? null, + }; +} + +export function isCollectFocusSchema(schema: Readonly> | null | undefined): boolean { + return schema?.[COLLECT_FOCUS_SCHEMA_KEY] === true && typeof schema.prompt === "string"; +} + +async function persistCollectFocus(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + activeFocus: ConversationFocus | null; + followup: MethodFollowup; +}): Promise { + const schema = collectFocusSchema(input.followup); + if (!schema) { + return { + status: "skipped", + focus: input.activeFocus, + questionId: null, + prompt: null, + }; + } + const questionId = stableFollowupQuestionId(input.followup); + const prompt = typeof schema.prompt === "string" ? schema.prompt : null; + const active = input.activeFocus; + if (active && active.questionId === questionId && isCollectFocusSchema(active.expectedAnswerSchema)) { + return { status: "already_open", focus: active, questionId: active.questionId, prompt }; + } + try { + const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, { + questionId, + intent: input.followup.intent, + targetEvidenceId: null, + targetDomain: input.followup.domain, + targetKind: null, + expectedAnswerSchema: schema, + }); + return { + status: result.idempotent ? "already_open" : "created", + focus: result.focus, + questionId: result.focus.questionId, + prompt, + }; + } catch (error) { + const code = error instanceof RectificationToolServiceError + ? error.code + : safeToolErrorCode(error); + if (code === "focus_idempotency_conflict" || code.includes("focus_idempotency_conflict")) { + return { + status: "duplicate_focus", + focus: input.activeFocus, + questionId, + prompt, + }; + } + return { + status: "skipped", + focus: input.activeFocus, + questionId: null, + prompt: null, + }; + } +} + export async function persistServerOwnedFocus(input: { accounting: AccountingClient; userId: string; @@ -137,9 +212,34 @@ export async function persistServerOwnedFocus(input: { }): Promise { const followup = input.followup; const frame = followup?.choice_frame ?? null; - if (!followup || !frame) { + if (!followup) { return { - status: followup?.intent === "distinguish_candidates" ? "invalid_choice_schema" : "skipped", + status: "skipped", + focus: input.activeFocus, + questionId: null, + prompt: null, + }; + } + if (!frame) { + if (followup.intent === "distinguish_candidates") { + return { + status: "invalid_choice_schema", + focus: input.activeFocus, + questionId: null, + prompt: null, + }; + } + if (followup.intent === "collect_method_evidence") { + return persistCollectFocus({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + activeFocus: input.activeFocus, + followup, + }); + } + return { + status: "skipped", focus: input.activeFocus, questionId: null, prompt: null, diff --git a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts index 3f7f0d70..d464da67 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts @@ -87,6 +87,17 @@ export function projectCurrentQuestion( domain: focus.targetDomain ?? null, }; } + const collectPrompt = typeof schema?.prompt === "string" ? schema.prompt.trim() : ""; + if (focus.intent === "collect_method_evidence" && collectPrompt && !looksLikeChoiceSchema(schema)) { + return { + question_id: focus.questionId ?? null, + focus_id: focus.id ?? null, + probe_id: probeId, + prompt: collectPrompt, + intent: focus.intent, + domain: focus.targetDomain ?? null, + }; + } if (!looksLikeChoiceSchema(schema)) return null; return { question_id: focus.questionId ?? null, diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index aa9c74dd..8259806e 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -644,11 +644,24 @@ test("answering a discriminator persists the next dated card so GET still has a assert.doesNotMatch(card.prompt, /2015/); }); -test("answering the last discriminator persists a year-locked family collect, not a yearless D24 card", async () => { +test("answering the last discriminator persists a year-locked family collect focus, not a yearless D24 card", async () => { const accounting = persistChoiceAccounting(familyCollectDossier(), { - set_agentic_rectification_conversation_focus: () => { - throw new Error("yearless D24 must not persist a scoring focus"); - }, + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: { + id: NEXT_FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: args.p_target_evidence_id, + target_domain: args.p_target_domain, + target_kind: args.p_target_kind, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-08-28T07:37:50.000Z", + resolved_at: null, + }, + idempotent: false, + }), }); const applied = await applyRectificationChoice(accounting.client, { userId: USER_ID, @@ -662,11 +675,14 @@ test("answering the last discriminator persists a year-locked family collect, no optionId: "C", expectedRevision: familyCollectInference().revision, }); - assert.equal( - accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"), - false, - JSON.stringify(accounting.calls.map((call) => call.fn)), - ); + const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus"); + assert.ok(setFocus, JSON.stringify(accounting.calls.map((call) => call.fn))); + assert.equal(setFocus.args.p_intent, "collect_method_evidence"); + const schema = setFocus.args.p_expected_answer_schema as { choice?: unknown; prompt?: string; collect?: boolean }; + assert.equal(schema.choice, undefined); + assert.equal(schema.collect, true); + assert.match(schema.prompt ?? "", /2021/); + assert.match(schema.prompt ?? "", /家人|结婚|添丁|住院/); assert.equal(applied.nextInterviewPersisted, true); assert.equal(applied.nextChoiceReady, false); assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false); @@ -801,6 +817,39 @@ test("collection focus without choice copy is not an unrenderable current_questi assert.equal(projection.current_question, null); }); +test("failed card persist still leaves a spoken collect next step", () => { + const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); + assert.match(source, /if \(followup\?\.choice_frame\)/); + assert.match(source, /intent: "collect_method_evidence"/); + assert.match(source, /choice_frame: null/); + assert.match(source, /请再说一件记得大概时间的经历/); + assert.match(source, /当前几个候选已经构成可信区间/); +}); + +test("spoken collect focus keeps current_question after refresh", () => { + const snapshot = candidateSnapshotFixture(); + Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() }); + const dossier = parseV9CaseDossier(dossierFixture({ + latestResult: snapshot, + conversationSummary: conversationSummaryFixture({ + activeFocus: activeFocusFixture({ + intent: "collect_method_evidence", + targetDomain: "family", + expectedAnswerSchema: { + prompt: "2021 年前后,家里有没有结婚、添丁或住院这类记得住时间的事?不记得具体日子也可以先说有没有。", + collect: true, + }, + }), + }), + })); + assert.ok(dossier); + const projection = projectTurnDecision(dossier); + const currentQuestion = projection.current_question as { prompt?: string; unrenderable?: boolean } | null; + assert.equal(currentQuestion?.unrenderable, undefined); + assert.match(currentQuestion?.prompt ?? "", /2021/); + assert.equal(projection.current_question && typeof projection.current_question === "object", true); +}); + test("turn_decision hides current_probe unless a valid current_question exists", () => { const withFocus = projectTurnDecision(parseV9CaseDossier(choiceDossier())!); assert.ok(withFocus.current_question); diff --git a/frontend/tests/rectification-discriminator-followup-consistency.test.ts b/frontend/tests/rectification-discriminator-followup-consistency.test.ts new file mode 100644 index 00000000..8b386954 --- /dev/null +++ b/frontend/tests/rectification-discriminator-followup-consistency.test.ts @@ -0,0 +1,474 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { + inspectDiscriminatorProbes, + withCompletedContrastOptions, + type CandidateDiscriminatorProbe, +} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; +import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts"; +import { + contrastPacketFromDossier, + decideFromDossier, + followupAsksRenderableDiscriminator, + rectificationFollowupCatalog, + type DecisionDossier, +} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; + +const CAREER_2020_04: ConflictProbe = { + id: "probe:career.2020.04.dasha_boundary", + semantic_key: "career.2020.04.dasha_boundary", + candidate_split_hash: "career.2020.04", + domain: "career", + year: 2020, + question: "2020 年 4 月前后有没有入职或换工作?", + candidate_ids: ["05:00", "05:10"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, + { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.9, + source: "dasha_boundary", + choice_kind: "existence", +}; + +const REL_2024_05: ConflictProbe = { + id: "probe:relationship.2024.05.dasha_boundary", + semantic_key: "relationship.2024.05.dasha_boundary", + candidate_split_hash: "relationship.2024.05", + domain: "relationship", + year: 2024, + question: "2024 年 5 月前后有没有开始一段认真关系?", + candidate_ids: ["05:00", "05:10"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, + { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.85, + source: "dasha_boundary", + choice_kind: "existence", +}; + +const D24: ConflictProbe = { + id: "contrast:varga.d24.05:00/05:10", + semantic_key: "varga.d24.05:00/05:10", + candidate_split_hash: "varga.d24.05:00/05:10", + domain: "education", + year: 0, + question: "有没有学业或考试发挥明显失常、压力特别大的时候?", + candidate_ids: ["05:00", "05:10"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, + { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 2.5, + source: "varga_contrast", + choice_kind: "event_quality", +}; + +const D12: ConflictProbe = { + id: "contrast:varga.d12.05:00/05:10", + semantic_key: "varga.d12.05:00/05:10", + candidate_split_hash: "varga.d12.05:00/05:10", + domain: "family", + year: 0, + question: "家里有没有结婚、添丁或住院这类事?", + candidate_ids: ["05:00", "05:10"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, + { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.8, + source: "varga_contrast", + choice_kind: "existence", +}; + +const D9_STYLE_NO_OPTIONS: ConflictProbe = { + id: "contrast:varga.d9.巨蟹座/狮子座", + semantic_key: "varga.d9.巨蟹座/狮子座", + candidate_split_hash: "varga.d9.巨蟹座/狮子座", + domain: "relationship", + year: 0, + question: "亲密关系里更接近下面哪一种相处方式?", + candidate_ids: ["05:00", "05:10"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, + { answer_class: "weak_yes", supports: ["05:10"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 1.4, + source: "varga_contrast", + choice_kind: "varga_style", +}; + +const D10_STYLE_NO_OPTIONS: ConflictProbe = { + id: "contrast:varga.d10.天秤座/天蝎座", + semantic_key: "varga.d10.天秤座/天蝎座", + candidate_split_hash: "varga.d10.天秤座/天蝎座", + domain: "career", + year: 0, + question: "平时做事更接近下面哪一种职责风格?", + candidate_ids: ["05:00", "05:10"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, + { answer_class: "weak_yes", supports: ["05:10"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 1.2, + source: "varga_contrast", + choice_kind: "varga_style", +}; + +const FAMILY_2021_COLLECT = { + year: 2021, + year_label: "2021 年前后", + domain: "family" as const, + event_family: "家人结婚、添丁或住院", + source: "age_band" as const, + tracks: ["vimshottari", "narayana"] as const, + tracks_agree: false, + unique_minute_claim: false as const, + user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。", + role: "collect" as const, + phase: "evidence_collection" as const, + information_gain: 0, + semantic_key: "family.2021", + candidate_split_hash: "family:2021", + candidate_ids: [] as const, + expected_outcomes: [] as const, + choice_kind: "existence" as const, +}; + +const EVIDENCE = [ + { + id: "e-career-entry", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-04-01", + occurredTo: null, + eventKind: "career_entry", + }, + { + id: "e-career-exit", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-10-01", + occurredTo: null, + eventKind: "career_exit", + }, + { + id: "e-rel-start", + status: "confirmed", + domain: "relationship", + datePrecision: "month", + occurredFrom: "2024-05-01", + occurredTo: null, + eventKind: "relationship_start", + }, + { + id: "e-rel-end", + status: "confirmed", + domain: "relationship", + datePrecision: "day", + occurredFrom: "2024-08-08", + occurredTo: null, + eventKind: "relationship_end", + }, +] as const; + +function contrastFromConflict(probe: ConflictProbe): CandidateDiscriminatorProbe { + return { + probeId: probe.id, + candidateSetVersion: "05:00-05:10", + question: probe.question, + expectedOutcomes: probe.expected_outcomes.map((row) => ({ + outcomeId: row.answer_class, + supportsCandidateIds: row.supports, + conflictsCandidateIds: row.conflicts, + })), + candidateSplitHash: probe.candidate_split_hash, + informationGain: probe.information_gain, + sourceFeatures: [{ technique: probe.domain, calculationResultId: null }], + domain: probe.domain, + year: probe.year > 0 ? probe.year : null, + semanticKey: probe.semantic_key, + choiceKind: probe.choice_kind, + }; +} + +function deadlockDossier(): { dossier: DecisionDossier; state: ReturnType } { + const state = buildInferenceState({ + range_start: "05:00", + range_end: "05:10", + candidates: [ + { id: "05:00", time: "05:00", relative_support: 18 }, + { id: "05:10", time: "05:10", relative_support: 16 }, + ], + events: [ + { id: "e-career-entry", domain: "career", year: 2020, precision: "month" }, + { id: "e-career-exit", domain: "career", year: 2020, precision: "month" }, + { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" }, + { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" }, + ], + probes: [CAREER_2020_04, REL_2024_05, D24, D12, D9_STYLE_NO_OPTIONS, D10_STYLE_NO_OPTIONS], + answered_probes: [ + { + probe_id: CAREER_2020_04.id, + semantic_key: CAREER_2020_04.semantic_key, + candidate_split_hash: CAREER_2020_04.candidate_split_hash, + answer_class: "yes", + classified_from: "choice", + }, + { + probe_id: REL_2024_05.id, + semantic_key: REL_2024_05.semantic_key, + candidate_split_hash: REL_2024_05.candidate_split_hash, + answer_class: "yes", + classified_from: "choice", + }, + ], + }); + const dossier: DecisionDossier = { + evidence: EVIDENCE, + conversationSummary: { activeFocus: null, declinedSkippedTopics: [] }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + candidates: state.candidates.map((item) => ({ + candidateId: item.id, + time: item.time, + rank: item.rank, + relativeSupport: Math.round(item.posterior_score), + })), + representativeTime: state.representative_time, + evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never), + decisionReceipt: { + inference_state: state, + evidence_collection_probes: [FAMILY_2021_COLLECT], + }, + }, + case: { acceptedTime: null }, + }; + return { dossier, state }; +} + +test("deadlock case: decision and followup agree and still render a D9/D10 style card", () => { + const { dossier } = deadlockDossier(); + const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(dossier)); + assert.equal(inspected.selected?.semanticKey, D24.semantic_key); + + const d9 = withCompletedContrastOptions(contrastFromConflict(D9_STYLE_NO_OPTIONS)); + const d10 = withCompletedContrastOptions(contrastFromConflict(D10_STYLE_NO_OPTIONS)); + assert.equal(d9.ok, true); + assert.equal(d10.ok, true); + if (d9.ok) assert.equal(d9.probe.choiceKind, "varga_style"); + if (d10.ok) assert.equal(d10.probe.choiceKind, "varga_style"); + + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); + const plan = buildMethodFollowupPlan({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: "discriminate_candidates", + ...catalog, + candidatesSeparated: false, + }); + assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), true); + assert.equal(plan.next_followup?.intent, "distinguish_candidates"); + assert.ok(plan.next_followup?.choice_frame); + assert.equal(plan.next_followup?.choice_kind, "varga_style"); + assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d(9|10)\./); + assert.equal( + plan.dropped_probes.some((item) => item.semantic_key.startsWith("varga.d9.") || item.semantic_key.startsWith("varga.d10.")), + false, + JSON.stringify(plan.dropped_probes), + ); + + const decision = decideFromDossier(dossier); + assert.equal(decision.nextAction, "ask_candidate_discriminator"); + assert.equal(decision.sessionOutcome, "discriminate_candidates"); + assert.equal(decision.canConfirmExactMinute, false); +}); + +test("varga_style probes missing style_options complete the same way in inspect and followup ranking", () => { + const probe = contrastFromConflict(D9_STYLE_NO_OPTIONS); + const inspected = inspectDiscriminatorProbes({ + candidateSetVersion: "05:00-05:10", + vargaDifferences: [], + probes: [probe], + }); + assert.ok(inspected.selected); + assert.equal(inspected.selected?.choiceKind, "varga_style"); + assert.equal(inspected.dropped.length, 0); + + const plan = buildMethodFollowupPlan({ + evidence: EVIDENCE, + sessionOutcome: "discriminate_candidates", + candidatesSeparated: false, + contrastPacket: { + candidateSetVersion: "05:00-05:10", + vargaDifferences: [], + probes: [probe], + }, + }); + assert.equal(plan.next_followup?.semantic_key, probe.semanticKey); + assert.equal(plan.next_followup?.choice_kind, "varga_style"); + assert.ok(plan.next_followup?.choice_frame); + assert.equal(plan.dropped_probes.some((item) => item.semantic_key === probe.semanticKey), false); +}); + +test("method coverage incomplete still asks a renderable D9 style card", () => { + const { dossier } = deadlockDossier(); + const decision = decideFromDossier(dossier); + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); + const plan = buildMethodFollowupPlan({ + evidence: dossier.evidence, + declinedTopics: [], + sessionOutcome: "discriminate_candidates", + ...catalog, + candidatesSeparated: false, + }); + assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered"); + assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); + assert.equal(decision.nextAction, "ask_candidate_discriminator"); + assert.equal(plan.next_followup?.intent, "distinguish_candidates"); + assert.ok(plan.next_followup?.choice_frame); + assert.notEqual(plan.next_followup?.source, "method_coverage"); +}); + +test("clock-key varga_style without signs fail-closes to collect instead of a dead-end discriminator", () => { + const d9Clock: ConflictProbe = { + ...D9_STYLE_NO_OPTIONS, + id: "contrast:varga.d9.05:00/05:10", + semantic_key: "varga.d9.05:00/05:10", + candidate_split_hash: "varga.d9.05:00/05:10", + }; + const d10Clock: ConflictProbe = { + ...D10_STYLE_NO_OPTIONS, + id: "contrast:varga.d10.05:00/05:10", + semantic_key: "varga.d10.05:00/05:10", + candidate_split_hash: "varga.d10.05:00/05:10", + }; + const { dossier } = deadlockDossier(); + const state = buildInferenceState({ + range_start: "05:00", + range_end: "05:10", + candidates: [ + { id: "05:00", time: "05:00", relative_support: 18 }, + { id: "05:10", time: "05:10", relative_support: 16 }, + ], + events: [ + { id: "e-career-entry", domain: "career", year: 2020, precision: "month" }, + { id: "e-career-exit", domain: "career", year: 2020, precision: "month" }, + { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" }, + { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" }, + ], + probes: [CAREER_2020_04, REL_2024_05, D24, D12, d9Clock, d10Clock], + answered_probes: [ + { + probe_id: CAREER_2020_04.id, + semantic_key: CAREER_2020_04.semantic_key, + candidate_split_hash: CAREER_2020_04.candidate_split_hash, + answer_class: "yes", + classified_from: "choice", + }, + { + probe_id: REL_2024_05.id, + semantic_key: REL_2024_05.semantic_key, + candidate_split_hash: REL_2024_05.candidate_split_hash, + answer_class: "yes", + classified_from: "choice", + }, + ], + }); + const clockDossier: DecisionDossier = { + ...dossier, + latestResult: { + ...dossier.latestResult!, + decisionReceipt: { + inference_state: state, + evidence_collection_probes: [FAMILY_2021_COLLECT], + }, + }, + }; + const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(clockDossier)); + assert.equal(inspected.selected?.semanticKey, D24.semantic_key); + assert.equal(inspected.selected?.choiceKind, "event_quality"); + const d9 = withCompletedContrastOptions(contrastFromConflict(d9Clock)); + assert.equal(d9.ok, true); + if (d9.ok) assert.equal(d9.probe.choiceKind, "existence"); + + const catalog = rectificationFollowupCatalog(clockDossier.latestResult, clockDossier.evidence); + const plan = buildMethodFollowupPlan({ + evidence: clockDossier.evidence, + declinedTopics: [], + sessionOutcome: "discriminate_candidates", + ...catalog, + candidatesSeparated: false, + }); + assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.choice_frame, null); + assert.equal(plan.next_followup?.source, "method_coverage"); + + const decision = decideFromDossier(clockDossier); + assert.notEqual(decision.nextAction, "ask_candidate_discriminator"); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.canConfirmExactMinute, false); +}); + +test("unrenderable contrast probes are recorded in dropped_probes instead of being dropped silently", () => { + const unrenderable = contrastFromConflict({ + ...D9_STYLE_NO_OPTIONS, + information_gain: 0, + semantic_key: "varga.d9.dropped", + candidate_split_hash: "varga.d9.dropped", + id: "contrast:varga.d9.dropped", + }); + const inspected = inspectDiscriminatorProbes({ + candidateSetVersion: "05:00-05:10", + vargaDifferences: [], + probes: [unrenderable], + }); + assert.equal(inspected.selected, null); + assert.equal(inspected.dropped.some((item) => item.semantic_key === unrenderable.semanticKey), true); + + const plan = buildMethodFollowupPlan({ + evidence: EVIDENCE, + sessionOutcome: "discriminate_candidates", + candidatesSeparated: false, + contrastPacket: { + candidateSetVersion: "05:00-05:10", + vargaDifferences: [], + probes: [unrenderable], + }, + }); + assert.equal( + plan.dropped_probes.some((item) => item.semantic_key === unrenderable.semanticKey), + true, + JSON.stringify(plan.dropped_probes), + ); +}); + +test("agent message path no longer returns the discriminator dead-end copy", () => { + const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); + const fastPath = route.slice( + route.indexOf('if (action === "message")'), + route.indexOf("const requestTime"), + ); + assert.doesNotMatch(fastPath, /目前没有可继续区分/); + assert.match(route, /当前几个候选已经构成可信区间/); + assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION/); + assert.match(fastPath, /!plan\.next_followup/); + assert.ok(fastPath.includes("ask_candidate_discriminator")); + assert.ok(fastPath.includes("open.unrenderable !== true")); + assert.ok(route.indexOf("classifyRectificationTurnIntent") < route.indexOf("runV9AgentTurn({")); +}); diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index e3053c3e..2fa9de54 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -432,3 +432,50 @@ test("contrast probe is not replaced by an already-answered education quality pr assert.notEqual(schema.probe_id, "probe:education.2016"); assert.match(String(schema.probe_id), /varga\.d24/); }); + +test("spoken collect followup persists a collect focus without a choice card", async () => { + const followup: MethodFollowup = { + method_id: "relatives", + intent: "collect_method_evidence", + ask_theme: "family_event", + domain: "family", + kind_hint: "family_event", + user_prompt_hint: "collect", + must_not_label: false, + choice_frame: null, + source: "method_coverage", + probe_year: 2021, + year_label: "2021 年前后", + semantic_key: "family.2021", + }; + const accounting = fakeAccounting({ + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: null, + target_domain: args.p_target_domain, + target_kind: args.p_target_kind, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-08-27T00:00:00.000Z", + resolved_at: null, + idempotent: false, + }), + }); + const result = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: null, + followup, + }); + assert.equal(result.status, "created"); + assert.equal(result.focus?.intent, "collect_method_evidence"); + assert.equal(result.focus?.expectedAnswerSchema.collect, true); + assert.equal(result.focus?.expectedAnswerSchema.choice, undefined); + assert.match(String(result.focus?.expectedAnswerSchema.prompt ?? ""), /2021/); + assert.equal(openQuestionFromPersistedFocus(result), null); +}); diff --git a/frontend/tests/rectification-turn-intent-classifier.test.ts b/frontend/tests/rectification-turn-intent-classifier.test.ts index 06259827..9895de78 100644 --- a/frontend/tests/rectification-turn-intent-classifier.test.ts +++ b/frontend/tests/rectification-turn-intent-classifier.test.ts @@ -105,6 +105,9 @@ test("production intent handling contains no semantic regex or positional text p assert.ok(route.indexOf("classifyRectificationTurnIntent") < route.indexOf("runV9AgentTurn({")); assert.ok(route.indexOf("persistServerOwnedFocus") < route.indexOf("runV9AgentTurn({")); assert.ok(fastPath.includes("ask_candidate_discriminator")); - assert.match(fastPath, /目前没有可继续区分/); + assert.doesNotMatch(fastPath, /目前没有可继续区分/); + assert.match(route, /当前几个候选已经构成可信区间/); + assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION/); + assert.match(fastPath, /!plan\.next_followup/); assert.doesNotMatch(route, /classified\.answer_class!/); }); diff --git a/frontend/tests/rectification-varga-style-weight.test.ts b/frontend/tests/rectification-varga-style-weight.test.ts index 3b222260..9b7fcf24 100644 --- a/frontend/tests/rectification-varga-style-weight.test.ts +++ b/frontend/tests/rectification-varga-style-weight.test.ts @@ -161,6 +161,13 @@ test("varga_style B-option weak_yes counts as strong conflict; existence weak_ye assert.equal(existenceCounts[existenceConflicted], 0); }); +test("varga_style contrast writeback keeps style_options for the next round", () => { + const probe = styleProbeFrom(twoGroupStylePacket()); + assert.equal(probe.choice_kind, "varga_style"); + assert.ok((probe.style_options?.length ?? 0) >= 2); + assert.ok(probe.style_options?.every((item) => item.label.trim().length > 0)); +}); + test("engine varga.d9/d10 without style_options scores with the render effective kind", () => { for (const semanticKey of ["varga.d9", "varga.d10"] as const) { const packet = {