diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d6429573..e8f3037a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7054,3 +7054,19 @@ - 相关记录:BUG-456 - 复发自:无(提交 `797a423a` 曾出现同类的"改了 Skill 字节但未刷新 registry hash",当时未单独立项) - 修复版本:Skill `10.0.14` + +## BUG-460 | 非终态轮缺少统一出口闸导致 answer_choice 与消息早退进入无问题死路 + +- 状态:resolved +- 首次发现:2026-09-01 +- 最近更新:2026-09-01 +- 影响面:POST `/api/rectification/agent` 的 `opening`、`message`、`answer_choice`、`stop_and_review` 推进轮,`decideRectification` 的 probe 耗尽转向,以及服务端问题在 turn 历史中的可见性 +- 用户现象:真实 Case `645ba774-67fb-4d6f-b842-f59e4d97adc0` 已有 7 条证据、3 个领域并答完 6 道区分题后,仍保持正确的 `can_adopt=false`,但 `current_question=null`,界面永久显示等待服务端更新。 +- 触发条件:最后一步走 `answer_choice`,或 `message` 命中任一 HTTP 200 确定性早退;同时普通 discriminator 已耗尽、候选分离不足,原决策链直接返回区间而未继续尝试 holdout、带日期收集或 Nakshatra 边界题。 +- 根因:BUG-456 只在当时命中的 opening/message 分支附近补了兜底调用,没有建立所有推进轮共享的后置条件;后来新增或既有的 structured choice 与多个消息早退仍可绕过。选择题历史另以「接下来请点选下面这一问。」代替服务端 focus prompt,导致回看时看不到真实问题。 +- 修复:所有成功的推进轮统一经过 awaited 公共出口闸;immediate 路径必须等 focus 持久化完成后才返回 HTTP 200,stream 路径必须在 `done` 前完成。`read_only` 显式无副作用。闸门最终重读 Case 并验证 `current_question || canAdopt || 已终态`,无法建立后置条件时 fail-closed。probe 耗尽按普通 discriminator → holdout → dated collect → Nakshatra 边界题 → 明确区间出口转向,所有路径继续使用既有 delivery capability,事故 Case 的 `canAdopt=false` 不变。服务端 focus prompt 直接写入 turn 历史,不让模型复述。 +- 验证:任务 0 三条不变量覆盖四种推进 action 与 answer_choice 响应可见时序;上一轮五条收紧不变量继续通过;rectification、skill registry 与 TypeScript 最终数字见本次交付报告。未进行真实 staging smoke。 +- 防复发:新增 action 必须先加入穷举的 execution-kind 映射,否则 TypeScript/结构契约失败;所有 HTTP 200 推进出口只能在公共闸门之后可见,所有流式成功出口只能在公共闸门之后发送 `done`。不得用正文、问号或 A/B/C/D 文本推断当前问题,也不得通过放宽采用门槛消除无下一步状态。 +- 相关记录:BUG-459 +- 复发自:BUG-456 +- 修复版本:待发布 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index d7970fae..6110349a 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -13,7 +13,6 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica import { applyRectificationChoice, applyCollectFocusDenial, - ensureNonTerminalTurnExit, persistNextInterviewIfIdle, } from "@/lib/rectification-agentic/v9/answer-choice"; import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service"; @@ -41,6 +40,12 @@ import { import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema, isRenderableChoiceOpenQuestion } from "@/lib/rectification-agentic/v9/server-focus"; import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup"; import { isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision"; +import { + awaitTurnExitBeforeResponse, + finalizeSuccessfulTurnExit, + RECTIFICATION_ACTION_EXECUTION, + type RectificationRouteAction, +} from "@/lib/rectification-agentic/v9/turn-exit"; export const runtime = "nodejs"; export const maxDuration = 240; @@ -86,6 +91,9 @@ const agentRequestSchema = z.object({ clientActionId: z.string().uuid().optional(), }).strict(); +type ParsedRectificationAction = z.infer["action"]; +const rectificationActionExecution: Record = RECTIFICATION_ACTION_EXECUTION; + function actionToBudget(action: "opening" | "message" | "read_only"): RectificationAgentAction { if (action === "opening") return "opening"; if (action === "read_only") return "read_only"; @@ -170,7 +178,8 @@ export async function POST(request: Request) { const userId = user.id; const { caseId, sessionId, requestId, action } = parsed.data; - const isStructuredChoice = action === "answer_choice" || action === "stop_and_review"; + const execution = rectificationActionExecution[action]; + const isStructuredChoice = execution === "immediate"; // Feature selector: the V9 runtime is DB-driven. When the flag is not // published/enabled, no new runs are served (legacy stays read-only). @@ -246,8 +255,15 @@ export async function POST(request: Request) { ); } - if (isStructuredChoice) { - const actionId = parsed.data.actionId; + const selectedModel = isStructuredChoice + ? null + : await resolveSessionLanguageModel( + chatSession.model_id, + chatSession.model_config_version, + ); + const immediateResponse = await (async (): Promise => { + if (isStructuredChoice) { + const actionId = parsed.data.actionId; const focusId = parsed.data.focusId; const expectedRevision = parsed.data.expectedRevision; if (!actionId || !focusId || expectedRevision === undefined) { @@ -309,11 +325,8 @@ export async function POST(request: Request) { } } - const selectedModel = await resolveSessionLanguageModel( - chatSession.model_id, - chatSession.model_config_version, - ); - if (!selectedModel) { + const resolvedModel = selectedModel; + if (!resolvedModel) { return NextResponse.json( { error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" }, { status: 409 }, @@ -328,7 +341,7 @@ export async function POST(request: Request) { if (focus && choice) { let classified = null; try { - classified = await classifyRectificationTurnIntent(selectedModel, { + classified = await classifyRectificationTurnIntent(resolvedModel, { focus, userMessage: parsed.data.message ?? "", caseStatus, @@ -537,6 +550,34 @@ export async function POST(request: Request) { } } + return null; + })(); + if (immediateResponse) { + if (immediateResponse.status !== 200) return immediateResponse; + const response = await awaitTurnExitBeforeResponse( + immediateResponse, + () => finalizeSuccessfulTurnExit({ + accounting: accounting as never, + userId, + caseId, + action, + }), + ); + return response; + } + if (action === "answer_choice" || action === "stop_and_review") { + return NextResponse.json( + { error: "选择题处理失败", message: "请稍后重试。" }, + { status: 500 }, + ); + } + if (!selectedModel) { + return NextResponse.json( + { error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" }, + { status: 409 }, + ); + } + const requestTime = new Date(); const chinaTime = new Date(requestTime.getTime() + 8 * 60 * 60 * 1000) .toISOString() @@ -673,30 +714,14 @@ export async function POST(request: Request) { if (!result.ok) { send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" }); } else { - if (action === "message" || action === "opening") { - try { - await persistNextInterviewIfIdle({ - accounting: accounting as never, - userId, - caseId, - }); - } catch (error) { - console.warn( - `[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, - ); - } - try { - await ensureNonTerminalTurnExit({ - accounting: accounting as never, - userId, - caseId, - }); - } catch (error) { - console.warn( - `[rectification-v9] nonterminal turn exit repair failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, - ); - } - } + // Shared gate owns persistNextInterviewIfIdle then ensureNonTerminalTurnExit; + // The shared gate replaces the old message/opening-only cleanup. + await finalizeSuccessfulTurnExit({ + accounting: accounting as never, + userId, + caseId, + action, + }); send({ type: "done", emitted: true }); } } catch (error) { diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index fa4037c1..55e5ca4f 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -150,6 +150,7 @@ export type DecideRectificationInput = Readonly<{ trainingGateOpen?: boolean; candidateScores: readonly CandidateScoreRow[]; discriminatorProbe?: CandidateDiscriminatorProbe | null; + nakshatraBoundaryProbe?: CandidateDiscriminatorProbe | null; holdoutValidation?: HoldoutValidationStatus; accepted?: boolean; inferenceCredibleRange?: readonly [string, string] | null; @@ -277,6 +278,23 @@ export function decideRectification(input: DecideRectificationInput): Rectificat if (probe) { return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason); } + if (holdout === "not_started") { + return holdoutValidation(separation, range, capability); + } + if (input.datedMethodCollectOpen === true) { + return collect(separation, holdout, range, null, capability, stopReason); + } + if (input.nakshatraBoundaryProbe) { + return discriminateOrExhaust( + input, + separation, + holdout, + range, + input.nakshatraBoundaryProbe, + capability, + stopReason, + ); + } return stopClass?.kind === "exhausted" ? completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason) : completeWithRange(separation, holdout, range, "offer", capability); diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index bf4a1b50..e7cf0e83 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -12,6 +12,7 @@ import { RECTIFICATION_TERMINATION_COPY, isNonConvergingRangeOffer, nonConvergin import { applyChoiceWithoutEvidence, previousInferenceFromReceipt, + withNakshatraBoundaryProbe, } from "./inference-adapter"; import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCatalog } from "./decision-from-dossier"; import type { InferenceState } from "../core/types.ts"; @@ -49,6 +50,7 @@ import { spokenFollowupForUser, } from "./method-followup"; import type { SessionOutcomeKind } from "./confirmation-gate"; +import { refinementFromDecisionReceipt } from "./refinement-packet"; import { projectCurrentQuestion } from "./turn-decision"; export type ApplyChoiceCommand = Readonly<{ @@ -136,7 +138,11 @@ export async function applyRectificationChoice( const optionId = command.optionId; const questionId = focus.questionId; const scoring = schema.scoring !== false && !questionId.endsWith(":holdout"); - const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + const receipt = dossier.latestResult?.decisionReceipt ?? null; + const previous = withNakshatraBoundaryProbe( + previousInferenceFromReceipt(receipt), + refinementFromDecisionReceipt(receipt).nakshatra_boundary, + ); const answerClass = optionId === "stop" ? null : outcomeIdForOption(optionId, schema); if (optionId !== "stop" && !answerClass) { throw new RectificationToolServiceError("agentic_rectification_invalid_choice_schema"); @@ -322,8 +328,8 @@ export async function persistNextInterviewAfterChoice(input: { followup, }); const open = openQuestionFromPersistedFocus(persistedFocus); - if (isRenderableChoiceOpenQuestion(open)) { - return { hostNarration: "接下来请点选下面这一问。", choiceReady: true }; + if (isRenderableChoiceOpenQuestion(open) && open.prompt) { + return { hostNarration: open.prompt, choiceReady: true }; } if (followup?.choice_frame) { const spokenFollowup = spokenCollectFallbackFollowup(followup); @@ -749,15 +755,12 @@ ${nonConvergingRangeNarration({ }; } -export async function ensureNonTerminalTurnExit(input: { +async function inspectNonTerminalTurnExit(input: { accounting: AccountingClient; userId: string; caseId: string; -}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> { +}) { const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); - if (projectCurrentQuestion(dossier.conversationSummary.activeFocus)) { - return { persisted: false, choiceReady: false, hostNarration: null }; - } let birthDate: string | null = null; try { const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId); @@ -766,12 +769,23 @@ export async function ensureNonTerminalTurnExit(input: { birthDate = null; } const decision = decideFromDossier(dossier, { birthDate }); - if ( - dossier.case.acceptedTime + const satisfied = Boolean( + projectCurrentQuestion(dossier.conversationSummary.activeFocus) + || dossier.case.acceptedTime || dossier.case.confirmedTime || decision.completionStatus === "provisional_range_user_stopped" || decision.canAdopt - ) { + ); + return { dossier, decision, satisfied }; +} + +export async function ensureNonTerminalTurnExit(input: { + accounting: AccountingClient; + userId: string; + caseId: string; +}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> { + const before = await inspectNonTerminalTurnExit(input); + if (before.satisfied) { return { persisted: false, choiceReady: false, hostNarration: null }; } console.warn(JSON.stringify({ @@ -779,14 +793,19 @@ export async function ensureNonTerminalTurnExit(input: { case_id: input.caseId, reason: "missing_question_and_adopt_carrier", })); - return persistExhaustionCollect({ + const repaired = await persistExhaustionCollect({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, - dossier, - decision, - decisionReceipt: dossier.latestResult?.decisionReceipt, + dossier: before.dossier, + decision: before.decision, + decisionReceipt: before.dossier.latestResult?.decisionReceipt, }); + const after = await inspectNonTerminalTurnExit(input); + if (!after.satisfied) { + throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing"); + } + return repaired; } function optionQuoteFromSchema(schema: Readonly>, optionId: ChoiceKey): string | null { 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 f7871203..474da9d9 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -23,7 +23,7 @@ import { type HoldoutValidationStatus, type RectificationDecision, } from "../core/rectification-decision.ts"; -import type { InferenceState } from "../core/types.ts"; +import type { ConflictProbe, InferenceState } from "../core/types.ts"; import { askedDiscriminatorKeys, authoritativeCandidateProjection, @@ -185,6 +185,7 @@ export function contrastPacketFromLatestResult( const fromInference: EngineContrastProbe[] = (inference?.probes ?? []).flatMap((probe) => { if (answered.has(probe.id) || probe.information_gain <= 0) return []; if (probe.source === "known_event_quality") return []; + if (probe.source === "nakshatra_boundary") return []; return [{ semantic_key: probe.semantic_key, candidate_split_hash: probe.candidate_split_hash, @@ -193,7 +194,6 @@ export function contrastPacketFromLatestResult( user_meaning: probe.question, information_gain: probe.information_gain, expected_outcomes: probe.expected_outcomes, - candidate_ids: probe.candidate_ids, ...(probe.choice_kind === "varga_style" || probe.choice_kind === "event_quality" || probe.choice_kind === "existence" @@ -212,7 +212,6 @@ export function contrastPacketFromLatestResult( user_meaning: probe.user_meaning, information_gain: probe.information_gain, expected_outcomes: probe.expected_outcomes, - candidate_ids: probe.candidate_ids, choice_kind: probe.choice_kind, style_options: probe.style_options, })), @@ -273,7 +272,7 @@ export function rectificationFollowupCatalog( eventClarificationProbes: refinement.event_clarification_probes, evidenceCollectionProbes: refinement.evidence_collection_probes, precisionStage: refinement.precision_stage?.current ?? null, - nakshatraBoundary: refinement.nakshatra_boundary, + nakshatraProbe: inference?.probes.find((probe) => probe.source === "nakshatra_boundary") ?? null, oosBlindPrompts: refinement.oos_blind_prompts, holdoutEvents: (inference?.events ?? []) .filter((item) => item.usage === "holdout") @@ -287,7 +286,7 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket candidateSetVersion: state.candidate_set_id, calculationResultId: null, engineProbes: state.probes - .filter((item) => !answered.has(item.id)) + .filter((item) => !answered.has(item.id) && item.source !== "nakshatra_boundary") .map((item) => ({ semantic_key: item.semantic_key, candidate_split_hash: item.candidate_split_hash, @@ -442,6 +441,74 @@ function discriminatorProbeIfFollowupCanAsk(input: { return { probe: matched ?? input.inspected.selected, dropped }; } +function nakshatraContrastPacket( + probe: ConflictProbe, + candidateSetVersion: string, +): CandidateContrastPacket { + return buildCandidateContrastPacket({ + candidateSetVersion, + engineProbes: [{ + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + domain: probe.domain, + year: probe.year > 0 ? probe.year : undefined, + user_meaning: probe.question, + information_gain: probe.information_gain, + expected_outcomes: probe.expected_outcomes, + choice_kind: probe.choice_kind, + style_options: probe.style_options, + }], + }); +} + +function nakshatraProbeIfFollowupCanAsk(input: { + dossier: DecisionDossier; + probe: ConflictProbe | null; + candidateSetVersion: string; + askedKeys: readonly string[]; + topCandidateTimes: readonly string[]; + holdoutValidation: HoldoutValidationStatus; + birthDate?: string | null; +}): { probe: CandidateDiscriminatorProbe | null; dropped: DroppedProbe[] } { + if (!input.probe) return { probe: null, dropped: [] }; + const packet = nakshatraContrastPacket(input.probe, input.candidateSetVersion); + const inspected = inspectDiscriminatorProbes(packet, { + askedKeys: input.askedKeys, + mentionedKeys: mentionedVargaKeysFromLedgerEvidence(input.dossier.evidence), + topCandidateTimes: input.topCandidateTimes, + }); + if (!inspected.selected) return { probe: null, dropped: inspected.dropped }; + const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence); + const plan = buildMethodFollowupPlan({ + evidence: input.dossier.evidence, + declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: "discriminate_candidates", + contrastPacket: { ...packet, probes: [] }, + topCandidateTimes: input.topCandidateTimes, + askedProbeKeys: input.askedKeys, + eventProbes: [], + eventClarificationProbes: [], + evidenceCollectionProbes: [], + precisionStage: null, + nakshatraProbe: input.probe, + holdoutValidation: input.holdoutValidation, + oosBlindPrompts: catalog.oosBlindPrompts, + holdoutEvents: catalog.holdoutEvents, + ...(input.birthDate ? { birthDate: input.birthDate } : {}), + candidatesSeparated: false, + }); + if (!followupAsksRenderableDiscriminator(plan.next_followup)) { + return { probe: null, dropped: mergeDroppedProbes(inspected.dropped, plan.dropped_probes) }; + } + const key = plan.next_followup?.semantic_key; + const matched = key ? completedProbeForSemanticKey(key, packet, []) : null; + return { + probe: matched ?? inspected.selected, + dropped: mergeDroppedProbes(inspected.dropped, plan.dropped_probes), + }; +} + function evidenceStopInputs(evidence: DecisionDossier["evidence"]): { datedEventCount: number; datedDomainCount: number; @@ -511,6 +578,16 @@ export function decideFromDossier( askedKeys, birthDate: options?.birthDate, }); + const holdoutValidation = holdoutStatusFromInference(inference, oosBlindPrompts); + const nakshatra = nakshatraProbeIfFollowupCanAsk({ + dossier, + probe: inference?.probes.find((probe) => probe.source === "nakshatra_boundary") ?? null, + candidateSetVersion: inference?.candidate_set_id ?? "", + askedKeys, + topCandidateTimes, + holdoutValidation, + birthDate: options?.birthDate, + }); return { ...decideRectification({ methodCoverageAll: blockingMethodsCovered(collecting.methods), @@ -520,7 +597,8 @@ export function decideFromDossier( snapshotCurrent, candidateScores: candidateScoresFromDossier(dossier.latestResult), discriminatorProbe: gated.probe, - holdoutValidation: holdoutStatusFromInference(inference, oosBlindPrompts), + nakshatraBoundaryProbe: nakshatra.probe, + holdoutValidation, accepted: Boolean(dossier.case.acceptedTime), inferenceCredibleRange: inference?.credible_range ?? null, engineCeiling: engineCapabilityCeilingFromReceipt(latest?.decisionReceipt ?? null), @@ -529,7 +607,7 @@ export function decideFromDossier( ...evidenceStops, userUncertaintyHigh, }), - droppedProbes: gated.dropped, + droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped), }; } @@ -570,11 +648,29 @@ export function decideAfterInferenceChange(input: { askedKeys: input.state.answered_probes.map((item) => item.semantic_key), topCandidateTimes, }); + const askedKeys = input.state.answered_probes.flatMap((item) => [ + item.probe_id, + item.semantic_key, + item.candidate_split_hash, + ]); const gated = discriminatorProbeIfFollowupCanAsk({ dossier: input.dossier, inspected, contrastPacket, - askedKeys: input.state.answered_probes.map((item) => item.semantic_key), + askedKeys, + birthDate: input.birthDate, + }); + const oosBlindPrompts = refinementFromDecisionReceipt( + input.dossier.latestResult?.decisionReceipt ?? null, + ).oos_blind_prompts; + const holdoutValidation = holdoutStatusFromState(input.state, oosBlindPrompts); + const nakshatra = nakshatraProbeIfFollowupCanAsk({ + dossier: input.dossier, + probe: input.state.probes.find((probe) => probe.source === "nakshatra_boundary") ?? null, + candidateSetVersion: input.state.candidate_set_id, + askedKeys, + topCandidateTimes, + holdoutValidation, birthDate: input.birthDate, }); return { @@ -586,10 +682,8 @@ export function decideAfterInferenceChange(input: { .filter((item) => item.status !== "eliminated") .map((item) => ({ time: item.time, score: item.posterior_score })), discriminatorProbe: gated.probe, - holdoutValidation: holdoutStatusFromState( - input.state, - refinementFromDecisionReceipt(input.dossier.latestResult?.decisionReceipt ?? null).oos_blind_prompts, - ), + nakshatraBoundaryProbe: nakshatra.probe, + holdoutValidation, inferenceCredibleRange: input.state.credible_range, userStopped: input.userStopped, accepted: Boolean(input.dossier.case.acceptedTime), @@ -599,7 +693,7 @@ export function decideAfterInferenceChange(input: { ...evidenceStops, userUncertaintyHigh, }), - droppedProbes: gated.dropped, + droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped), }; } diff --git a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts index 26a1309c..c88b2cb6 100644 --- a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts +++ b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts @@ -13,14 +13,20 @@ import { selectHighestGainProbe } from "../core/select-probe.ts"; import { askedEventProbeKeysFromLedgerEvidence } from "../core/candidate-contrast-packet.ts"; import { rankActive } from "../core/convergence-evaluator.ts"; import { rangeFromTimes, unionStillValidRange } from "../core/credible-range.ts"; -import { previousInferenceFromReceipt } from "../core/compose-receipt.ts"; +import { + previousInferenceFromReceipt as parsePreviousInferenceFromReceipt, +} from "../core/compose-receipt.ts"; import type { AnswerClass, ConflictProbe, InferenceState, ProbeAnswer } from "../core/types.ts"; import { datedPrecision } from "./evidence-model.ts"; import { isHoldoutVerificationQuote, type ChoiceKey, } from "./choice-card.ts"; -import type { DiscriminatingEventProbe } from "./refinement-packet.ts"; +import { + parseNakshatraBoundary, + type DiscriminatingEventProbe, + type NakshatraBoundary, +} from "./refinement-packet.ts"; function yearFrom(value: string | null | undefined): number | null { if (!value || value.length < 4 || !/^\d{4}/.test(value)) return null; @@ -68,7 +74,122 @@ export function askedDiscriminatorKeys( ]; } -export { previousInferenceFromReceipt }; +const NAKSHATRA_BOUNDARY_SOURCE = "nakshatra_boundary"; + +export function nakshatraBoundaryProbe( + state: InferenceState | null | undefined, + boundary: NakshatraBoundary | null | undefined, +): ConflictProbe | null { + if (!state || !boundary?.near_boundary) return null; + const optionA = boundary.options.find((item) => item.key === "A") ?? null; + const optionB = boundary.options.find((item) => item.key === "B") ?? null; + if ( + !optionA?.traits.length + || !optionB?.traits.length + || optionA.time_bias === optionB.time_bias + ) return null; + + const active = rankActive(state.candidates) + .slice() + .sort((left, right) => left.time.localeCompare(right.time)); + if (active.length < 2) return null; + const pivot = Math.ceil(active.length / 2); + const earlier = active.slice(0, pivot).map((item) => item.id); + const later = active.slice(pivot).map((item) => item.id); + if (earlier.length === 0 || later.length === 0) return null; + + const semanticKey = `nakshatra-boundary:${state.candidate_set_id}`; + const candidateSplitHash = `${semanticKey}:${earlier.join(",")}|${later.join(",")}`; + const candidatesFor = (bias: "earlier" | "later") => bias === "earlier" ? earlier : later; + const conflictsFor = (bias: "earlier" | "later") => bias === "earlier" ? later : earlier; + const optionLabel = (key: "A" | "B", traits: readonly string[]) => `${key} 组:${traits.join("、")}`; + + return { + id: `probe:${semanticKey}`, + semantic_key: semanticKey, + candidate_split_hash: candidateSplitHash, + domain: "appearance", + year: 0, + question: boundary.user_meaning + ?? "升点靠近两段日常节奏的交界。平时做事时,哪一组更像你?这只用来偏置时间窗,不能确认唯一分钟。", + candidate_ids: active.map((item) => item.id), + expected_outcomes: [ + { + answer_class: "yes", + supports: candidatesFor(optionA.time_bias), + conflicts: conflictsFor(optionA.time_bias), + }, + { + answer_class: "weak_yes", + supports: candidatesFor(optionB.time_bias), + conflicts: conflictsFor(optionB.time_bias), + }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.01, + source: NAKSHATRA_BOUNDARY_SOURCE, + choice_kind: "varga_style", + style_options: [ + { + label: optionLabel("A", optionA.traits), + answer_class: "yes", + sign: optionA.time_bias === "earlier" ? "较早时间窗" : "较晚时间窗", + }, + { + label: optionLabel("B", optionB.traits), + answer_class: "weak_yes", + sign: optionB.time_bias === "earlier" ? "较早时间窗" : "较晚时间窗", + }, + { label: "两组都不太像我", answer_class: "no" }, + { label: "一时说不好", answer_class: "unsure" }, + ], + }; +} + +export function withNakshatraBoundaryProbe( + state: InferenceState | null, + boundary: NakshatraBoundary | null | undefined, +): InferenceState | null { + if (!state) return null; + const probe = nakshatraBoundaryProbe(state, boundary); + const existingIndexes = state.probes.flatMap((item, index) => ( + item.source === NAKSHATRA_BOUNDARY_SOURCE ? [index] : [] + )); + if (!probe || isDuplicateProbe(probe, state.answered_probes)) { + if (existingIndexes.length === 0) return state; + return { + ...state, + probes: state.probes.filter((item) => item.source !== NAKSHATRA_BOUNDARY_SOURCE), + }; + } + const existing = existingIndexes.length > 0 ? state.probes[existingIndexes[0]!] : null; + if ( + existingIndexes.length === 1 + && existing?.id === probe.id + && existing.semantic_key === probe.semantic_key + && existing.candidate_split_hash === probe.candidate_split_hash + ) return state; + const probes = state.probes.filter((item) => item.source !== NAKSHATRA_BOUNDARY_SOURCE); + const insertAt = existingIndexes[0] ?? probes.length; + return { + ...state, + probes: [ + ...probes.slice(0, insertAt), + probe, + ...probes.slice(insertAt), + ], + }; +} + +export function previousInferenceFromReceipt( + receipt: Readonly> | null | undefined, +): InferenceState | null { + return withNakshatraBoundaryProbe( + parsePreviousInferenceFromReceipt(receipt), + parseNakshatraBoundary(receipt?.nakshatra_boundary), + ); +} type CandidateSnapshotRow = Readonly<{ candidateId?: string; diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 120a47af..a740cfb1 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -68,6 +68,7 @@ import { parseAgentChoiceCopy, preferConcreteChoicePrompt, mergeChoiceCard, + serverOwnedChoiceCopy, type RectificationChoiceCard, type RectificationChoiceFrame, } from "./choice-card.ts"; @@ -77,6 +78,7 @@ import { decideRectification, type HoldoutValidationStatus, } from "../core/rectification-decision.ts"; +import type { ConflictProbe } from "../core/types.ts"; import { datedDomainsFromEvidence, isStructuredDiscriminator, @@ -104,7 +106,6 @@ import type { DiscriminatingEventProbe, EventProbeDomain, EventProbeStyleOption, - NakshatraBoundary, OosBlindPrompt, PrecisionStageId, } from "./refinement-packet"; @@ -947,7 +948,7 @@ export const GENERIC_COLLECT_QUESTION = "请先说一件你记得大概时间的 export function spokenFollowupForUser(followup: MethodFollowup | null): string | null { if (!followup) return null; - if (followup.choice_frame) return "接下来请点选下面这一问。"; + if (followup.choice_frame) return serverOwnedChoiceCopy(followup.choice_frame)?.prompt ?? null; if (followup.intent !== "collect_method_evidence") return null; const base = USER_COLLECT_QUESTION[followup.domain ?? ""] ?? GENERIC_COLLECT_QUESTION; @@ -1307,7 +1308,7 @@ export function buildMethodFollowupPlan(input: { observations?: readonly InternalVargaObservation[]; sessionOutcome?: SessionOutcomeKind; precisionStage?: PrecisionStageId | null; - nakshatraBoundary?: NakshatraBoundary | null; + nakshatraProbe?: ConflictProbe | null; oosBlindPrompts?: readonly OosBlindPrompt[]; eventProbes?: readonly DiscriminatingEventProbe[]; eventClarificationProbes?: readonly DiscriminatingEventProbe[]; @@ -1614,7 +1615,25 @@ export function buildMethodFollowupPlan(input: { probe_id: contrast.probeId, }, true, true); }; - if (!dashaCovered) { + if (dashaCovered) { + const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated; + for (const ranked of rankedDiscriminators) { + if (!allowLowGainDiscriminator && ranked.score < 0.08) continue; + const candidate = followupFromRanked(ranked); + if (candidate.choice_frame) { + next = candidate; + break; + } + } + } + if (!next && input.holdoutValidation === "not_started") { + const fields = holdoutAskFields( + input.oosBlindPrompts?.[0], + (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null, + ); + if (fields) next = makeFollowup(fields, false); + } + if (!next && !dashaCovered) { next = makeFollowup({ method_id: "dasha_events", intent: "collect_method_evidence", @@ -1628,37 +1647,54 @@ export function buildMethodFollowupPlan(input: { ), source: "method_coverage", }); - } else { + } + if (!next && dashaCovered && coverageComplete) { const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated; - for (const ranked of rankedDiscriminators) { - if (!allowLowGainDiscriminator && ranked.score < 0.08) continue; - const candidate = followupFromRanked(ranked); - if (candidate.choice_frame) { - next = candidate; - break; - } - } - if (!next && coverageComplete) { - const yearless = yearlessDiscriminators[0]; - if (yearless && (allowLowGainDiscriminator || yearless.score >= 0.08)) { - const domain = contrastFollowupDomain( - yearless.eventProbe?.domain ?? yearless.contrastProbe?.domain ?? null, - ); - const lead = YEARLESS_COLLECT_LEAD[domain]; - if (lead && !declined.has(domain)) { - next = makeFollowup({ - method_id: PROBE_METHOD_ID[domain], - intent: "collect_method_evidence", - ask_theme: REVERSE_VERIFY_THEME[domain], - domain, - kind_hint: REVERSE_VERIFY_KIND[domain], - user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]), - source: "method_coverage", - }); - } + const yearless = yearlessDiscriminators[0]; + if (yearless && (allowLowGainDiscriminator || yearless.score >= 0.08)) { + const domain = contrastFollowupDomain( + yearless.eventProbe?.domain ?? yearless.contrastProbe?.domain ?? null, + ); + const lead = YEARLESS_COLLECT_LEAD[domain]; + if (lead && !declined.has(domain)) { + next = makeFollowup({ + method_id: PROBE_METHOD_ID[domain], + intent: "collect_method_evidence", + ask_theme: REVERSE_VERIFY_THEME[domain], + domain, + kind_hint: REVERSE_VERIFY_KIND[domain], + user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]), + source: "method_coverage", + }); } } } + if ( + !next + && input.holdoutValidation !== "not_started" + && !datedMethodCollectOpen(methods) + && input.nakshatraProbe + ) { + const probe = input.nakshatraProbe; + next = makeFollowup({ + method_id: "nakshatra_boundary", + intent: "distinguish_candidates", + ask_theme: "nakshatra_trait", + domain: probe.domain, + kind_hint: null, + user_prompt_hint: probe.question, + source: "nakshatra_boundary", + information_gain: probe.information_gain, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + probe_year: probe.year, + choice_kind: probe.choice_kind ?? "varga_style", + candidate_ids: probe.candidate_ids, + expected_outcomes: probe.expected_outcomes, + style_options: probe.style_options, + probe_id: probe.id, + }, true, true); + } const sameDomainYearlessCard = (domain: string): MethodFollowup | null => { const ranked = yearlessDiscriminators.find((row) => ( (row.eventProbe?.domain ?? row.contrastProbe?.domain ?? null) === domain @@ -1934,23 +1970,6 @@ export function buildMethodFollowupPlan(input: { ), source: "varga_observation", }); - } else if (input.nakshatraBoundary?.near_boundary) { - next = makeFollowup({ - method_id: "nakshatra_boundary", - intent: "distinguish_candidates", - ask_theme: "nakshatra_trait", - domain: null, - kind_hint: null, - user_prompt_hint: input.nakshatraBoundary.user_meaning - ?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。", - source: "nakshatra_boundary", - }); - } else if (input.holdoutValidation === "not_started") { - const fields = holdoutAskFields( - input.oosBlindPrompts?.[0], - (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null, - ); - if (fields) next = makeFollowup(fields, false); } else if (horaryStatus === "uncovered") { next = sameDomainYearlessCard("horary") ?? makeFollowup({ method_id: "horary", diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 5e9e1db7..ca57a567 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -8,8 +8,10 @@ import { askedProbeKeysFromReceipt, stampChoiceSchemaWithProbe, previousInferenceFromReceipt, + withNakshatraBoundaryProbe, } from "./inference-adapter"; import { spokenFollowupForUser, type MethodFollowup } from "./method-followup"; +import { refinementFromDecisionReceipt } from "./refinement-packet"; import { setV10ConversationFocus, RectificationToolServiceError, @@ -90,7 +92,11 @@ function expectedAnswerSchemaFor( candidate_split_hash: followup.candidate_split_hash ?? null, choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence", }; - const state = previousInferenceFromReceipt(decisionReceipt ?? null); + const receipt = decisionReceipt ?? null; + const state = withNakshatraBoundaryProbe( + previousInferenceFromReceipt(receipt), + refinementFromDecisionReceipt(receipt).nakshatra_boundary, + ); if (decisionReceipt?.inference_state !== undefined && !state) return null; const stamped = stampChoiceSchemaWithProbe( schema, diff --git a/frontend/src/lib/rectification-agentic/v9/turn-exit.ts b/frontend/src/lib/rectification-agentic/v9/turn-exit.ts new file mode 100644 index 00000000..03f51a24 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/turn-exit.ts @@ -0,0 +1,79 @@ +import { + ensureNonTerminalTurnExit, + persistNextInterviewIfIdle, +} from "./answer-choice.ts"; +import { projectCurrentQuestion } from "./turn-decision.ts"; +import { + loadV9CaseDossier, + persistV9DeterministicTurn, + type RectificationRpcClient, +} from "./tool-service.ts"; + +export type RectificationRouteAction = + | "opening" + | "message" + | "read_only" + | "answer_choice" + | "stop_and_review"; + +export const RECTIFICATION_ACTION_EXECUTION = { + opening: "stream", + message: "hybrid", + read_only: "read_only", + answer_choice: "immediate", + stop_and_review: "immediate", +} as const satisfies Record; + +export async function finalizeSuccessfulTurnExit(input: { + accounting: RectificationRpcClient; + userId: string; + caseId: string; + action: RectificationRouteAction; +}): Promise { + if (input.action === "read_only") { + // Read-only requests must never mutate the interview or create a focus. + return; + } + let focusCreated = false; + try { + const next = await persistNextInterviewIfIdle(input); + focusCreated = next.persisted; + } catch (error) { + console.warn( + `[rectification-v9] persist next interview after turn failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, + ); + } + try { + const repaired = await ensureNonTerminalTurnExit(input); + focusCreated ||= repaired.persisted; + } catch (error) { + console.warn( + `[rectification-v9] nonterminal turn exit repair failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, + ); + throw error; + } + if (!focusCreated) return; + try { + const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + const question = projectCurrentQuestion(dossier.conversationSummary.activeFocus); + if (question?.focus_id && question.prompt) { + await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, { + requestId: question.focus_id, + userMessage: null, + assistantMessage: question.prompt, + }); + } + } catch (error) { + console.warn( + `[rectification-v9] persist server question turn failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, + ); + } +} + +export async function awaitTurnExitBeforeResponse( + response: T, + finalize: () => Promise, +): Promise { + await finalize(); + return response; +} diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index cb50ce4c..da9c9007 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -709,12 +709,15 @@ test("answering a discriminator persists the next dated card so GET still has a assert.equal(schema.semantic_key, EDUCATION_2014_PROBE.semantic_key); assert.match(schema.choice?.prompt ?? "", /2014/); assert.doesNotMatch(schema.choice?.prompt ?? "", /2015/); + assert.equal(schema.choice?.prompt, "2014 年前后,有没有升学、转学或换学习环境?"); assert.equal(applied.nextInterviewPersisted, true); assert.equal(applied.nextChoiceReady, true); assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false); - assert.equal(applied.narration, "接下来请点选下面这一问。"); + const persistedPrompt = schema.choice?.prompt; + assert.ok(persistedPrompt); + assert.equal(applied.narration, persistedPrompt); const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn"); - assert.equal(turn?.args.p_assistant_message, "接下来请点选下面这一问。"); + assert.equal(turn?.args.p_assistant_message, persistedPrompt); const refreshed = parseV9CaseDossier(twoProbeDossier()); assert.ok(refreshed); @@ -1104,7 +1107,7 @@ test("structured choice narration never persists a fake loading state", () => { test("the public agent route treats structured choice as a non-model command", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const start = route.indexOf("if (isStructuredChoice)"); - const end = route.indexOf("const selectedModel", start); + const end = route.indexOf("const resolvedModel", start); assert.ok(start >= 0 && end > start); const block = route.slice(start, end); assert.match(block, /applyRectificationChoice\(accounting/); @@ -1202,6 +1205,42 @@ test("choice identity SQL keys stale_question to inactive focus, not question_id assert.match(route, /选择题请求缺少 actionId、focusId 或 expectedRevision/); }); +test("choice followup narration uses the server-owned prompt", () => { + const prompt = "2014 年前后,有没有升学、转学或换学习环境?"; + const followup: MethodFollowup = { + method_id: "d5_education", + intent: "distinguish_candidates", + ask_theme: "education_style", + domain: "education", + kind_hint: null, + user_prompt_hint: "server-owned choice", + must_not_label: false, + choice_frame: { + question_id: "education.2014", + method_id: "d5_education", + period: "2014 年前后", + prompt, + varga: null, + why: "用于区分候选时间", + option_a_hint: "明确发生且时间吻合", + option_b_hint: "发生过但程度较弱", + neither_label: "明确没有发生", + unsure_label: "这段记不清楚", + option_a_answer_class: "yes", + option_b_answer_class: "weak_yes", + option_c_answer_class: "no", + option_d_answer_class: "unsure", + choice_mode: "A/B/C/D", + stop_label: "先这样,先看当前范围", + stop_message: "先这样", + scoring: true, + }, + source: "event_probe", + }; + + assert.equal(spokenFollowupForUser(followup), prompt); +}); + test("degraded spoken collect strips discriminator identity", () => { const followup: MethodFollowup = { method_id: "dasha_events", diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index 50749547..b91ace84 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -639,7 +639,7 @@ test("occupation collect denial declines the focus and advances coverage to hora test("message and opening turns persist the next followup so current_question is not null", async () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn")); - assert.match(afterRun, /if \(action === "message" \|\| action === "opening"\)/); + assert.doesNotMatch(afterRun, /if \(action === "message" \|\| action === "opening"\)/); assert.match(afterRun, /persistNextInterviewIfIdle/); assert.match(afterRun, /ensureNonTerminalTurnExit/); assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle")); @@ -900,11 +900,13 @@ test("nonterminal turn exit deterministically restores a spoken question", async caseId: string; }) => Promise<{ hostNarration: string | null; persisted: boolean }>); assert.equal(typeof ensureExit, "function"); + let activeFocus: Record | null = null; const accounting = fakeAccounting({ ...receiptHandlers, - get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()), + get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier(), activeFocus), set_agentic_rectification_conversation_focus: (_fn, args) => { - return { focus: createdFocusFromArgs(args), idempotent: false }; + activeFocus = createdFocusFromArgs(args); + return { focus: activeFocus, idempotent: false }; }, }); const repaired = await ensureExit!({ diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 191ad7af..94dc7d58 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -8,11 +8,23 @@ import { publicDecisionFields, } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts"; -import { inspectDiscriminatorProbes, selectDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { + inspectDiscriminatorProbes, + selectDiscriminatorProbe, + type CandidateDiscriminatorProbe, +} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { contrastPacketFromDossier, decideFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { + applyChoiceWithoutEvidence, + nakshatraBoundaryProbe, + stampChoiceSchemaWithProbe, + withNakshatraBoundaryProbe, +} from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; import { buildMethodFollowupPlan, conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { awaitTurnExitBeforeResponse, finalizeSuccessfulTurnExit } from "../src/lib/rectification-agentic/v9/turn-exit.ts"; import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; -import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { projectCurrentQuestion, projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { evidenceLedgerFingerprint, parseV9CaseDossier, @@ -20,9 +32,14 @@ import { } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { safeCaseProjection } from "../src/mastra/rectification-v9-tools.ts"; import { + CASE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, candidateSnapshotFixture, computeFixture, dossierFixture, + fakeAccounting, } from "./rectification-v9-test-support.ts"; const SEPARATED = [ @@ -210,6 +227,391 @@ function readSource(relative: string) { return readFileSync(new URL(relative, import.meta.url), "utf8"); } +const ADVANCING_RECTIFICATION_ACTIONS = [ + "opening", + "message", + "answer_choice", + "stop_and_review", +] as const; + +function routeExitContract(route: string, turnExit: string) { + const immediateStart = route.indexOf("if (immediateResponse)"); + const immediateEnd = route.indexOf("const requestTime", immediateStart); + const immediateExit = immediateStart >= 0 && immediateEnd > immediateStart + ? route.slice(immediateStart, immediateEnd) + : ""; + const awaitIndex = immediateExit.indexOf("await awaitTurnExitBeforeResponse"); + const returnIndex = immediateExit.indexOf("return response", awaitIndex); + const immediateAwaited = awaitIndex >= 0 + && returnIndex > awaitIndex + && /finalizeSuccessfulTurnExit\s*\(/.test(immediateExit.slice(awaitIndex, returnIndex)); + const readOnlyExplicitlyExcluded = /input\.action\s*===\s*["']read_only["']/.test(turnExit); + + const resultStart = route.indexOf("if (!result.ok)"); + const doneIndex = route.indexOf('send({ type: "done"', resultStart); + const streamedSuccess = resultStart >= 0 && doneIndex > resultStart + ? route.slice(resultStart, doneIndex) + : ""; + const streamingAwaited = /await\s+finalizeSuccessfulTurnExit\s*\(/.test(streamedSuccess); + const executionBody = /RECTIFICATION_ACTION_EXECUTION\s*=\s*\{([\s\S]*?)\}\s*as const/.exec(turnExit)?.[1] ?? ""; + const executionActions = [...executionBody.matchAll(/^\s*([a-z_]+):/gm)].map((match) => match[1]); + + return { executionActions, immediateAwaited, readOnlyExplicitlyExcluded, streamingAwaited }; +} + +test("nonterminal invariant 1: every advancing action exits through an awaited common gate", () => { + const route = readSource("../src/app/api/rectification/agent/route.ts"); + const turnExit = readSource("../src/lib/rectification-agentic/v9/turn-exit.ts"); + const declared = /action:\s*z\.enum\(\[([^\]]+)\]\)/.exec(route)?.[1] + ?.match(/["']([^"']+)["']/g) + ?.map((value) => value.slice(1, -1)) ?? []; + assert.deepEqual( + declared.filter((action) => action !== "read_only"), + [...ADVANCING_RECTIFICATION_ACTIONS], + "new advancing actions must enter this invariant instead of silently bypassing the exit gate", + ); + + const contract = routeExitContract(route, turnExit); + const failures: string[] = []; + if (contract.executionActions.join(",") !== declared.join(",")) { + failures.push("schema actions and execution declarations differ"); + } + for (const action of ADVANCING_RECTIFICATION_ACTIONS) { + const immediate = action === "message" || action === "answer_choice" || action === "stop_and_review"; + const streamed = action === "opening" || action === "message"; + if (immediate && !contract.immediateAwaited) { + failures.push(`${action}: HTTP 200 response can return without awaiting the common exit gate`); + } + if (streamed && !contract.streamingAwaited) { + failures.push(`${action}: stream can emit done before awaiting the common exit gate`); + } + } + if (!contract.readOnlyExplicitlyExcluded) { + failures.push("read_only: common gate exclusion is not explicit at the shared exit"); + } + assert.deepEqual(failures, []); +}); + +test("nonterminal invariant 2: exhausted probes take the next available server-owned exit", () => { + const nakshatraProbe: CandidateDiscriminatorProbe = { + probeId: "probe:nakshatra-boundary:incident", + candidateSetVersion: "incident", + question: "哪一组日常节奏更像你?", + informationGain: 0.01, + semanticKey: "nakshatra-boundary:incident", + candidateSplitHash: "nakshatra-boundary:incident:early|late", + expectedOutcomes: [ + { outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:07"] }, + { outcomeId: "weak_yes", supportsCandidateIds: ["05:07"], conflictsCandidateIds: ["05:00"] }, + { outcomeId: "no", supportsCandidateIds: [], conflictsCandidateIds: [] }, + { outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] }, + ], + sourceFeatures: [], + domain: "appearance", + year: null, + choiceKind: "varga_style", + styleOptions: [ + { label: "A 组:直接、外放", answerClass: "yes" }, + { label: "B 组:克制、内敛", answerClass: "weak_yes" }, + { label: "两组都不太像", answerClass: "no" }, + { label: "一时说不好", answerClass: "unsure" }, + ], + }; + const cases = [ + { + name: "holdout", + holdoutValidation: "not_started" as const, + datedMethodCollectOpen: false, + nakshatraBoundaryProbe: null, + nextAction: "ask_holdout_validation", + }, + { + name: "dated collect", + holdoutValidation: "unavailable" as const, + datedMethodCollectOpen: true, + nakshatraBoundaryProbe: null, + nextAction: "ask_fact_collection", + }, + { + name: "nakshatra boundary", + holdoutValidation: "unavailable" as const, + datedMethodCollectOpen: false, + nakshatraBoundaryProbe: nakshatraProbe, + nextAction: "ask_candidate_discriminator", + }, + { + name: "explicit range exit", + holdoutValidation: "unavailable" as const, + datedMethodCollectOpen: false, + nakshatraBoundaryProbe: null, + nextAction: "offer_provisional_range", + }, + ]; + + for (const fixture of cases) { + const decision = decideRectification({ + methodCoverageAll: true, + trainingGateOpen: true, + snapshotCurrent: true, + candidateScores: [ + { time: "05:00", score: 34 }, + { time: "05:06", score: 33 }, + { time: "05:07", score: 33 }, + ], + discriminatorProbe: null, + holdoutValidation: fixture.holdoutValidation, + datedMethodCollectOpen: fixture.datedMethodCollectOpen, + nakshatraBoundaryProbe: fixture.nakshatraBoundaryProbe, + userStopped: false, + datedEventCount: 7, + datedDomainCount: 3, + engineCeiling: ENGINE_OPEN, + }); + assert.equal(decision.separation.sufficient, false, fixture.name); + assert.equal(decision.canAdopt, false, fixture.name); + assert.equal(decision.nextAction, fixture.nextAction, fixture.name); + } +}); + +test("nakshatra boundary is a consumable four-answer probe and is not asked twice", () => { + const base = buildInferenceState({ + range_start: "04:51", + range_end: "05:15", + candidates: [ + { id: "04:51", time: "04:51", relative_support: 34 }, + { id: "05:03", time: "05:03", relative_support: 33 }, + { id: "05:15", time: "05:15", relative_support: 32 }, + ], + events: Array.from({ length: 7 }, (_, index) => ({ + id: `incident-event-${index + 1}`, + domain: ["career", "relationship", "education"][index % 3]!, + year: 2016 + index, + precision: "year" as const, + })), + probes: [], + }); + const boundary = { + near_boundary: true, + user_meaning: "平时做决定时,哪一组节奏更像你?", + options: [ + { key: "A" as const, time_bias: "earlier" as const, traits: ["直接", "行动快"] }, + { key: "B" as const, time_bias: "later" as const, traits: ["克制", "先观察"] }, + ], + }; + const probe = nakshatraBoundaryProbe(base, boundary); + assert.ok(probe); + assert.deepEqual(probe.style_options?.map((item) => item.answer_class), ["yes", "weak_yes", "no", "unsure"]); + assert.match(probe.style_options?.[0]?.label ?? "", /直接.*行动快/); + assert.match(probe.style_options?.[1]?.label ?? "", /克制.*先观察/); + + const withProbe = withNakshatraBoundaryProbe(base, boundary); + assert.ok(withProbe); + const schema = stampChoiceSchemaWithProbe({ + choice: { + options: [ + { key: "A", answer_class: "yes" }, + { key: "B", answer_class: "weak_yes" }, + { key: "C", answer_class: "no" }, + { key: "D", answer_class: "unsure" }, + ], + }, + }, withProbe, "nakshatra-boundary:incident", { + probe_id: probe.id, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + }); + const applied = applyChoiceWithoutEvidence(withProbe, { + choiceKey: "A", + schema, + questionId: "nakshatra-boundary:incident", + domain: "appearance", + classifiedFrom: "choice", + }); + assert.equal(applied.applied, true); + assert.equal(applied.reason, "applied"); + assert.equal(applied.state.answered_probes.at(-1)?.probe_id, probe.id); + assert.equal(applied.state.answered_probes.at(-1)?.answer_class, "yes"); + + const deduplicated = withNakshatraBoundaryProbe(applied.state, boundary); + assert.ok(deduplicated); + assert.equal(deduplicated.probes.some((item) => item.source === "nakshatra_boundary"), false); +}); + +test("nonterminal invariant 3: answer_choice response waits until incident fallback focus is persisted", async () => { + const evidence = Array.from({ length: 7 }, (_, index) => ({ + id: `44444444-4444-4444-8444-${String(index + 10).padStart(12, "0")}`, + source_turn_id: "33333333-3333-4333-8333-333333333333", + subject: "self", + event_kind: "dated_event", + domain: ["career", "relationship", "education"][index % 3]!, + occurred_from: `${2016 + index}-01-01`, + occurred_to: null, + date_precision: "year", + summary: `incident evidence ${index + 1}`, + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-01T00:00:00.000Z", + })); + const answeredProbes = Array.from({ length: 6 }, (_, index) => ({ + id: `incident-answered-${index + 1}`, + semantic_key: `incident.answered.${index + 1}`, + candidate_split_hash: `incident-answered-split-${index + 1}`, + domain: ["career", "relationship", "education"][index % 3]!, + year: 2016 + index, + question: `incident answered question ${index + 1}`, + candidate_ids: ["04:51", "05:03", "05:15"], + expected_outcomes: [ + { answer_class: "yes" as const, supports: ["04:51"], conflicts: ["05:15"] }, + { answer_class: "no" as const, supports: ["05:15"], conflicts: ["04:51"] }, + { answer_class: "unsure" as const, supports: [], conflicts: [] }, + ], + information_gain: 0.5, + source: "dasha_boundary", + })); + const droppedProbes = Array.from({ length: 5 }, (_, index) => ({ + id: `incident-dropped-${index + 1}`, + semantic_key: `varga.d${index + 2}.incident`, + candidate_split_hash: `incident-dropped-split-${index + 1}`, + domain: "career", + year: 0, + question: `yearless varga contrast ${index + 1}`, + candidate_ids: ["04:51", "05:03", "05:15"], + expected_outcomes: [ + { answer_class: "yes" as const, supports: ["04:51"], conflicts: ["05:15"] }, + { answer_class: "no" as const, supports: ["05:15"], conflicts: ["04:51"] }, + ], + information_gain: 1 + index / 10, + source: "varga_contrast", + })); + const incidentInference = buildInferenceState({ + range_start: "04:51", + range_end: "05:15", + candidates: [ + { id: "04:51", time: "04:51", relative_support: 34 }, + { id: "05:03", time: "05:03", relative_support: 33 }, + { id: "05:15", time: "05:15", relative_support: 32 }, + ], + events: evidence.map((item) => ({ + id: item.id, + domain: item.domain, + year: Number(item.occurred_from.slice(0, 4)), + precision: "year" as const, + })), + probes: [...answeredProbes, ...droppedProbes], + answered_probes: answeredProbes.map((probe) => ({ + probe_id: probe.id, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + answer_class: "unsure" as const, + classified_from: "choice" as const, + })), + }); + const raw = dossierFixture({ + evidence, + evidenceCount: evidence.length, + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + decisionReceipt: { + inference_state: incidentInference, + diagnostic_quality: { passed: false, margin_percent: 4.476 }, + date_sensitivity_retention_rate: 0.2857, + oos_blind_prompts: [ + { domain: "family", user_meaning: "家里有没有结婚、添丁或住院这类记得住时间的事?", used_for_scoring: false }, + { domain: "health_pressure", user_meaning: "有没有记得住时间的健康压力事件?", used_for_scoring: false }, + ], + nakshatra_boundary: { + near_boundary: true, + user_meaning: "平时做决定时,哪一组节奏更像你?", + options: [ + { key: "A", time_bias: "earlier", traits: ["直接", "行动快"] }, + { key: "B", time_bias: "later", traits: ["克制", "先观察"] }, + ], + }, + }, + candidates: [ + { candidate_id: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:51", relative_support: 34, tied_minute_count: 1 }, + { candidate_id: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:03", relative_support: 33, tied_minute_count: 1 }, + { candidate_id: "88888888-8888-4888-8888-888888888883", rank: 3, time: "05:15", relative_support: 32, tied_minute_count: 1 }, + ], + }), + }); + const parsedIncident = parseV9CaseDossier(raw); + assert.ok(parsedIncident); + const decision = decideFromDossier(parsedIncident); + assert.equal(decision.canAdopt, false); + assert.notEqual(decision.nextAction, "offer_provisional_range"); + + let activeFocus: Record | null = null; + let releaseWrite!: () => void; + const writeGate = new Promise((resolve) => { releaseWrite = resolve; }); + let markWriteStarted!: () => void; + const writeStarted = new Promise((resolve) => { markWriteStarted = resolve; }); + const accounting = fakeAccounting({ + get_agentic_rectification_case_dossier: () => ({ + ...raw, + conversation_summary: { + ...raw.conversation_summary, + active_focus: activeFocus, + }, + }), + get_agentic_rectification_case_compute: () => computeFixture(), + set_agentic_rectification_conversation_focus: async (_fn, args) => { + markWriteStarted(); + await writeGate; + activeFocus = { + id: 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-09-01T00:00:00.000Z", + resolved_at: null, + }; + return { focus: activeFocus, idempotent: false }; + }, + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), + }); + + let responseVisible = false; + const responsePromise = awaitTurnExitBeforeResponse( + new Response(null, { status: 200 }), + () => finalizeSuccessfulTurnExit({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + action: "answer_choice", + }), + ); + void responsePromise.then(() => { responseVisible = true; }); + await writeStarted; + await Promise.resolve(); + assert.equal(responseVisible, false, "answer_choice response resolved before focus persistence completed"); + + releaseWrite(); + const response = await responsePromise; + const refreshed = parseV9CaseDossier({ + ...raw, + conversation_summary: { + ...raw.conversation_summary, + active_focus: activeFocus, + }, + }); + assert.ok(refreshed); + const currentQuestion = projectCurrentQuestion(refreshed.conversationSummary.activeFocus); + assert.equal(response.status, 200); + assert.equal(decision.canAdopt, false); + assert.ok(currentQuestion?.prompt); + assert.equal(currentQuestion.prompt, "家里有没有结婚、添丁或住院这类记得住时间的事?不记得具体日子也可以先说有没有。"); + const historyTurn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn"); + assert.equal(historyTurn?.args.p_request_id, FOCUS_ID); + assert.equal(historyTurn?.args.p_user_message, null); + assert.equal(historyTurn?.args.p_assistant_message, currentQuestion.prompt); +}); + test("public decision fields are derived from decideRectification", () => { const fixtures = [ { diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts index 33d053aa..079032d9 100644 --- a/frontend/tests/rectification-spoken-collect.test.ts +++ b/frontend/tests/rectification-spoken-collect.test.ts @@ -186,8 +186,7 @@ test("openQuestionFromPersistedFocus returns collect_spoken without making colle answerChoice.indexOf("async function persistFocusAfterChoice"), ); assert.match(nextInterview, /isRenderableChoiceOpenQuestion/); - assert.match(nextInterview, /接下来请点选下面这一问/); - assert.ok(nextInterview.indexOf("isRenderableChoiceOpenQuestion") < nextInterview.indexOf("接下来请点选下面这一问")); + assert.match(nextInterview, /hostNarration: open\.prompt/); const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); assert.match(route, /isRenderableChoiceOpenQuestion/); @@ -325,8 +324,10 @@ function collectFocus() { test("agent route keeps question ownership in the server Case projection", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); + const turnExit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8"); const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn")); - assert.match(afterRun, /persistNextInterviewIfIdle/); + assert.match(afterRun, /await finalizeSuccessfulTurnExit/); + assert.match(turnExit, /persistNextInterviewIfIdle/); assert.doesNotMatch(afterRun, /persistCollectSpokenAssistantIfNew|persistEmptyCollectSpokenAssistant/); assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/); const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");