diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 9d88a5ed..ce10d82f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6381,6 +6381,22 @@ - 复发自:无 - 修复版本:待发布 +## BUG-421 | 工具投影与 turn_decision 各判一次,单候选被写成并列区间 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`safeCaseProjection`、`latestResultToolProjection`、`evaluateCandidateSeparation`、GET/工具 `session_outcome` +- 用户现象:同一 Case 的 turn_decision 与 full_diagnostics 可能给出不同的 `session_outcome` / `selection_allowed`。只剩一个候选时仍被说成基本并列。 +- 触发条件:read-case 同时被问 turn_decision 与 full_diagnostics;或候选集收敛到 1 个分钟后继续出牌/写报告。 +- 根因:`safeCaseProjection` 自己跑 `decideConversationalSession` 并可能第二次 `buildMethodFollowupPlan`,`latestResultToolProjection` 可在没有决策时把会话猜成 `collect_evidence`。单候选把 lead 伪造为 8,状态仍是 `not_separated`,报告按并列区间写。 +- 修复:`safeCaseProjection` 开头 `decideFromDossier`,方法计划只编一次。`latestResultToolProjection` 必收 `RectificationDecision` 并用 `overlayPublicDecision`。单候选状态为 `sole_candidate`,不再伪造 lead。`canConfirmExactMinute` 仍只由确认门打开。不改 Skill。 +- 验证:`rectification-decision-authority` 锁定 `projectTurnDecision` 与 `safeCaseProjection` 的 `session_outcome` / `selection_allowed` 相同,工具源不再 `decideConversationalSession`。`rectification-decide-next-action` 锁定单候选是 `sole_candidate`、可收口为代表时间、报告不含「基本并列」、确认门仍关。 +- 防复发:不得在投影层再猜 `sessionOutcome ?? "collect_evidence"`。不得为单候选伪造 `MIN_SEPARATION_LEAD`。不得把单候选写成并列区间。 +- 相关记录:BUG-410、#41 +- 复发自:无 +- 修复版本:待发布 + ## BUG-410 | 训练已齐仍因家人/职业方法层停在采集,Agent 只确认后截断 - 状态:resolved diff --git a/frontend/src/lib/rectification-agentic/core/candidate-separation.ts b/frontend/src/lib/rectification-agentic/core/candidate-separation.ts index 577beee7..5f7a9a15 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-separation.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-separation.ts @@ -1,11 +1,12 @@ /** * Candidate separation is not event-fit and not method coverage. * 34/33/33 is a tie. A 1-point engine lead is not a winner. + * One remaining candidate is a sole candidate, not a parallel range. */ export const MIN_SEPARATION_LEAD = 8; -export type SeparationStatus = "not_separated" | "weak_lead" | "separated"; +export type SeparationStatus = "not_separated" | "weak_lead" | "separated" | "sole_candidate"; export type CandidateScoreRow = Readonly<{ id?: string; @@ -35,14 +36,36 @@ export function evaluateCandidateSeparation( const top = ranked[0] ?? null; const runnerUp = ranked[1] ?? null; const total = ranked.reduce((sum, item) => sum + Math.max(item.score, 0), 0); - const lead = top && runnerUp ? top.score - runnerUp.score : (top ? MIN_SEPARATION_LEAD : 0); - const topShare = top && total > 0 ? Math.max(top.score, 0) / total : 0; - const status: SeparationStatus = !top || ranked.length < 2 || lead < MIN_SEPARATION_LEAD + if (!top) { + return { + sufficient: false, + status: "not_separated", + lead: 0, + topShare: 0, + representativeTime: null, + credibleRange: [], + ranked, + }; + } + if (!runnerUp) { + return { + sufficient: true, + status: "sole_candidate", + lead: 0, + topShare: 1, + representativeTime: top.time, + credibleRange: [top.time], + ranked, + }; + } + const lead = top.score - runnerUp.score; + const topShare = total > 0 ? Math.max(top.score, 0) / total : 0; + const status: SeparationStatus = lead < MIN_SEPARATION_LEAD ? "not_separated" : lead >= 20 ? "separated" : "weak_lead"; - const peak = top?.score ?? 0; + const peak = top.score; const credibleRange = ranked .filter((item) => peak - item.score < MIN_SEPARATION_LEAD) .map((item) => item.time); @@ -51,8 +74,8 @@ export function evaluateCandidateSeparation( status, lead, topShare, - representativeTime: top?.time ?? null, - credibleRange: credibleRange.length > 0 ? credibleRange : (top ? [top.time] : []), + representativeTime: top.time, + credibleRange: credibleRange.length > 0 ? credibleRange : [top.time], ranked, }; } diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 44173858..97e23673 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -141,6 +141,19 @@ export function decideRectification(input: DecideRectificationInput): Rectificat } return completeWithRange(separation, holdout, range, "exhausted"); } + if (holdout === "unavailable") { + if (userStopped) { + return completeWithRange(separation, holdout, range, "user_stopped"); + } + return finish("adopt_representative", { + input, + separation, + holdout, + range, + probe: null, + canConfirmExactMinute: false, + }); + } if (userStopped && holdout !== "passed") { return completeWithRange(separation, holdout, range, "user_stopped"); } @@ -273,7 +286,7 @@ function completeWithRange( } function finish( - sessionOutcome: "adopt_representative" | "awaiting_confirmation" | "validated_range" | "exact_minute_confirmed", + fallbackOutcome: "adopt_representative" | "awaiting_confirmation" | "validated_range" | "exact_minute_confirmed", input: { input: DecideRectificationInput; separation: CandidateSeparation; @@ -294,7 +307,7 @@ function finish( ? (input.input.accepted ? "exact_minute_confirmed" : "awaiting_confirmation") : input.holdout === "passed" ? "validated_range" - : sessionOutcome; + : fallbackOutcome; return { phase: "completed", nextAction: "ready_to_adopt", diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index bf2937f6..02204c6f 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -64,7 +64,6 @@ import { datedDomainsFromEvidence, isStructuredDiscriminator, mentionedVargaKeysFromLedgerEvidence, - selectDiscriminatorProbe, vargaLayerCovered, vargaLayerFromSemanticKey, type CandidateContrastPacket, @@ -1116,7 +1115,7 @@ export function buildMethodFollowupPlan(input: { coverage("horary", horaryStatus), ]; - const sessionOutcome = input.sessionOutcome ?? "collect_evidence"; + const sessionOutcome = input.sessionOutcome; const candidatesSeparated = input.candidatesSeparated === true; const contrastProbes = candidatesSeparated ? [] : [...(input.contrastPacket?.probes ?? [])]; // Legacy known-event quality cards were never backed by an inference probe. @@ -1221,7 +1220,7 @@ export function buildMethodFollowupPlan(input: { methods, next_followup: keepNext, deferred_followup: null, - session_outcome: sessionOutcome, + session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, @@ -1257,7 +1256,7 @@ export function buildMethodFollowupPlan(input: { methods, next_followup: holdoutNext, deferred_followup: null, - session_outcome: sessionOutcome, + session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, @@ -1285,7 +1284,7 @@ export function buildMethodFollowupPlan(input: { methods, next_followup: next, deferred_followup: null, - session_outcome: sessionOutcome, + session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, @@ -1714,7 +1713,7 @@ export function buildMethodFollowupPlan(input: { methods, next_followup: deferAdoption ? null : next, deferred_followup: deferAdoption ? next : null, - session_outcome: sessionOutcome, + session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, @@ -1732,24 +1731,8 @@ export function projectRectificationChoiceCard( latestAssistantText?: string | null; }, ): RectificationChoiceCard | null { - let plan = buildMethodFollowupPlan(input); - const sessionOutcome = conversationalSessionOutcome({ - selectionAllowed: input.selectionAllowed === true, - proposeAllowed: input.proposeAllowed === true, - confirmationAllowed: input.confirmationAllowed === true, - nextFollowup: plan.next_followup, - methods: plan.methods, - userStopped: input.userStopped, - candidateScores: input.candidateScores, - discriminatorProbe: selectDiscriminatorProbe(input.contrastPacket ?? null, { - mentionedKeys: mentionedVargaKeysFromLedgerEvidence(input.evidence), - }) ?? undefined, - holdoutValidation: input.holdoutValidation, - evidence: input.evidence, - }); - if (sessionOutcome !== (input.sessionOutcome ?? "collect_evidence")) { - plan = buildMethodFollowupPlan({ ...input, sessionOutcome }); - } + const sessionOutcome = input.sessionOutcome; + const plan = buildMethodFollowupPlan(input); if ( !input.accepted && (sessionOutcome === "adopt_representative" diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 844d405e..fb0b2dfc 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -38,6 +38,7 @@ import { scorableEvidence, RectificationToolServiceError, type V9CaseDossier, + type V9ComputeProjection, } from "@/lib/rectification-agentic/v9/tool-service"; import { evidenceDomainSchema, @@ -63,10 +64,6 @@ import { parseRectificationHouseTable } from "@/lib/rectification-candidate-resu import { buildMethodFollowupPlan, buildNextUserAction, - conversationalSessionOutcome, - decideConversationalSession, - type MethodCoverage, - type MethodFollowup, } from "@/lib/rectification-agentic/v9/method-followup"; import { refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet"; import { @@ -89,8 +86,12 @@ import { import { projectTurnDecision } from "@/lib/rectification-agentic/v9/turn-decision"; import { contrastPacketFromLatestResult, + decideFromDossier, + overlayPublicDecision, rectificationFollowupCatalog, } from "@/lib/rectification-agentic/v9/decision-from-dossier"; +import type { RectificationDecision } from "@/lib/rectification-agentic/core/rectification-decision"; +import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectification-decision"; import { QUESTION_CONTRACT_VERSION } from "@/lib/rectification-agentic/v9/probe-question-contract"; import { posteriorMap, @@ -100,11 +101,8 @@ import { buildCandidateContrastPacket, conflictProbesFromContrast, datedDomainsFromEvidence, - mentionedVargaKeysFromLedgerEvidence, - selectDiscriminatorProbe, volunteeredDomainsFromEvidence, } from "@/lib/rectification-agentic/core/candidate-contrast-packet"; -import { evaluateCandidateSeparation } from "@/lib/rectification-agentic/core/candidate-separation"; import { offerSessionKinds } from "@/lib/rectification-agentic/core/decide-next-action"; import { candidateSnapshotSource, @@ -112,7 +110,6 @@ import { storedSnapshotIsCurrent, SNAPSHOT_STALE_COPY, } from "@/lib/rectification-agentic/core/snapshot-source"; -import type { HoldoutValidationStatus } from "@/lib/rectification-agentic/core/decide-next-action"; import { buildSkillVerificationReport } from "@/lib/rectification-agentic/v9/skill-verification-report"; import { internalObservationsFromWindowScan, @@ -170,22 +167,6 @@ function safeBirthContext(compute: Awaited> }; } -function candidateScoresFromLatest(latest: NonNullable | null | undefined) { - return authoritativeCandidateProjection(latest).scores; -} - -function holdoutStatusFromLatest(latest: NonNullable | null | undefined): HoldoutValidationStatus { - const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null); - if (!inference) return "unavailable"; - const hasHoldout = inference.events.some((item) => item.usage === "holdout"); - if (!hasHoldout) return "unavailable"; - if (inference.holdout_passed === true) return "passed"; - if (inference.holdout_passed === false || inference.result_status === "validation_failed") { - return "failed"; - } - return "not_started"; -} - function contrastPacketFromLatest( latest: NonNullable | null | undefined, evidence: DossierForTools["evidence"] = [], @@ -223,104 +204,53 @@ function storedSnapshotSource(latest: NonNullable[0], - evidence: readonly Readonly<{ - status?: string | null; - domain?: string | null; - eventKind?: string | null; - summary?: string | null; - }>[], -) { - return selectDiscriminatorProbe(packet, { - mentionedKeys: mentionedVargaKeysFromLedgerEvidence(evidence), - }); +function sessionOutcomeKind(value: unknown): string { + if (typeof value === "string") return value; + if (value && typeof value === "object" && "kind" in value) { + const kind = (value as { kind?: unknown }).kind; + if (typeof kind === "string") return kind; + } + return ""; } -function safeCaseProjection( - dossier: ReturnType, - compute: Awaited>, +export function safeCaseProjection( + dossier: V9CaseDossier, + compute: V9ComputeProjection, ): Record { - const caseRow = dossier.case; - const latest = dossier.latestResult; + const parsed = parseDossierForTools(dossier); + const caseRow = parsed.case; + const latest = parsed.latestResult; + const decision = decideFromDossier(dossier, { + currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), + }); const windowScan = windowScanFromDecisionReceipt(latest?.decisionReceipt ?? null); const observations = internalObservationsFromWindowScan(windowScan); - const catalog = rectificationFollowupCatalog(latest, dossier.evidence); + const catalog = rectificationFollowupCatalog(latest, parsed.evidence); const contrastPacket = catalog.contrastPacket; const accepted = Boolean(caseRow.acceptedTime); - const candidateScores = candidateScoresFromLatest(latest); - const holdoutValidation = holdoutStatusFromLatest(latest); - const separation = evaluateCandidateSeparation(candidateScores); - const currentSnapshot = snapshotSourceFromDossier(dossier, compute); + const currentSnapshot = snapshotSourceFromDossier(parsed, compute); const storedSnapshot = latest ? storedSnapshotSource(latest) : null; const snapshotCurrent = storedSnapshotIsCurrent(storedSnapshot, currentSnapshot); - const collectingPlan = buildMethodFollowupPlan({ - evidence: dossier.evidence, - activeFocus: dossier.conversationSummary.activeFocus, - declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + const methodFollowupPlan = buildMethodFollowupPlan({ + evidence: parsed.evidence, + activeFocus: parsed.conversationSummary.activeFocus, + declinedTopics: parsed.conversationSummary.declinedSkippedTopics, observations, - sessionOutcome: "collect_evidence", + sessionOutcome: decision.sessionOutcome, ...catalog, birthDate: String(compute.baselineBirthSnapshot.birth_date ?? "") || null, accepted, - candidatesSeparated: separation.sufficient, - holdoutValidation, + candidatesSeparated: decision.separation.sufficient, + holdoutValidation: decision.holdoutValidation, }); - const userStopped = dossier.case.status === "paused"; - const confirmationGate = buildConfirmationGate({ - engineConfirmationAllowed: latest?.confirmationAllowed === true, - candidates: latest?.candidates ?? [], - decisionReceipt: latest?.decisionReceipt ?? null, - }); - const decision = decideConversationalSession({ - selectionAllowed: false, - proposeAllowed: false, - confirmationAllowed: confirmationGate.confirmation_allowed, - nextFollowup: collectingPlan.next_followup, - methods: collectingPlan.methods, - userStopped, - candidateScores, - discriminatorProbe: selectLiveDiscriminator(contrastPacket, dossier.evidence), - holdoutValidation, - snapshotCurrent, - evidence: dossier.evidence, - accepted, - }); - const sessionOutcome = decision.sessionOutcome; - const latestProjection = latest - ? latestResultToolProjection(latest, { - proposeAllowed: decision.proposeAllowed, - nextFollowup: collectingPlan.next_followup, - methods: collectingPlan.methods, - userStopped, - candidateScores, - holdoutValidation, - discriminatorProbe: selectLiveDiscriminator(contrastPacket, dossier.evidence), - snapshotCurrent, - evidence: dossier.evidence, - }) - : null; - const methodFollowupPlan = sessionOutcome === "collect_evidence" - ? { ...collectingPlan, session_outcome: sessionOutcome } - : buildMethodFollowupPlan({ - evidence: dossier.evidence, - activeFocus: dossier.conversationSummary.activeFocus, - declinedTopics: dossier.conversationSummary.declinedSkippedTopics, - observations, - sessionOutcome, - ...catalog, - birthDate: String(compute.baselineBirthSnapshot.birth_date ?? "") || null, - accepted, - candidatesSeparated: separation.sufficient, - holdoutValidation, - }); + const latestProjection = latest ? latestResultToolProjection(latest, decision) : null; const birthContext = safeBirthContext(compute); const nextUserAction = buildNextUserAction({ - scorableCount: dossier.scorable.length, - evidenceCount: dossier.evidence.length, + scorableCount: parsed.scorable.length, + evidenceCount: parsed.evidence.length, hasLatestResult: Boolean(latestProjection), selectionAllowed: decision.selectionAllowed, - sessionOutcome, + sessionOutcome: decision.sessionOutcome, nextFollowup: methodFollowupPlan.next_followup, workingTime: caseRow.acceptedTime ?? (typeof birthContext.active_birth_time === "string" ? birthContext.active_birth_time : null) @@ -331,8 +261,7 @@ function safeCaseProjection( const latestResult = latestProjection ? { ...latestProjection, - session_outcome: sessionOutcomeView(sessionOutcome), - candidate_separation: separation, + candidate_separation: decision.separation, candidate_contrast_packet: contrastPacket, candidate_snapshot: { is_current: snapshotCurrent, @@ -355,15 +284,15 @@ function safeCaseProjection( evidence_count: caseRow.evidenceCount, turn_count: caseRow.turnCount, evidence_summary: { - confirmed: dossier.confirmedEvidence.length, - pending: dossier.pendingEvidence.length, - by_domain: dossier.domainCounts, - by_kind: dossier.kindCounts, - scorable: dossier.scorable.length, + confirmed: parsed.confirmedEvidence.length, + pending: parsed.pendingEvidence.length, + by_domain: parsed.domainCounts, + by_kind: parsed.kindCounts, + scorable: parsed.scorable.length, }, - evidence_context: safeEvidenceContext(dossier), - conversation_context: safeConversationContext(dossier), - conversation_summary: safeConversationSummary(dossier), + evidence_context: safeEvidenceContext(parsed), + conversation_context: safeConversationContext(parsed), + conversation_summary: safeConversationSummary(parsed), birth_context: birthContext, latest_result: latestResult, method_followup_plan: methodFollowupPlan, @@ -441,18 +370,7 @@ function safeEvidenceContext(dossier: DossierForTools) { export function latestResultToolProjection( latest: NonNullable, - session?: { - proposeAllowed?: boolean; - nextFollowup?: MethodFollowup | null; - methods?: readonly MethodCoverage[]; - userStopped?: boolean; - candidateScores?: readonly Readonly<{ time: string; score: number }>[]; - holdoutValidation?: HoldoutValidationStatus; - discriminatorProbe?: ReturnType; - snapshotCurrent?: boolean; - evidence?: DossierForTools["evidence"]; - accepted?: boolean; - }, + decision: RectificationDecision, ): Record { const width = indistinguishableWidthMinutes(latest.candidates); const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt ?? null); @@ -461,37 +379,14 @@ export function latestResultToolProjection( candidates: latest.candidates, decisionReceipt: latest.decisionReceipt ?? null, }); - const candidateScores = session?.candidateScores ?? candidateScoresFromLatest(latest); - const decision = session - ? decideConversationalSession({ - selectionAllowed: false, - proposeAllowed: false, - confirmationAllowed: confirmationGate.confirmation_allowed, - nextFollowup: session.nextFollowup ?? null, - methods: session.methods, - userStopped: session.userStopped, - candidateScores, - discriminatorProbe: session.discriminatorProbe, - holdoutValidation: session.holdoutValidation, - snapshotCurrent: session.snapshotCurrent, - evidence: session.evidence, - accepted: session.accepted, - }) - : null; - const sessionOutcome = decision?.sessionOutcome ?? "collect_evidence"; const houseTable = parseRectificationHouseTable(latest.decisionReceipt?.house_table); const refinement = refinementFromDecisionReceipt(latest.decisionReceipt ?? null); - const separation = evaluateCandidateSeparation(candidateScores); - const contrastPacket = contrastPacketFromLatest(latest, session?.evidence); + const contrastPacket = contrastPacketFromLatest(latest); const candidateProjection = authoritativeCandidateProjection(latest); - return { + const base = { result_id: latest.resultId, candidates: candidateProjection.fromInference ? candidateProjection.candidates : latest.candidates, - selection_allowed: candidateProjection.consistent && decision ? decision.selectionAllowed : false, - propose_allowed: decision ? decision.proposeAllowed : false, confirmation_allowed: confirmationGate.confirmation_allowed, - completion_status: decision?.completionStatus ?? null, - validated: decision?.validated ?? false, representative_time: candidateProjection.fromInference ? candidateProjection.representativeTime : latest.representativeTime, @@ -501,8 +396,7 @@ export function latestResultToolProjection( indistinguishable_width_minutes: width, window_scan: windowScan, confirmation_gate: confirmationGate, - session_outcome: sessionOutcomeView(sessionOutcome), - candidate_separation: separation, + candidate_separation: decision.separation, candidate_contrast_packet: contrastPacket, event_dasha_ledger: refinement.event_dasha_ledger, event_fit_rate: refinement.event_fit_rate, @@ -536,7 +430,7 @@ export function latestResultToolProjection( calculation_result_id?: string; }> : [], - separation, + separation: decision.separation, engineResultId: latest.resultId, }), ...(houseTable ? { house_table: houseTable } : {}), @@ -548,6 +442,16 @@ export function latestResultToolProjection( : {}), inference_state: compactInferenceProjection(previousInferenceFromReceipt(latest.decisionReceipt ?? null)), }; + const overlaid = overlayPublicDecision(latest, decision); + return { + ...base, + ...publicDecisionFields(decision), + candidates: overlaid.candidates, + representative_time: overlaid.representativeTime ?? overlaid.representative_time ?? base.representative_time, + selection_allowed: overlaid.selectionAllowed, + confirmation_allowed: confirmationGate.confirmation_allowed, + session_outcome_view: sessionOutcomeView(decision.sessionOutcome), + }; } function agentVisibleLatestProjection( @@ -601,27 +505,32 @@ function agentVisibleLatestProjection( }; } -function collectingFollowupForParsed( +function followupPlanForParsed( parsed: DossierForTools, latest: NonNullable, + decision: RectificationDecision, birthDate?: string | null, ) { const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt ?? null); const observations = internalObservationsFromWindowScan(windowScan); const catalog = rectificationFollowupCatalog(latest, parsed.evidence); - const separation = evaluateCandidateSeparation(candidateScoresFromLatest(latest)); - return buildMethodFollowupPlan({ - evidence: parsed.evidence, - activeFocus: parsed.conversationSummary.activeFocus, - declinedTopics: parsed.conversationSummary.declinedSkippedTopics, - observations, - sessionOutcome: "collect_evidence", - ...catalog, - birthDate, - accepted: Boolean(parsed.case.acceptedTime), - candidatesSeparated: separation.sufficient, - holdoutValidation: holdoutStatusFromLatest(latest), - }); + return { + plan: buildMethodFollowupPlan({ + evidence: parsed.evidence, + activeFocus: parsed.conversationSummary.activeFocus, + declinedTopics: parsed.conversationSummary.declinedSkippedTopics, + observations, + sessionOutcome: decision.sessionOutcome, + ...catalog, + birthDate: birthDate ?? null, + accepted: Boolean(parsed.case.acceptedTime), + candidatesSeparated: decision.separation.sufficient, + holdoutValidation: decision.holdoutValidation, + }), + contrastPacket: catalog.contrastPacket, + sessionOutcome: decision.sessionOutcome, + decision, + }; } function sessionAwareFollowupForParsed( @@ -629,55 +538,16 @@ function sessionAwareFollowupForParsed( latest: NonNullable, options?: { birthDate?: string | null; snapshotCurrent?: boolean }, ) { - const collectingPlan = collectingFollowupForParsed(parsed, latest, options?.birthDate); - const catalog = rectificationFollowupCatalog(latest, parsed.evidence); - const contrastPacket = catalog.contrastPacket; - const candidateScores = candidateScoresFromLatest(latest); - const holdoutValidation = holdoutStatusFromLatest(latest); - const sessionOutcome = conversationalSessionOutcome({ - selectionAllowed: false, - proposeAllowed: false, - confirmationAllowed: buildConfirmationGate({ - engineConfirmationAllowed: latest.confirmationAllowed, - candidates: latest.candidates, - decisionReceipt: latest.decisionReceipt ?? null, - }).confirmation_allowed, - nextFollowup: collectingPlan.next_followup, - methods: collectingPlan.methods, - userStopped: parsed.case.status === "paused", - candidateScores, - discriminatorProbe: selectLiveDiscriminator(contrastPacket, parsed.evidence), - holdoutValidation, - snapshotCurrent: options?.snapshotCurrent, + const decision = decideFromDossier({ evidence: parsed.evidence, - accepted: Boolean(parsed.case.acceptedTime), + conversationSummary: parsed.conversationSummary, + latestResult: latest, + case: parsed.case, + turns: parsed.turns, + }, { + currentEvidenceFingerprint: evidenceLedgerFingerprint(parsed.evidence), }); - if (sessionOutcome === "collect_evidence") { - return { - plan: { ...collectingPlan, session_outcome: sessionOutcome }, - contrastPacket, - sessionOutcome, - }; - } - const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt ?? null); - const observations = internalObservationsFromWindowScan(windowScan); - const separation = evaluateCandidateSeparation(candidateScores); - return { - plan: buildMethodFollowupPlan({ - evidence: parsed.evidence, - activeFocus: parsed.conversationSummary.activeFocus, - declinedTopics: parsed.conversationSummary.declinedSkippedTopics, - observations, - sessionOutcome, - ...catalog, - birthDate: options?.birthDate ?? null, - accepted: Boolean(parsed.case.acceptedTime), - candidatesSeparated: separation.sufficient, - holdoutValidation, - }), - contrastPacket, - sessionOutcome, - }; + return followupPlanForParsed(parsed, latest, decision, options?.birthDate); } function safeConversationSummary(dossier: DossierForTools) { @@ -826,7 +696,7 @@ export function createRectificationV9ReadOnlyTools(ctx: RectificationV9Context) const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); if ((input.projection ?? "turn_decision") === "full_diagnostics") { const compute = await loadV9CaseCompute(accounting, userId, input.caseId); - return safeCaseProjection(parseDossierForTools(dossier), compute); + return safeCaseProjection(dossier, compute); } return projectTurnDecision(dossier); }, @@ -1057,7 +927,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { } catch { // Ranking stays fail-open when compute is unavailable. } - const { plan: collectingPlan, contrastPacket } = sessionAwareFollowupForParsed(parsed, latest, { birthDate }); + const { plan: collectingPlan, contrastPacket, decision } = sessionAwareFollowupForParsed(parsed, latest, { birthDate }); const persistedFocus = await persistServerOwnedFocus({ accounting, userId, @@ -1077,6 +947,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { : { ...collectingPlan, next_followup: null }, persistedFocus, contrastPacket, + decision, }; }; @@ -1173,7 +1044,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const projectionKind = input.projection ?? "turn_decision"; const projection = projectionKind === "full_diagnostics" ? safeCaseProjection( - parseDossierForTools(refreshed), + refreshed, await loadV9CaseCompute(accounting, userId, input.caseId), ) : projectTurnDecision(refreshed); @@ -1184,7 +1055,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const projectionKind = input.projection ?? "turn_decision"; const projection = projectionKind === "full_diagnostics" ? safeCaseProjection( - parseDossierForTools(dossier), + dossier, await loadV9CaseCompute(accounting, userId, input.caseId), ) : projectTurnDecision(dossier); @@ -1768,18 +1639,8 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { algorithmVersion: scored.persisted.algorithmVersion, decisionReceipt: scored.decisionReceipt, }; - const { collectingPlan, persistedFocus, contrastPacket } = await persistPlanFocus(scored.parsed, latest); - const latestProjection = latestResultToolProjection(latest, { - nextFollowup: collectingPlan.next_followup, - methods: collectingPlan.methods, - userStopped: scored.parsed.case.status === "paused", - candidateScores: candidateScoresFromLatest(latest), - holdoutValidation: holdoutStatusFromLatest(latest), - discriminatorProbe: selectLiveDiscriminator(contrastPacket, scored.parsed.evidence), - snapshotCurrent: true, - evidence: scored.parsed.evidence, - accepted: Boolean(scored.parsed.case.acceptedTime), - }); + const { collectingPlan, persistedFocus, decision } = await persistPlanFocus(scored.parsed, latest); + const latestProjection = latestResultToolProjection(latest, decision); const projection = { ...agentVisibleLatestProjection(latestProjection, { openQuestion: openQuestionFromPersistedFocus(persistedFocus), @@ -1879,27 +1740,15 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { throw new RectificationToolServiceError("no_candidate_result"); } const latest = parsed.latestResult; - const { plan: collectingPlan, contrastPacket } = sessionAwareFollowupForParsed(parsed, latest); - const userStopped = parsed.case.status === "paused"; - const candidateScores = candidateScoresFromLatest(latest); + const { decision } = sessionAwareFollowupForParsed(parsed, latest); const currentSnapshot = snapshotSourceFromDossier(parsed, null); const storedSnapshot = storedSnapshotSource(latest); const scoreableCurrent = storedSnapshotIsCurrent(storedSnapshot, currentSnapshot); if (!scoreableCurrent) { throw new RectificationToolServiceError("offer_not_allowed"); } - const projection = latestResultToolProjection(latest, { - nextFollowup: collectingPlan.next_followup, - methods: collectingPlan.methods, - userStopped, - candidateScores, - holdoutValidation: holdoutStatusFromLatest(latest), - discriminatorProbe: selectLiveDiscriminator(contrastPacket, dossier.evidence), - snapshotCurrent: true, - evidence: parsed.evidence, - accepted: Boolean(parsed.case.acceptedTime), - }); - const sessionKind = (projection.session_outcome as { kind?: string }).kind ?? ""; + const projection = latestResultToolProjection(latest, decision); + const sessionKind = sessionOutcomeKind(projection.session_outcome); if (!offerSessionKinds().includes(sessionKind)) { throw new RectificationToolServiceError("offer_not_allowed"); } diff --git a/frontend/tests/rectification-confirmation-gate.test.ts b/frontend/tests/rectification-confirmation-gate.test.ts index d566e70f..f9ef0e0c 100644 --- a/frontend/tests/rectification-confirmation-gate.test.ts +++ b/frontend/tests/rectification-confirmation-gate.test.ts @@ -11,6 +11,7 @@ import { } from "../src/lib/rectification-agentic/v9/confirmation-gate.ts"; import { RECTIFICATION_POLICY } from "../src/lib/rectification-policy.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { createRectificationV9Tools, latestResultToolProjection, @@ -62,6 +63,14 @@ const PLATEAU = [ { candidateId: THIRD_CANDIDATE_ID, time: "04:47", rank: 3, relativeSupport: 25, tiedMinuteCount: 25 }, ]; +function collectDecision(candidates: readonly { time: string; relativeSupport: number }[]) { + return decideRectification({ + methodCoverageAll: false, + trainingGateOpen: false, + candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport })), + }); +} + function blocker( gate: ReturnType, id: string, @@ -125,7 +134,7 @@ test("a 25-minute plateau stays confirmation-blocked as an indistinguishable ran selectedTime: null, selectionKind: null, algorithmVersion: "rectification-v5", - }); + }, collectDecision(PLATEAU)); const gate = projection.confirmation_gate as ReturnType; assert.ok(Number(projection.indistinguishable_width_minutes) >= 25); assert.equal(projection.confirmation_allowed, false); @@ -136,7 +145,7 @@ test("a 25-minute plateau stays confirmation-blocked as an indistinguishable ran RECTIFICATION_POLICY.maxConfirmationWidthMinutes, ); assert.match(blocker(gate, "adjacent_minutes_indistinguishable").user_meaning, /不可分区间|代表性候选/); - assert.equal((projection.session_outcome as { kind: string }).kind, "collect_evidence"); + assert.equal(projection.session_outcome, "collect_evidence"); assert.equal(sessionOutcomeFromGate({ selectionAllowed: true, confirmationAllowed: false, @@ -183,7 +192,7 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as decisionReceipt: { gates: { exact_confirmation: { external_validation_status: "not_evaluated" } }, }, - }); + }, collectDecision(UNIQUE_MINUTE)); const serialized = JSON.stringify(projection); assert.equal(projection.confirmation_allowed, false); assert.equal( diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index c2977cdc..25f7fb40 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -22,6 +22,7 @@ import { import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { buildSkillVerificationReport } from "../src/lib/rectification-agentic/v9/skill-verification-report.ts"; + const TIED = [ { id: "c0", time: "05:00", score: 34 }, { id: "c1", time: "05:01", score: 33 }, @@ -499,6 +500,31 @@ test("stale snapshot keeps discrimination instead of returning to collection", ( }).type, "ask_fact_collection"); }); +test("a single candidate is sole_candidate, not a parallel range", () => { + const separation = evaluateCandidateSeparation([{ time: "05:00", score: 80 }]); + assert.equal(separation.status, "sole_candidate"); + assert.equal(separation.sufficient, true); + assert.equal(separation.lead, 0); + assert.deepEqual(separation.credibleRange, ["05:00"]); + const decision = decideRectification({ + methodCoverageAll: true, + trainingGateOpen: true, + candidateScores: [{ time: "05:00", score: 80 }], + holdoutValidation: "unavailable", + }); + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.equal(decision.canConfirmExactMinute, false); + assert.doesNotMatch( + buildSkillVerificationReport({ + representativeTime: "05:00", + widthMinutes: 0, + candidates: [{ time: "05:00", rank: 1, relativeSupport: 80 }], + separation, + }), + /基本并列/, + ); +}); + test("34/33/33 is a tie, not a recommended winner", () => { const separation = evaluateCandidateSeparation(TIED); assert.equal(separation.status, "not_separated"); diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 7d9dda76..2eecd3fc 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -10,6 +10,14 @@ import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-n import { selectDiscriminatorProbe } 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 { buildMethodFollowupPlan, conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { parseV9CaseDossier, parseV9ComputeProjection } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { safeCaseProjection } from "../src/mastra/rectification-v9-tools.ts"; +import { + candidateSnapshotFixture, + computeFixture, + dossierFixture, +} from "./rectification-v9-test-support.ts"; const SEPARATED = [ { time: "04:48", score: 58 }, @@ -643,6 +651,28 @@ test("public candidate cards follow the inference ranking and hide an inconsiste } }); +test("turn decision and safe case projection share session_outcome and selection_allowed", () => { + const dossier = parseV9CaseDossier(dossierFixture({ + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + representativeTime: "05:00", + }), + })); + const compute = parseV9ComputeProjection(computeFixture()); + assert.ok(dossier); + assert.ok(compute); + const turn = projectTurnDecision(dossier); + const safe = safeCaseProjection(dossier, compute); + assert.equal( + (turn.next_action as { session_outcome: unknown }).session_outcome, + (safe.latest_result as { session_outcome: unknown }).session_outcome, + ); + assert.equal( + (turn.candidate_summary as { selection_allowed: unknown }).selection_allowed, + (safe.latest_result as { selection_allowed: unknown }).selection_allowed, + ); +}); + test("interview, choice, refresh and next-action all call the same reducer", () => { const interview = readSource("../src/lib/rectification-agentic/v9/interview-state.ts"); const adapter = readSource("../src/lib/rectification-agentic/v9/decision-from-dossier.ts"); @@ -650,13 +680,20 @@ test("interview, choice, refresh and next-action all call the same reducer", () const refresh = readSource("../src/lib/rectification-agentic/v9/turn-decision.ts"); const followup = readSource("../src/lib/rectification-agentic/v9/method-followup.ts"); const tools = readSource("../src/mastra/rectification-v9-tools.ts"); + const separation = readSource("../src/lib/rectification-agentic/core/candidate-separation.ts"); const caseRoute = readSource("../src/app/api/rectification/cases/[caseId]/route.ts"); assert.match(interview, /decideFromDossier\(/); assert.match(refresh, /decideFromDossier\(/); assert.match(choice, /decideAfterInferenceChange\(/); assert.match(adapter, /decideRectification\(/); assert.match(followup, /decideRectification\(/); - assert.match(tools, /decideConversationalSession\(/); + assert.match(tools, /decideFromDossier\(/); + assert.match(tools, /overlayPublicDecision/); + assert.match(separation, /sole_candidate/); + assert.doesNotMatch(separation, /top \? MIN_SEPARATION_LEAD/); + assert.doesNotMatch(tools, /decideConversationalSession\(/); + assert.doesNotMatch(tools, /sessionOutcome \?\? "collect_evidence"/); + assert.doesNotMatch(followup, /input\.sessionOutcome \?\? "collect_evidence"/); assert.match(caseRoute, /overlayPublicDecision/); assert.match(caseRoute, /publicDecisionFields\(decision\)/); assert.doesNotMatch(interview, /sessionOutcomeFromGate/); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 455fed25..d4fd38b2 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -14,6 +14,7 @@ import { } from "../src/lib/rectification-agentic/v9/varga-observations.ts"; import { WINDOW_SCAN_DISPLAY_LAYER_ORDER } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { readVedastroMinuteSensitiveStatus } from "../src/lib/rectification-agentic/v9/confirmation-gate.ts"; import { authoritativeCandidateProjection } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; import { createRectificationV9Tools, latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts"; @@ -49,6 +50,14 @@ const FAMILY_ID = "44444444-4444-4444-8444-444444444443"; const CAREER_ID = "44444444-4444-4444-8444-444444444444"; const UNIQUE_MINUTE_COPY = /±5 分钟确定性/; +function collectDecision(candidates: readonly { time: string; relativeSupport?: number }[]) { + return decideRectification({ + methodCoverageAll: false, + trainingGateOpen: false, + candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport ?? 0 })), + }); +} + const DYNAMIC_STYLE_OPTIONS = [ { label: "明确发生且时间吻合", answer_class: "yes" as const }, { label: "发生过但程度较弱", answer_class: "weak_yes" as const }, @@ -864,7 +873,7 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin confirmation_allowed: boolean; indistinguishable_width_minutes: number; window_scan: { d9_candidates_differ: boolean } | null; - session_outcome: { kind: string }; + session_outcome: string; }; }>; }).execute({ caseId: CASE_ID, projection: "full_diagnostics" }); @@ -881,7 +890,7 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin (projection as { next_user_action?: { on_user_stop?: { id?: string } } }).next_user_action?.on_user_stop?.id, "offer_provisional_range", ); - assert.equal(projection.latest_result.session_outcome.kind, "collect_evidence"); + assert.equal(projection.latest_result.session_outcome, "collect_evidence"); assert.equal(projection.internal_observations.find((item) => item.layer === "d9")?.ask_theme, "relationship_style"); assert.equal(projection.latest_result.confirmation_allowed, false); assert.ok(projection.latest_result.indistinguishable_width_minutes >= 25); @@ -1316,26 +1325,27 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => { "8d7aa2d4bea0414e9a89ef908ccbc8c708c98f79f5b78ae4f7dc229b5f7dbb30", ); assert.equal(deprecated.status, "deprecated"); - const plateau = latestResultToolProjection({ - resultId: RESULT_ID, - candidates: [ + const plateauCandidates = [ { candidateId: CANDIDATE_ID, time: "04:45", rank: 1, relativeSupport: 40, tiedMinuteCount: 25 }, { candidateId: SECOND_CANDIDATE_ID, time: "04:46", rank: 2, relativeSupport: 35, tiedMinuteCount: 25 }, { candidateId: THIRD_CANDIDATE_ID, time: "04:47", rank: 3, relativeSupport: 25, tiedMinuteCount: 25 }, - ], + ]; + const plateau = latestResultToolProjection({ + resultId: RESULT_ID, + candidates: plateauCandidates, selectionAllowed: true, confirmationAllowed: true, representativeTime: "04:45", selectedTime: null, selectionKind: null, algorithmVersion: "rectification-v5", - }); + }, collectDecision(plateauCandidates)); assert.equal(plateau.confirmation_allowed, false); assert.equal(plateau.unique_minute_claim, false); assert.match(String(plateau.skill_verification_report), /Dasha \+ Gochara/); assert.match(String(plateau.skill_verification_report), /candidate_range_not_birth_time_truth/); assert.doesNotMatch(String(plateau.skill_verification_report), UNIQUE_MINUTE_COPY); - assert.equal((plateau.session_outcome as { kind: string }).kind, "collect_evidence"); + assert.equal(plateau.session_outcome, "collect_evidence"); const skill = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url), "utf8"); assert.match(skill, /method_followup_plan/); assert.match(skill, /感情 → 事业 → 家人 → 职业 → 占问/); @@ -1383,14 +1393,14 @@ test("Mastra hides active candidates when the receipt range excludes one of them algorithmVersion: "rectification-v5", decisionReceipt: { inference_state: inferenceState }, }; - const session = { - methods: buildMethodFollowupPlan({ evidence: CLASSIC_COVERAGE }).methods, + const session = decideRectification({ + methodCoverageAll: true, userStopped: true, candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport })), - holdoutValidation: "passed" as const, + holdoutValidation: "passed", snapshotCurrent: true, trainingGateOpen: true, - }; + }); const valid = latestResultToolProjection(latest, session); assert.deepEqual((valid.candidates as typeof candidates).map((item) => item.time), ["05:00", "05:07"]); assert.equal(valid.selection_allowed, true); @@ -2836,9 +2846,9 @@ test("paused case with selection_allowed may offer the escape hatch", async () = accounting: accounting.client as never, }); const projection = await (tools["rectification-offer-candidates"] as unknown as { - execute(input: unknown): Promise<{ session_outcome: { kind: string } }>; + execute(input: unknown): Promise<{ session_outcome: string }>; }).execute({ caseId: CASE_ID }); - assert.equal(projection.session_outcome.kind, "provisional_range_user_stopped"); + assert.equal(projection.session_outcome, "provisional_range_user_stopped"); assert.equal( accounting.calls.some((call) => call.fn === "transition_agentic_rectification_case_status" @@ -2949,9 +2959,9 @@ test("offer-candidates allows a 34/33/33 tie after method coverage when remainin accounting: accounting.client as never, }); const projection = await (tools["rectification-offer-candidates"] as unknown as { - execute(input: unknown): Promise<{ session_outcome: { kind: string } }>; + execute(input: unknown): Promise<{ session_outcome: string }>; }).execute({ caseId: CASE_ID }); - assert.equal(projection.session_outcome.kind, "provisional_range"); + assert.equal(projection.session_outcome, "provisional_range"); assert.equal( accounting.calls.some((call) => call.fn === "transition_agentic_rectification_case_status" diff --git a/frontend/tests/rectification-ingest-p0.test.ts b/frontend/tests/rectification-ingest-p0.test.ts index cb30fbaf..fb0c969e 100644 --- a/frontend/tests/rectification-ingest-p0.test.ts +++ b/frontend/tests/rectification-ingest-p0.test.ts @@ -18,6 +18,7 @@ import { } from "../src/lib/rectification-agentic/v9/candidate-plateau.ts"; import { RECTIFICATION_POLICY } from "../src/lib/rectification-policy.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { createRectificationV9Tools, latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts"; import { CASE_ID, @@ -126,7 +127,11 @@ test("a 25-minute tied plateau projects width and forbids unique-minute confirma selectedTime: null, selectionKind: null, algorithmVersion: "rectification-v5", - }); + }, decideRectification({ + methodCoverageAll: false, + trainingGateOpen: false, + candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport })), + })); assert.equal(projection.indistinguishable_width_minutes, width); assert.equal(projection.confirmation_allowed, false); assert.equal(