From 3f1998bdb6f7a4e023170b1a3b8a67a6133fe824 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 24 Aug 2026 12:19:36 +0800 Subject: [PATCH] fix(web): stop adopting tied rectification candidates after method coverage Coverage complete only unlocks discrimination. A 34/33/33 window plus an occupation note must ask a D9/D10 contrast probe instead of offering a stale winner card. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++ .../cases/[caseId]/candidates/accept/route.ts | 4 + .../core/candidate-contrast-packet.ts | 182 ++++++++++++++ .../core/candidate-separation.ts | 58 +++++ .../core/decide-next-action.ts | 89 +++++++ .../lib/rectification-agentic/core/index.ts | 4 + .../core/snapshot-source.ts | 77 ++++++ .../v9/confirmation-gate.ts | 21 ++ .../v9/method-followup.ts | 184 +++++++++++--- .../v9/skill-verification-report.ts | 46 +++- .../src/lib/rectification-candidate-result.ts | 10 +- frontend/src/mastra/agentic-rectification.ts | 4 +- frontend/src/mastra/rectification-v9-tools.ts | 210 ++++++++++++++-- .../rectification-candidate-result.test.ts | 7 +- .../tests/rectification-choice-card.test.ts | 55 ++++- .../rectification-decide-next-action.test.ts | 230 ++++++++++++++++++ .../tests/rectification-eight-method.test.ts | 217 ++++++++++++++++- frontend/tests/rectification-v9-agent.test.ts | 4 +- .../tests/rectification-v9-test-support.ts | 7 +- 19 files changed, 1345 insertions(+), 80 deletions(-) create mode 100644 frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts create mode 100644 frontend/src/lib/rectification-agentic/core/candidate-separation.ts create mode 100644 frontend/src/lib/rectification-agentic/core/decide-next-action.ts create mode 100644 frontend/src/lib/rectification-agentic/core/snapshot-source.ts create mode 100644 frontend/tests/rectification-decide-next-action.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a89dc08b..1588119b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5531,6 +5531,22 @@ - 复发自:无 - 修复版本:未修复 +## BUG-366 | 方法覆盖完成被当成候选收敛,职业笔记又让候选卡立刻失效 + +- 状态:resolved +- 首次发现:2026-08-24 +- 最近更新:2026-08-24 +- 影响面:`decideNextAction`、`conversationalSessionOutcome`、`isOfferBlockingFollowup`、`method_followup_plan`、`rectification-offer-candidates`、候选 snapshot 指纹、生时纠正系统提示、验证报告 +- 用户现象:用户一句话里同时给出长期职业和带日期创业事件后,状态终于会动,但直接跳到 `adopt_candidate`。Agent 按协议出牌。职业无日期 `occupation_note` 不参与评分却把方法覆盖标成完成;候选仍是 34/33/33。随后候选卡被前端判定失效,提示「出生资料已变化」。系统没有按 D9/D10 差异反问前事。 +- 触发条件:经典方法层已齐或职业笔记刚补齐;引擎 `propose_allowed` 已开;候选相对支持只差 1 分;Agent 在同一轮先写带日期事件再写 `occupation_note` 再 `offer-candidates`。 +- 根因:两层概念被焊在一起。(1) `methodCoverageAll && propose_allowed` 直接变成 `session_outcome=adopt_representative`,覆盖完成被当成收敛;盘面差异只写进最终报告,不进入决策。(2) 无评分 `occupation_note` 推进工作流版本,候选 snapshot 仍按上一版证据读取,失效文案又把所有 409 翻译成出生资料变化。 +- 修复:新增 `decideNextAction`。覆盖完成只进入 `ask_candidate_discriminator`。34/33/33 为 `not_separated`。D9/D10 差异生成 `CandidateContrastPacket`。未通过 holdout 不得 `ready_to_adopt`。候选卡只认出生资料 + 可评分证据 + 推断修订 + 候选集;`occupation_note` 仍覆盖职业层,但不让 snapshot 过期。失效原因拆开。不改已哈希 Skill `10.0.11`。 +- 验证:`frontend/tests/rectification-decide-next-action.test.ts` 锁定覆盖≠收敛、并列必须出区分探针、无评分职业笔记不改 scoreable fingerprint、holdout 未过不得 adopt、报告不得无 `calculationResultId` 写 executed;`frontend/tests/rectification-eight-method.test.ts` 锁定职业笔记覆盖后不再直接 adopt、覆盖后剩余探针仍走区分。 +- 防复发:禁止 `methodCoverageAll` 直接变成 `adopt_representative`。禁止把事件拟合率当成候选区分。禁止无评分证据让候选卡失效。禁止把所有 stale 提示写成「出生资料已变化」。不得改已哈希 Skill `10.0.11`。 +- 相关记录:BUG-361、BUG-362、BUG-363 +- 复发自:BUG-361(职业笔记覆盖和出牌门修了,但覆盖完成被当成可以 adopt) +- 修复版本:待发布 + ## BUG-365 | staging web 镜像 next build 被推断类型检查挡住 - 状态:resolved(本地修复,待提交与发布) diff --git a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts index 6e99ae88..7ed2ab42 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts @@ -26,6 +26,10 @@ function errorResponse(error: unknown) { ["agentic_rectification_candidate_already_selected", 409, "该时间已采用"], ["agentic_rectification_candidate_superseded", 409, "已有更新的候选结果"], ["agentic_rectification_candidate_profile_changed", 409, "出生资料已变化,候选已失效"], + ["birth_profile_changed", 409, "出生资料已变化,候选已失效"], + ["scoreable_evidence_changed", 409, "可评分证据已变化,请重新比较候选"], + ["candidate_set_superseded", 409, "已有更新的候选结果"], + ["inference_revision_changed", 409, "候选后验已更新,请使用当前结果"], ["agentic_rectification_case_terminal", 409, "该校正已结束"], ["agentic_rectification_case_not_found", 404, "校正记录不存在或无权访问"], ] as const; diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts new file mode 100644 index 00000000..67902cad --- /dev/null +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -0,0 +1,182 @@ +/** + * Chart differences that can actually eliminate candidates. + * Window-scan prose in the final report is not a substitute for this packet. + */ + +export type ContrastExpectedOutcome = Readonly<{ + outcomeId: string; + supportsCandidateIds: readonly string[]; + conflictsCandidateIds: readonly string[]; +}>; + +export type ContrastSourceFeature = Readonly<{ + technique: string; + calculationResultId: string | null; +}>; + +export type CandidateDiscriminatorProbe = Readonly<{ + probeId: string; + candidateSetVersion: string; + question: string; + expectedOutcomes: readonly ContrastExpectedOutcome[]; + candidateSplitHash: string; + informationGain: number; + sourceFeatures: readonly ContrastSourceFeature[]; + domain: string | null; + year: number | null; + semanticKey: string; +}>; + +export type VargaDifference = Readonly<{ + layer: string; + signs: readonly string[]; +}>; + +export type CandidateContrastPacket = Readonly<{ + candidateSetVersion: string; + probes: readonly CandidateDiscriminatorProbe[]; + vargaDifferences: readonly VargaDifference[]; +}>; + +export type EngineContrastProbe = Readonly<{ + semantic_key?: string; + candidate_split_hash?: string; + domain?: string; + year?: number; + question?: string; + user_meaning?: string; + information_gain?: number; + expected_outcomes?: readonly Readonly<{ + answer_class?: string; + supports?: readonly string[]; + conflicts?: readonly string[]; + }>[]; + left_time?: string; + right_time?: string; +}>; + +const D10_PREDICTIONS: Readonly> = { + 白羊: "某一阶段身份独立、承担主导职责", + 金牛: "稳定技术或事务执行、在组织内慢慢积累", + 双子: "沟通、写作或频繁切换任务", + 巨蟹: "照顾、家庭或情感劳动更重的职责", + 狮子: "站到台前、带人、或公开担责", + 处女: "稳定技术执行、组织内分析或服务", + 天秤: "跨领域合作、工作环境或合作模式变化", + 天蝎: "研究、转化、或把一件事做深", + 射手: "跨领域、教学或更开阔的工作环境", + 摩羯: "管理职责、长期结构、延迟回报", + 水瓶: "独立技术路线、非传统协作", + 双鱼: "服务、艺术或界限更模糊的工作", +}; + +function signKey(value: string): string { + return value.replace(/座$/, "").trim(); +} + +export function buildCandidateContrastPacket(input: { + candidateSetVersion: string; + calculationResultId?: string | null; + engineProbes?: readonly EngineContrastProbe[]; + vargaDifferences?: readonly VargaDifference[]; + askedKeys?: readonly string[]; +}): CandidateContrastPacket { + const asked = new Set(input.askedKeys ?? []); + const fromEngine = (input.engineProbes ?? []).flatMap((probe) => { + const built = probeFromEngine(probe, input.candidateSetVersion, input.calculationResultId ?? null); + if (!built) return []; + if (asked.has(built.semanticKey) || asked.has(built.candidateSplitHash) || asked.has(built.probeId)) { + return []; + } + return [built]; + }); + const vargaDifferences = input.vargaDifferences ?? []; + const fromVarga = vargaProbe(vargaDifferences, input.candidateSetVersion, input.calculationResultId ?? null, asked); + const probes = [...fromEngine, ...(fromVarga ? [fromVarga] : [])] + .sort((left, right) => right.informationGain - left.informationGain); + return { + candidateSetVersion: input.candidateSetVersion, + probes, + vargaDifferences, + }; +} + +export function selectDiscriminatorProbe( + packet: CandidateContrastPacket | null | undefined, +): CandidateDiscriminatorProbe | null { + const ranked = (packet?.probes ?? []).filter((probe) => probe.expectedOutcomes.length >= 2); + return ranked[0] ?? null; +} + +function probeFromEngine( + probe: EngineContrastProbe, + candidateSetVersion: string, + calculationResultId: string | null, +): CandidateDiscriminatorProbe | null { + const outcomes = (probe.expected_outcomes ?? []).flatMap((row) => { + const outcomeId = typeof row.answer_class === "string" ? row.answer_class : ""; + const supports = row.supports ?? []; + const conflicts = row.conflicts ?? []; + if (!outcomeId) return []; + return [{ outcomeId, supportsCandidateIds: supports, conflictsCandidateIds: conflicts }]; + }); + if (outcomes.length < 2 && probe.left_time && probe.right_time && probe.left_time !== probe.right_time) { + outcomes.push( + { outcomeId: "yes", supportsCandidateIds: [probe.left_time], conflictsCandidateIds: [probe.right_time] }, + { outcomeId: "no", supportsCandidateIds: [probe.right_time], conflictsCandidateIds: [probe.left_time] }, + ); + } + if (outcomes.length < 2) return null; + const semanticKey = probe.semantic_key ?? `${probe.domain ?? "career"}.${probe.year ?? "contrast"}`; + const split = probe.candidate_split_hash ?? semanticKey; + const question = probe.question ?? probe.user_meaning ?? ""; + if (!question.trim()) return null; + return { + probeId: `contrast:${semanticKey}:${split}`, + candidateSetVersion, + question, + expectedOutcomes: outcomes, + candidateSplitHash: split, + informationGain: probe.information_gain ?? 0, + sourceFeatures: [{ technique: probe.domain ?? "event_probe", calculationResultId }], + domain: probe.domain ?? null, + year: probe.year ?? null, + semanticKey, + }; +} + +function vargaProbe( + differences: readonly VargaDifference[], + candidateSetVersion: string, + calculationResultId: string | null, + asked: ReadonlySet, +): CandidateDiscriminatorProbe | null { + const d10 = differences.find((item) => item.layer === "d10" && item.signs.length >= 2); + const d9 = differences.find((item) => item.layer === "d9" && item.signs.length >= 2); + const chosen = d10 ?? d9; + if (!chosen) return null; + const semanticKey = `varga.${chosen.layer}.${chosen.signs.join("|")}`; + if (asked.has(semanticKey)) return null; + const predictions = chosen.signs.map((sign) => D10_PREDICTIONS[signKey(sign)] ?? `${sign} 这一段更常见的前事`); + const outcomes: ContrastExpectedOutcome[] = chosen.signs.map((sign, index) => ({ + outcomeId: `supports_${signKey(sign)}`, + supportsCandidateIds: [sign], + conflictsCandidateIds: chosen.signs.filter((_, other) => other !== index), + })); + const layerLabel = chosen.layer.toUpperCase(); + const question = chosen.layer === "d10" + ? `当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事:更接近${predictions.join(",还是")}?` + : `当前几个候选在关系盘上还分得开。请核对一段还没用进评分的感情前事,用来对照 ${layerLabel} 差异。`; + return { + probeId: `contrast:${semanticKey}`, + candidateSetVersion, + question, + expectedOutcomes: outcomes, + candidateSplitHash: semanticKey, + informationGain: 0.12, + sourceFeatures: [{ technique: layerLabel, calculationResultId }], + domain: chosen.layer === "d10" ? "career" : "relationship", + year: null, + semanticKey, + }; +} diff --git a/frontend/src/lib/rectification-agentic/core/candidate-separation.ts b/frontend/src/lib/rectification-agentic/core/candidate-separation.ts new file mode 100644 index 00000000..577beee7 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/core/candidate-separation.ts @@ -0,0 +1,58 @@ +/** + * 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. + */ + +export const MIN_SEPARATION_LEAD = 8; + +export type SeparationStatus = "not_separated" | "weak_lead" | "separated"; + +export type CandidateScoreRow = Readonly<{ + id?: string; + time: string; + score: number; +}>; + +export type CandidateSeparation = Readonly<{ + sufficient: boolean; + status: SeparationStatus; + lead: number; + topShare: number; + representativeTime: string | null; + credibleRange: readonly string[]; + ranked: readonly CandidateScoreRow[]; +}>; + +export function evaluateCandidateSeparation( + candidates: readonly CandidateScoreRow[], +): CandidateSeparation { + const ranked = [...candidates] + .filter((item) => Number.isFinite(item.score)) + .sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + return left.time.localeCompare(right.time); + }); + 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 + ? "not_separated" + : lead >= 20 + ? "separated" + : "weak_lead"; + const peak = top?.score ?? 0; + const credibleRange = ranked + .filter((item) => peak - item.score < MIN_SEPARATION_LEAD) + .map((item) => item.time); + return { + sufficient: status !== "not_separated", + status, + lead, + topShare, + representativeTime: top?.time ?? null, + credibleRange: credibleRange.length > 0 ? credibleRange : (top ? [top.time] : []), + ranked, + }; +} diff --git a/frontend/src/lib/rectification-agentic/core/decide-next-action.ts b/frontend/src/lib/rectification-agentic/core/decide-next-action.ts new file mode 100644 index 00000000..6a08f296 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/core/decide-next-action.ts @@ -0,0 +1,89 @@ +import { + evaluateCandidateSeparation, + type CandidateScoreRow, + type CandidateSeparation, +} from "./candidate-separation.ts"; +import type { CandidateDiscriminatorProbe } from "./candidate-contrast-packet.ts"; + +export type RectificationNextActionType = + | "ask_fact_collection" + | "ask_candidate_discriminator" + | "ask_holdout_validation" + | "offer_provisional_range" + | "ready_to_adopt"; + +export type HoldoutValidationStatus = "not_started" | "passed" | "failed" | "unavailable"; + +export type DecideNextActionInput = Readonly<{ + methodCoverageAll: boolean; + proposeAllowed: boolean; + confirmationAllowed?: boolean; + userStopped?: boolean; + selectionAllowed?: boolean; + snapshotCurrent?: boolean; + candidateScores: readonly CandidateScoreRow[]; + discriminatorProbe?: CandidateDiscriminatorProbe | null; + holdoutValidation?: HoldoutValidationStatus; +}>; + +export type RectificationNextAction = Readonly<{ + type: RectificationNextActionType; + separation: CandidateSeparation; + probe: CandidateDiscriminatorProbe | null; +}>; + +/** + * Coverage complete only unlocks discrimination. It never grants adoption. + */ +export function decideNextAction(input: DecideNextActionInput): RectificationNextAction { + const separation = evaluateCandidateSeparation(input.candidateScores); + const probe = input.discriminatorProbe ?? null; + const holdout = input.holdoutValidation ?? "unavailable"; + const userStopped = input.userStopped === true; + + if (input.confirmationAllowed === true) { + return { type: "ready_to_adopt", separation, probe: null }; + } + if (userStopped && input.selectionAllowed === true) { + if (!separation.sufficient) { + return { type: "offer_provisional_range", separation, probe: null }; + } + return { type: "ready_to_adopt", separation, probe: null }; + } + if (!input.methodCoverageAll || input.snapshotCurrent === false) { + return { type: "ask_fact_collection", separation, probe: null }; + } + if (!separation.sufficient) { + if (probe) { + return { type: "ask_candidate_discriminator", separation, probe }; + } + return { type: "offer_provisional_range", separation, probe: null }; + } + if (holdout === "not_started") { + return { type: "ask_holdout_validation", separation, probe: null }; + } + if (holdout === "failed") { + if (probe) { + return { type: "ask_candidate_discriminator", separation, probe }; + } + return { type: "offer_provisional_range", separation, probe: null }; + } + if (input.proposeAllowed !== true) { + return { type: "ask_fact_collection", separation, probe: null }; + } + return { type: "ready_to_adopt", separation, probe: null }; +} + +export function sessionKindFromNextAction( + type: RectificationNextActionType, +): "collect_evidence" | "discriminate_candidates" | "validate_holdout" | "provisional_range" | "adopt_representative" { + if (type === "ask_fact_collection") return "collect_evidence"; + if (type === "ask_candidate_discriminator") return "discriminate_candidates"; + if (type === "ask_holdout_validation") return "validate_holdout"; + if (type === "offer_provisional_range") return "provisional_range"; + return "adopt_representative"; +} + +export function offerSessionKinds(): readonly string[] { + return ["adopt_representative", "awaiting_confirmation", "provisional_range"]; +} diff --git a/frontend/src/lib/rectification-agentic/core/index.ts b/frontend/src/lib/rectification-agentic/core/index.ts index a7eba876..c949739f 100644 --- a/frontend/src/lib/rectification-agentic/core/index.ts +++ b/frontend/src/lib/rectification-agentic/core/index.ts @@ -10,3 +10,7 @@ export * from "./build-state.ts"; export * from "./probes-from-engine.ts"; export * from "./decision-fingerprint.ts"; export * from "./compose-receipt.ts"; +export * from "./candidate-separation.ts"; +export * from "./decide-next-action.ts"; +export * from "./candidate-contrast-packet.ts"; +export * from "./snapshot-source.ts"; diff --git a/frontend/src/lib/rectification-agentic/core/snapshot-source.ts b/frontend/src/lib/rectification-agentic/core/snapshot-source.ts new file mode 100644 index 00000000..b818ef1a --- /dev/null +++ b/frontend/src/lib/rectification-agentic/core/snapshot-source.ts @@ -0,0 +1,77 @@ +/** + * Candidate cards are keyed only on scoreable facts + birth profile + inference. + * An occupation_note that does not score must not stale the snapshot. + */ + +export type CandidateSnapshotSource = Readonly<{ + birthProfileFingerprint: string; + scoreableEvidenceFingerprint: string; + inferenceRevision: number; + candidateSetVersion: string; + scoringPolicyVersion: string; +}>; + +export type SnapshotStaleReason = + | "birth_profile_changed" + | "scoreable_evidence_changed" + | "candidate_set_superseded" + | "inference_revision_changed"; + +export const SNAPSHOT_STALE_COPY: Readonly> = { + birth_profile_changed: "出生资料已变化,候选已失效", + scoreable_evidence_changed: "可评分证据已变化,请重新比较候选", + candidate_set_superseded: "已有更新的候选结果", + inference_revision_changed: "候选后验已更新,请使用当前结果", +}; + +export function classifySnapshotStaleReason( + snapshot: CandidateSnapshotSource | null | undefined, + current: CandidateSnapshotSource, +): SnapshotStaleReason | null { + if (!snapshot) return "candidate_set_superseded"; + if ( + snapshot.birthProfileFingerprint + && current.birthProfileFingerprint + && snapshot.birthProfileFingerprint !== current.birthProfileFingerprint + ) { + return "birth_profile_changed"; + } + if (snapshot.scoreableEvidenceFingerprint !== current.scoreableEvidenceFingerprint) { + return "scoreable_evidence_changed"; + } + if (snapshot.candidateSetVersion !== current.candidateSetVersion) { + return "candidate_set_superseded"; + } + if (snapshot.inferenceRevision !== current.inferenceRevision) { + return "inference_revision_changed"; + } + return null; +} + +/** Cards stay current when only non-scoreable notes (occupation_note) change. */ +export function scoreableSnapshotIsCurrent( + snapshot: CandidateSnapshotSource | null | undefined, + current: CandidateSnapshotSource, +): boolean { + if (!snapshot) return false; + return snapshot.scoreableEvidenceFingerprint === current.scoreableEvidenceFingerprint + && snapshot.candidateSetVersion === current.candidateSetVersion + && snapshot.inferenceRevision === current.inferenceRevision; +} + +export function snapshotIsCurrent( + snapshot: CandidateSnapshotSource | null | undefined, + current: CandidateSnapshotSource, +): boolean { + return classifySnapshotStaleReason(snapshot, current) === null; +} + +export function candidateSnapshotSource(input: CandidateSnapshotSource): CandidateSnapshotSource { + return { + birthProfileFingerprint: input.birthProfileFingerprint, + scoreableEvidenceFingerprint: input.scoreableEvidenceFingerprint, + inferenceRevision: input.inferenceRevision, + candidateSetVersion: input.candidateSetVersion, + scoringPolicyVersion: input.scoringPolicyVersion, + }; +} diff --git a/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts b/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts index 1e16c168..40d145e6 100644 --- a/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts +++ b/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts @@ -56,6 +56,9 @@ export type ConfirmationGate = Readonly<{ export type SessionOutcomeKind = | "collect_evidence" + | "discriminate_candidates" + | "validate_holdout" + | "provisional_range" | "adopt_representative" | "awaiting_confirmation"; @@ -77,6 +80,24 @@ export function sessionOutcomeView(kind: SessionOutcomeKind): SessionOutcome { user_meaning: `这次校正的收口是采用代表性时间作当前排盘。${UNIQUE_MINUTE_CLOSED_COPY}。`, }; } + if (kind === "discriminate_candidates") { + return { + kind, + user_meaning: "方法资料已齐,但候选还没拉开。下一步按候选盘面差异反问前事,不要采用时间卡。", + }; + } + if (kind === "validate_holdout") { + return { + kind, + user_meaning: "领先候选还要用尚未计分的前事做独立核对,不能直接采用。", + }; + } + if (kind === "provisional_range") { + return { + kind, + user_meaning: "当前几个候选基本并列。给出的是可信区间的代表点,不是已经分出的赢家。", + }; + } return { kind: "collect_evidence", user_meaning: "还需要能评分的带日期事件,才能给出可采用的代表性时间。", diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 93612bb9..eddf3259 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -27,8 +27,8 @@ * After the first dated event, remaining dasha conflict probes * (year/activation differences) are asked before more method rotation * and they block offering time cards so the window can be filtered. - * Once blocking methods are covered and propose_allowed, offer a - * representative time even if probes or horary remain. + * Once blocking methods are covered, move into candidate discrimination. + * Coverage complete never means adopt. Horary does not block cards. * A/B/C/D choice frames attach only when candidates already diverge * (event probes, precision stage, varga observation, nakshatra, or holdout). */ @@ -40,7 +40,17 @@ import { type RectificationChoiceCard, type RectificationChoiceFrame, } from "./choice-card.ts"; -import { sessionOutcomeFromGate, type SessionOutcomeKind } from "./confirmation-gate.ts"; +import { + decideNextAction, + sessionKindFromNextAction, + type HoldoutValidationStatus, +} from "../core/decide-next-action.ts"; +import { + selectDiscriminatorProbe, + type CandidateContrastPacket, + type CandidateDiscriminatorProbe, +} from "../core/candidate-contrast-packet.ts"; +import type { SessionOutcomeKind } from "./confirmation-gate.ts"; import type { DiscriminatingEventProbe, NakshatraBoundary, @@ -308,6 +318,9 @@ export type NextUserActionId = | "score_now" | "record_stated_events" | "ask_method_followup" + | "ask_candidate_discriminator" + | "ask_holdout_validation" + | "offer_provisional_range" | "explain_current_window" | "verify_adopted_time" | "start_consultation"; @@ -316,7 +329,7 @@ export type NextUserAction = Readonly<{ id: NextUserActionId; user_meaning: string; on_user_stop: { - id: Exclude; + id: Exclude; user_meaning: string; }; }>; @@ -346,19 +359,53 @@ export function latestUserStoppedCollecting( export function isOfferBlockingFollowup( followup: MethodFollowup | null, methods?: readonly MethodCoverage[], + options?: { separated?: boolean }, ): boolean { if (methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered")) { return true; } if (!followup) return false; + const separated = options?.separated === true; if (followup.source === "event_probe") { - if (methods == null) return true; - return (followup.information_gain ?? 0) >= 0.08; + if (separated) return (followup.information_gain ?? 0) >= 0.08; + return true; + } + if ( + followup.source === "varga_observation" + || followup.source === "precision_stage" + || followup.intent === "distinguish_candidates" + ) { + return !separated; } if (followup.source !== "method_coverage") return false; return BLOCKING_COVERAGE_IDS.has(followup.method_id as MethodFollowupId); } +function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDiscriminatorProbe | null { + if (!followup) return null; + const discriminator = followup.source === "event_probe" + || followup.source === "varga_observation" + || followup.source === "precision_stage" + || followup.intent === "distinguish_candidates"; + if (!discriminator) return null; + const split = followup.candidate_split_hash ?? followup.semantic_key ?? followup.method_id; + return { + probeId: split, + candidateSetVersion: split, + question: followup.user_prompt_hint, + expectedOutcomes: [ + { outcomeId: "yes", supportsCandidateIds: ["left"], conflictsCandidateIds: ["right"] }, + { outcomeId: "no", supportsCandidateIds: ["right"], conflictsCandidateIds: ["left"] }, + ], + candidateSplitHash: split, + informationGain: followup.information_gain ?? 0, + sourceFeatures: [{ technique: followup.source, calculationResultId: null }], + domain: followup.domain, + year: followup.probe_year ?? null, + semanticKey: followup.semantic_key ?? followup.method_id, + }; +} + export function conversationalSessionOutcome(input: { selectionAllowed: boolean; proposeAllowed: boolean; @@ -366,14 +413,27 @@ export function conversationalSessionOutcome(input: { nextFollowup: MethodFollowup | null; methods?: readonly MethodCoverage[]; userStopped?: boolean; + candidateScores?: readonly Readonly<{ time: string; score: number }>[]; + discriminatorProbe?: CandidateDiscriminatorProbe | null; + holdoutValidation?: HoldoutValidationStatus; + snapshotCurrent?: boolean; }): SessionOutcomeKind { - return sessionOutcomeFromGate({ - selectionAllowed: input.selectionAllowed, + if (input.confirmationAllowed) return "awaiting_confirmation"; + const coverageOpen = Boolean( + input.methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered"), + ); + const decided = decideNextAction({ + methodCoverageAll: !coverageOpen, proposeAllowed: input.proposeAllowed, - confirmationAllowed: input.confirmationAllowed, - interviewOpen: isOfferBlockingFollowup(input.nextFollowup, input.methods), + confirmationAllowed: false, userStopped: input.userStopped, - }).kind; + selectionAllowed: input.selectionAllowed, + snapshotCurrent: input.snapshotCurrent, + candidateScores: input.candidateScores ?? [], + discriminatorProbe: input.discriminatorProbe ?? discriminatorFromFollowup(input.nextFollowup), + holdoutValidation: input.holdoutValidation, + }); + return sessionKindFromNextAction(decided.type); } export function buildNextUserAction(input: { @@ -407,14 +467,39 @@ export function buildNextUserAction(input: { "adopt_representative", "本轮已有代表性候选时间。说明本会话以代表性时间收口,不确认唯一分钟,请用户采用下方时间卡片;采用后才用该时间看盘。不要只说记下了以后再说。", ); + const provisional = action( + "offer_provisional_range", + "当前几个候选基本并列。说明这是可信区间,代表分钟只是计算用的代表点,不要称某分钟为当前推荐;不要继续假装已经收敛。", + ); if (input.sessionOutcome === "awaiting_confirmation") { return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt }; } - if ( - input.sessionOutcome === "adopt_representative" - ) { + if (input.sessionOutcome === "adopt_representative") { return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt }; } + if (input.sessionOutcome === "provisional_range") { + return { id: provisional.id, user_meaning: provisional.user_meaning, on_user_stop: provisional }; + } + if (input.sessionOutcome === "validate_holdout" && input.nextFollowup) { + return { + id: "ask_holdout_validation", + user_meaning: input.nextFollowup.user_prompt_hint, + on_user_stop: input.selectionAllowed ? provisional : action( + "explain_current_window", + `${working}。独立核对还没做完。用户说没有更多时,说明这是并列区间,不要采用一张赢家卡。`, + ), + }; + } + if (input.sessionOutcome === "discriminate_candidates" && input.nextFollowup) { + return { + id: "ask_candidate_discriminator", + user_meaning: input.nextFollowup.user_prompt_hint, + on_user_stop: input.selectionAllowed ? provisional : action( + "explain_current_window", + `${working}。候选还没拉开。用户说没有更多时,给出并列可信区间,不要宣布某分钟胜出。`, + ), + }; + } const scoreNow = action( "score_now", "已有可评分事件但还没有候选结果。本轮必须比较候选,不要只口头确认事件。", @@ -456,6 +541,9 @@ export function buildMethodFollowupPlan(input: { askedProbeKeys?: readonly string[]; birthDate?: string | null; accepted?: boolean; + candidatesSeparated?: boolean; + contrastPacket?: CandidateContrastPacket | null; + holdoutValidation?: HoldoutValidationStatus; }): MethodFollowupPlan { const makeFollowup = ( item: Omit, @@ -506,6 +594,8 @@ export function buildMethodFollowupPlan(input: { ]; const sessionOutcome = input.sessionOutcome ?? "collect_evidence"; + const candidatesSeparated = input.candidatesSeparated === true; + const contrastProbe = selectDiscriminatorProbe(input.contrastPacket ?? null); const focus = input.activeFocus ?? null; const keepAcceptedFocus = Boolean( focus && (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"), @@ -525,7 +615,7 @@ export function buildMethodFollowupPlan(input: { if ( focus && !staleCollectFocus - && (sessionOutcome !== "adopt_representative" || keepAcceptedFocus) + && (sessionOutcome !== "adopt_representative" && sessionOutcome !== "provisional_range" || keepAcceptedFocus) && (!input.accepted || keepAcceptedFocus) ) { const existingChoice = parseAgentChoiceCopy(focus.expectedAnswerSchema ?? null); @@ -603,7 +693,7 @@ export function buildMethodFollowupPlan(input: { ), source: "method_coverage", }); - } else if (conflictProbe && (!coverageComplete || (conflictProbe.information_gain ?? 0) >= 0.08)) { + } else if (conflictProbe && (!coverageComplete || !candidatesSeparated || (conflictProbe.information_gain ?? 0) >= 0.08)) { next = makeFollowup({ method_id: PROBE_METHOD_ID[conflictProbe.domain], intent: "distinguish_candidates", @@ -676,20 +766,25 @@ export function buildMethodFollowupPlan(input: { ), source: "method_coverage", }); - } else if (horaryStatus === "uncovered") { + } else if (contrastProbe && !candidatesSeparated) { + const domain = contrastProbe.domain === "relationship" ? "relationship" : "career"; next = makeFollowup({ - method_id: "horary", - intent: "collect_method_evidence", - ask_theme: "horary", - domain: "horary", - kind_hint: "horary_query", - user_prompt_hint: collectHint( - "有没有第一次认真问起这件事的时间?", - "占问观察盘", - "有的话可以按那个时间观察;没有也不挡给出时间卡。状态是 observation_only。", + method_id: domain === "relationship" ? "d9_relationship" : "d10_career", + intent: "distinguish_candidates", + ask_theme: domain === "relationship" ? "relationship_style" : "career_style", + domain, + kind_hint: domain === "relationship" ? "relationship_change" : "career_change", + user_prompt_hint: agentHint( + contrastProbe.question, + domain === "relationship" ? "D9" : "D10", + "按候选盘面差异核对前事,不要问两套盘哪个更像。", ), - source: "method_coverage", - }); + source: "event_probe", + information_gain: contrastProbe.informationGain, + semantic_key: contrastProbe.semanticKey, + candidate_split_hash: contrastProbe.candidateSplitHash, + probe_year: contrastProbe.year ?? undefined, + }, true, true); } else if (stage === "lagna_frame") { next = makeFollowup({ method_id: "dasha_events", @@ -870,10 +965,35 @@ export function buildMethodFollowupPlan(input: { ?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。", source: "nakshatra_boundary", }); + } else if (input.holdoutValidation === "not_started" && (input.oosBlindPrompts?.length ?? 0) > 0) { + const prompt = input.oosBlindPrompts![0]!; + next = makeFollowup({ + method_id: "oos_blind", + intent: "out_of_sample_check", + ask_theme: "oos_blind", + domain: prompt.domain, + kind_hint: null, + user_prompt_hint: prompt.user_meaning, + source: "oos_blind", + }, false, true); + } else if (horaryStatus === "uncovered") { + next = makeFollowup({ + method_id: "horary", + intent: "collect_method_evidence", + ask_theme: "horary", + domain: "horary", + kind_hint: "horary_query", + user_prompt_hint: collectHint( + "有没有第一次认真问起这件事的时间?", + "占问观察盘", + "有的话可以按那个时间观察;没有也不挡给出时间卡。状态是 observation_only。", + ), + source: "method_coverage", + }); } } - const deferAdoption = sessionOutcome === "adopt_representative"; + const deferAdoption = sessionOutcome === "adopt_representative" || sessionOutcome === "provisional_range"; return { methods, next_followup: deferAdoption ? null : next, @@ -890,6 +1010,7 @@ export function projectRectificationChoiceCard( selectionAllowed?: boolean; proposeAllowed?: boolean; userStopped?: boolean; + candidateScores?: readonly Readonly<{ time: string; score: number }>[]; }, ): RectificationChoiceCard | null { const plan = buildMethodFollowupPlan(input); @@ -900,10 +1021,13 @@ export function projectRectificationChoiceCard( nextFollowup: plan.next_followup, methods: plan.methods, userStopped: input.userStopped, + candidateScores: input.candidateScores, + discriminatorProbe: selectDiscriminatorProbe(input.contrastPacket ?? null), + holdoutValidation: input.holdoutValidation, }); if ( !input.accepted - && (sessionOutcome === "adopt_representative" || sessionOutcome === "awaiting_confirmation") + && (sessionOutcome === "adopt_representative" || sessionOutcome === "awaiting_confirmation" || sessionOutcome === "provisional_range") ) { return null; } diff --git a/frontend/src/lib/rectification-agentic/v9/skill-verification-report.ts b/frontend/src/lib/rectification-agentic/v9/skill-verification-report.ts index 1777a438..b7cfb00c 100644 --- a/frontend/src/lib/rectification-agentic/v9/skill-verification-report.ts +++ b/frontend/src/lib/rectification-agentic/v9/skill-verification-report.ts @@ -8,6 +8,7 @@ import type { EventDashaLedgerRow } from "./refinement-packet"; import type { WindowScan } from "./varga-observations"; +import type { CandidateSeparation } from "../core/candidate-separation"; const D9_TYPE_TABLE: Readonly> = { 白羊座: { trait: "主动、热情、冲动", spouse: "独立、有活力", marriage: "早期婚姻、激情型" }, @@ -73,7 +74,14 @@ export type SkillVerificationReportInput = Readonly<{ unique_minute_claim: false; }> | null; dashaAgreement?: Readonly<{ status: string; user_meaning: string }> | null; - techniqueAuditTable?: readonly Readonly<{ technique?: string; status?: string; note?: string }>[]; + techniqueAuditTable?: readonly Readonly<{ + technique?: string; + status?: string; + note?: string; + calculation_result_id?: string; + }>[]; + separation?: CandidateSeparation; + engineResultId?: string | null; }>; export function buildSkillVerificationReport(input: SkillVerificationReportInput): string { @@ -84,6 +92,7 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput const ledger = input.eventDashaLedger ?? []; const d9Signs = input.windowScan?.d9_sign_names ?? []; const d10Signs = input.windowScan?.d10_sign_names ?? []; + const tied = input.separation?.status === "not_separated"; const candidateLines = input.candidates.slice(0, 5).map((row) => ( `| ${row.rank} | ${row.time} | ${row.relativeSupport} |` )); @@ -98,12 +107,19 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput : ["当前窗还没有可对照的 D10 上升名,类型表待候选换升后填写。"]; const audit = (input.techniqueAuditTable ?? []).slice(0, 16); const auditRows = audit.length > 0 - ? audit.map((row) => `| ${row.technique ?? "技法"} | ${row.status ?? "blocked"} | ${row.note ?? ""} |`) + ? audit.map((row) => { + const executed = row.status === "executed" && Boolean(row.calculation_result_id); + const status = executed ? "executed" : (row.status === "executed" ? "input_covered" : (row.status ?? "blocked")); + const note = executed + ? (row.note ?? "") + : (row.note || "该方法所需资料已覆盖,没有可追溯的 calculationResultId,不能写成已执行。"); + return `| ${row.technique ?? "技法"} | ${status} | ${note} |`; + }) : [ - "| Vimshottari + 受控行运 | executed | 权重口径 40;事件吻合率不是唯一分钟 |", - "| D9/D10 分盘 | executed | 权重口径 35;类型表是校时方法 |", - "| 宫位 / 六亲六步 | executed | 权重口径 15;后三步方法论层 |", - "| Nakshatra Pada | observation_only | 权重口径 10;不确认唯一分钟 |", + "| Vimshottari + 受控行运 | input_covered | 没有 calculationResultId 时不能写成已执行 |", + "| D9/D10 分盘 | input_covered | 类型表是校时方法;差异要进入区分探针 |", + "| 宫位 / 六亲六步 | input_covered | 后三步方法论层 |", + "| Nakshatra Pada | observation_only | 不确认唯一分钟 |", "| KP / D60 / Gulika | observation_only | 不进提出门或确认门计分 |", "| 占问 | observation_only | AI 暂不支持独立占问;有问起时间则观察 |", ]; @@ -113,11 +129,17 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput "这是当前窗的相对拟合,标签是 `candidate_range_not_birth_time_truth`。采用把代表性时间写入当前排盘;本会话以代表性时间收口,不是已确认唯一出生分钟。", "", "### 筛选", - `- 当前代表分钟:${time}`, + tied + ? `- 当前候选基本并列:${(input.separation?.credibleRange ?? []).join("、") || "尚未拉开"}。${time} 只是后续计算的代表点,不是赢家。` + : `- 当前代表分钟:${time}`, `- 不可分宽度:${width}`, `- 本命上升(该分钟):${lagna}`, - input.windowScan?.d9_candidates_differ ? "- D9 仍会换升,关系类型对照只用来分开候选。" : "- D9 上升在当前窗较稳。", - input.windowScan?.d10_candidates_differ ? "- D10 仍会换升,事业类型对照只用来分开候选。" : "- D10 上升在当前窗较稳。", + input.windowScan?.d9_candidates_differ + ? "- D9 仍会换升。这是候选结构差异,应生成区分探针,不能直接宣布不可分。" + : "- D9 上升在当前窗较稳。", + input.windowScan?.d10_candidates_differ + ? "- D10 仍会换升。这是候选结构差异,应生成区分探针,不能直接宣布不可分。" + : "- D10 上升在当前窗较稳。", "", "### 候选窗(正文复述;卡片仍作采用控件)", "| 排名 | 时间 | 相对支持 |", @@ -126,8 +148,8 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput "", "### 方法1:Dasha + Gochara", fit - ? `${fit.user_meaning} ${fit.label}。` - : "还没有事件吻合率。有已确认事件后,80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟。", + ? `${fit.user_meaning} ${fit.label}。这是事件拟合程度,不是候选区分程度。` + : "还没有事件吻合率。有已确认事件后,80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。", "", "| 事件 | 吻合 | Dasha / Gochara |", "|---|---|---|", @@ -153,7 +175,7 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput "状态:`not_used` / `observation_only`。产品可观察一次问起时间,不挡出牌。", "", "### Technique Audit Table", - "权重口径:Dasha 40 / 分盘 35 / 宫位 15 / Pada 10。KP、D60、Gulika 观察或 blocked。", + "权重口径:Dasha 40 / 分盘 35 / 宫位 15 / Pada 10。KP、D60、Gulika 观察或 blocked。只有带 calculationResultId 的行才能写 executed。", "| 技法 | 状态 | 说明 |", "|---|---|---|", ...auditRows, diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index ae401df0..ddf8141a 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -18,6 +18,7 @@ import { buildConfirmationGate, type ConfirmationGate, } from "./rectification-agentic/v9/confirmation-gate"; +import { MIN_SEPARATION_LEAD } from "./rectification-agentic/core/candidate-separation"; export type RectificationCandidate = Readonly<{ candidateId: string; @@ -258,7 +259,10 @@ export function isRecommendedRectificationCandidate( result: RectificationCandidateResult, candidate: RectificationCandidate, ): boolean { - return !result.selectedTime - && result.selectionAllowed - && result.representativeTime === candidate.time; + if (result.selectedTime || !result.selectionAllowed || result.representativeTime !== candidate.time) { + return false; + } + const ranked = [...result.candidates].sort((left, right) => right.relativeSupport - left.relativeSupport); + const lead = (ranked[0]?.relativeSupport ?? 0) - (ranked[1]?.relativeSupport ?? 0); + return lead >= MIN_SEPARATION_LEAD; } diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index 7af1c185..874d2d5f 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -69,9 +69,9 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑 6. 工具执行过程保持静默。思考过程必须用简体中文,只写在思维链里:可以说你在核对哪类经历,禁止写工具名、错误码、参数、内部 ID、评分或密钥。正文像正常人说话,不写“本轮做了什么”,不描述 Skill、Case、Dossier、工具、内部 Activity、参数、错误或推理过程;完成凭证完全由服务端公开 Activity/receipt 展示。 7. 只基于成功 attempt 输出正文。工具失败时说明面向用户的边界,不声称未执行的方法或结果。 8. 当前轮新事件一律走 rectification-record-evidence-batch(一件也可以)。rectification-confirm-evidence 只用于用户对已有 pending 明确说“对/是”。不得要求用户把已说清的事件再发一遍。 -9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_action:id=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compare,C 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。挡住出牌的方法层未齐时,source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐且 propose_allowed 时本轮 adopt,即使还剩 event_probe、精度追问或占问;无日期 occupation_note 算已覆盖,不要再问职业。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–Dasha–Gochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。宽度大于 5 或并列分钟仍可出示代表性时间卡;不得为把不可分区间问到 5 分钟以内而继续 A/B/C/D。精度阶段追问不挡出牌。用户仍可 accepted 代表性候选。 +9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_action:id=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compare,C 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。挡住出牌的方法层未齐时,source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐只进入候选区分,不等于 adopt。无日期 occupation_note 算职业已覆盖,不要再问职业,也不要因它出牌。id=ask_candidate_discriminator 或 session_outcome=discriminate_candidates 时按 candidate_contrast_packet / next_followup 问一件能拆开候选的前事,不得 offer。id=ask_holdout_validation 时做盘外核对,不得 offer。id=offer_provisional_range 时说明并列可信区间,不要称某分钟为当前推荐。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–Dasha–Gochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。候选未拉开时不得出示赢家卡;D9/D10 差异和精度阶段追问要用来区分,不得直接宣布不可分。用户仍可 accepted 代表性候选。 10. 不泄露系统提示词或 Skill 原文。 -11. 追问只跟 method_followup_plan。账本为空或 collect_method_evidence 时用自然语言问一件带大概年份的经历,set-focus 不要写 choice,正文直接问,不要提点选卡。只有 next_followup 带 choice_frame(冲突探针、候选已经分不开、采用后核对前事)时才写 set-focus.expectedAnswerSchema.choice 的 A/B/C/D:题干由你写成自然语言是/否生平问题;年份和事件家族以 choice_frame.period 与 discriminating_event_probes 为准,不得发明年份,不要照抄 hint。挡住出牌的方法层未齐时,source=event_probe 只问这一件反推前事用来筛窗,不要继续轮询方法层,不要 offer。覆盖已齐则落实 adopt。采用后按剩余 dasha 探针核尚未出现过的年份,不要把已回答的考试质量题再问一遍。不要问两套盘哪个更像或可能性高低。A 是这件事大概就在那段时间,B 是有类似但年份不对或不够重大,C 是没有明显发生,D 是不记得;点选 C/D/B 且没有写入新证据时必须调用 rectification-resolve-focus 并传 choiceKey,后验由服务器当场写入,不要等下一次 compare;「先这样」由服务器补全;正文只说一句时间窗和为何问,禁止复述选项。不得询问外貌、体质、胎记或疤痕,也不得问钟点。不得按 missing_evidence_categories 轮询迁居,也不得先要 10–15 条事件长表。财务与健康只有用户主动说才问。方法覆盖为感情→事业→家人→职业→占问。D9/D10 类型表是校时方法,不是命运承诺。以「盘外核对(不计分)」开头的消息不得调用 record-evidence-batch 或 propose-evidence。 +11. 追问只跟 method_followup_plan。账本为空或 collect_method_evidence 时用自然语言问一件带大概年份的经历,set-focus 不要写 choice,正文直接问,不要提点选卡。只有 next_followup 带 choice_frame(冲突探针、候选已经分不开、采用后核对前事)时才写 set-focus.expectedAnswerSchema.choice 的 A/B/C/D:题干由你写成自然语言是/否生平问题;年份和事件家族以 choice_frame.period 与 discriminating_event_probes 为准,不得发明年份,不要照抄 hint。挡住出牌的方法层未齐时,source=event_probe 只问这一件反推前事用来筛窗,不要继续轮询方法层,不要 offer。覆盖已齐后问区分探针,不要 adopt。采用后按剩余 dasha 探针核尚未出现过的年份,不要把已回答的考试质量题再问一遍。不要问两套盘哪个更像或可能性高低。A 是这件事大概就在那段时间,B 是有类似但年份不对或不够重大,C 是没有明显发生,D 是不记得;点选 C/D/B 且没有写入新证据时必须调用 rectification-resolve-focus 并传 choiceKey,后验由服务器当场写入,不要等下一次 compare;「先这样」由服务器补全;正文只说一句时间窗和为何问,禁止复述选项。不得询问外貌、体质、胎记或疤痕,也不得问钟点。不得按 missing_evidence_categories 轮询迁居,也不得先要 10–15 条事件长表。财务与健康只有用户主动说才问。方法覆盖为感情→事业→家人→职业→占问。D9/D10 类型表是校时方法,不是命运承诺。以「盘外核对(不计分)」开头的消息不得调用 record-evidence-batch 或 propose-evidence。 12. 证据有效变化后由服务器重算候选。不要等用户说“没有更多了”才比较,也不要对同一证据指纹再 compare。分钟扫描只在服务端,结果只是候选或平台,不得宣布确认。 13. 落实 start_consultation:前事核对结束或用户先这样后,请用户用当前采用时间看盘;对不上同时请改选其他候选。解释事件–Dasha 账本、双轨是否一致、换升时刻、精度阶段、D9/D10 类型对照和相对支持时,仍必须说候选范围不是出生时间真值。`; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index f5d62489..1fe813a7 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -52,13 +52,12 @@ import { parseAgentChoiceCopy, } from "@/lib/rectification-agentic/v9/choice-card"; import { indistinguishableWidthMinutes } from "@/lib/rectification-agentic/v9/candidate-plateau"; -import { buildConfirmationGate, sessionOutcomeFromGate, sessionOutcomeView } from "@/lib/rectification-agentic/v9/confirmation-gate"; +import { buildConfirmationGate, sessionOutcomeView } from "@/lib/rectification-agentic/v9/confirmation-gate"; import { parseRectificationHouseTable } from "@/lib/rectification-candidate-result"; import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, - isOfferBlockingFollowup, latestUserStoppedCollecting, type MethodCoverage, type MethodFollowup, @@ -76,6 +75,19 @@ import { posteriorMap, scoreDeltas, } from "@/lib/rectification-agentic/core/decision-fingerprint"; +import { + buildCandidateContrastPacket, + selectDiscriminatorProbe, +} 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, + classifySnapshotStaleReason, + scoreableSnapshotIsCurrent, + 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, @@ -136,6 +148,83 @@ function readProposeAllowed(decisionReceipt: Readonly> | return decisionReceipt?.propose_allowed === true; } +function candidateScoresFromLatest(latest: NonNullable | null | undefined) { + const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null); + if (inference && inference.candidates.length > 0) { + return inference.candidates.map((item) => ({ + id: item.id, + time: item.time, + score: item.posterior_score, + })); + } + return (latest?.candidates ?? []).map((item) => ({ + id: item.candidateId, + time: item.time, + score: item.relativeSupport, + })); +} + +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.result_status === "validation_failed") return "failed"; + if (inference.result_status === "converged") return "passed"; + return "not_started"; +} + +function contrastPacketFromLatest(latest: NonNullable | null | undefined) { + const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null); + const windowScan = windowScanFromDecisionReceipt(latest?.decisionReceipt ?? null); + const refinement = refinementFromDecisionReceipt(latest?.decisionReceipt ?? null); + const vargaDifferences = [ + ...(windowScan?.d9_candidates_differ && windowScan.d9_sign_names.length >= 2 + ? [{ layer: "d9", signs: windowScan.d9_sign_names }] + : []), + ...(windowScan?.d10_candidates_differ && windowScan.d10_sign_names.length >= 2 + ? [{ layer: "d10", signs: windowScan.d10_sign_names }] + : []), + ]; + return buildCandidateContrastPacket({ + candidateSetVersion: inference?.candidate_set_id ?? latest?.resultId ?? "none", + calculationResultId: latest?.resultId ?? null, + engineProbes: refinement.discriminating_event_probes, + vargaDifferences, + askedKeys: askedProbeKeysFromReceipt(latest?.decisionReceipt), + }); +} + +function snapshotSourceFromDossier( + dossier: Pick, + compute: Awaited> | null, +) { + const latest = dossier.latestResult; + const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null); + const range = dossier.case.candidateRange ?? { start_time: "", end_time: "" }; + const birth = compute + ? candidateRangeFingerprint(range, compute.baselineProfileFingerprint) + : (latest?.candidateRangeFingerprint ?? ""); + return candidateSnapshotSource({ + birthProfileFingerprint: birth, + scoreableEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), + inferenceRevision: inference?.revision ?? 0, + candidateSetVersion: inference?.candidate_set_id ?? latest?.resultId ?? "", + scoringPolicyVersion: String(latest?.policyVersion ?? latest?.algorithmVersion ?? ""), + }); +} + +function storedSnapshotSource(latest: NonNullable) { + const inference = previousInferenceFromReceipt(latest.decisionReceipt ?? null); + return candidateSnapshotSource({ + birthProfileFingerprint: latest.candidateRangeFingerprint ?? "", + scoreableEvidenceFingerprint: latest.evidenceLedgerFingerprint ?? "", + inferenceRevision: inference?.revision ?? 0, + candidateSetVersion: inference?.candidate_set_id ?? latest.resultId, + scoringPolicyVersion: String(latest.policyVersion ?? latest.algorithmVersion ?? ""), + }); +} + function safeCaseProjection( dossier: ReturnType, compute: Awaited>, @@ -146,6 +235,10 @@ function safeCaseProjection( const observations = internalObservationsFromWindowScan(windowScan); const refinement = refinementFromDecisionReceipt(latest?.decisionReceipt ?? null); const accepted = Boolean(caseRow.acceptedTime); + const contrastPacket = contrastPacketFromLatest(latest); + const candidateScores = candidateScoresFromLatest(latest); + const holdoutValidation = holdoutStatusFromLatest(latest); + const separation = evaluateCandidateSeparation(candidateScores); const collectingPlan = buildMethodFollowupPlan({ evidence: dossier.evidence, activeFocus: dossier.conversationSummary.activeFocus, @@ -159,16 +252,28 @@ function safeCaseProjection( askedProbeKeys: askedProbeKeysFromReceipt(latest?.decisionReceipt), birthDate: String(compute.baselineBirthSnapshot.birth_date ?? "") || null, accepted, + candidatesSeparated: separation.sufficient, + contrastPacket, + holdoutValidation, }); const selectionAllowed = latest?.selectionAllowed === true; const proposeAllowed = readProposeAllowed(latest?.decisionReceipt); const userStopped = latestUserStoppedCollecting(dossier.turns); + const currentSnapshot = snapshotSourceFromDossier(dossier, compute); + const storedSnapshot = latest ? storedSnapshotSource(latest) : null; + const snapshotCurrent = !storedSnapshot + || !storedSnapshot.scoreableEvidenceFingerprint + || scoreableSnapshotIsCurrent(storedSnapshot, currentSnapshot); const latestProjection = latest ? latestResultToolProjection(latest, { proposeAllowed, nextFollowup: collectingPlan.next_followup, methods: collectingPlan.methods, userStopped, + candidateScores, + holdoutValidation, + discriminatorProbe: selectDiscriminatorProbe(contrastPacket), + snapshotCurrent, }) : null; const sessionOutcome = conversationalSessionOutcome({ @@ -178,6 +283,10 @@ function safeCaseProjection( nextFollowup: collectingPlan.next_followup, methods: collectingPlan.methods, userStopped, + candidateScores, + discriminatorProbe: selectDiscriminatorProbe(contrastPacket), + holdoutValidation, + snapshotCurrent, }); const methodFollowupPlan = sessionOutcome === "collect_evidence" ? { ...collectingPlan, session_outcome: sessionOutcome } @@ -194,6 +303,9 @@ function safeCaseProjection( askedProbeKeys: askedProbeKeysFromReceipt(latest?.decisionReceipt), birthDate: String(compute.baselineBirthSnapshot.birth_date ?? "") || null, accepted, + candidatesSeparated: separation.sufficient, + contrastPacket, + holdoutValidation, }); const birthContext = safeBirthContext(compute); const nextUserAction = buildNextUserAction({ @@ -202,16 +314,25 @@ function safeCaseProjection( hasLatestResult: Boolean(latestProjection), selectionAllowed, sessionOutcome, - nextFollowup: collectingPlan.next_followup, + nextFollowup: methodFollowupPlan.next_followup ?? collectingPlan.next_followup, workingTime: caseRow.acceptedTime ?? (typeof birthContext.active_birth_time === "string" ? birthContext.active_birth_time : null) ?? (typeof birthContext.reported_birth_time === "string" ? birthContext.reported_birth_time : null), accepted, }); + const staleReason = latest ? classifySnapshotStaleReason(storedSnapshot, currentSnapshot) : null; const latestResult = latestProjection ? { ...latestProjection, session_outcome: sessionOutcomeView(sessionOutcome), + candidate_separation: separation, + candidate_contrast_packet: contrastPacket, + candidate_snapshot: { + is_current: snapshotCurrent, + stale_reason: snapshotCurrent ? null : staleReason, + stale_user_meaning: snapshotCurrent || !staleReason ? null : SNAPSHOT_STALE_COPY[staleReason], + computed_from: currentSnapshot, + }, } : null; return { @@ -274,6 +395,9 @@ type DossierForTools = { selectedTime: string | null; selectionKind: string | null; algorithmVersion: string | null; + evidenceLedgerFingerprint?: string | null; + candidateRangeFingerprint?: string | null; + policyVersion?: string | null; decisionReceipt?: NonNullable["decisionReceipt"]; } | null; turns: V9CaseDossier["turns"]; @@ -315,6 +439,10 @@ export function latestResultToolProjection( nextFollowup?: MethodFollowup | null; methods?: readonly MethodCoverage[]; userStopped?: boolean; + candidateScores?: readonly Readonly<{ time: string; score: number }>[]; + holdoutValidation?: HoldoutValidationStatus; + discriminatorProbe?: ReturnType; + snapshotCurrent?: boolean; }, ): Record { const width = indistinguishableWidthMinutes(latest.candidates); @@ -326,15 +454,25 @@ export function latestResultToolProjection( }); const proposeAllowed = session?.proposeAllowed === true || readProposeAllowed(latest.decisionReceipt); - const sessionOutcome = sessionOutcomeFromGate({ - selectionAllowed: latest.selectionAllowed, - proposeAllowed, - confirmationAllowed: confirmationGate.confirmation_allowed, - interviewOpen: session ? isOfferBlockingFollowup(session.nextFollowup ?? null, session.methods) : true, - userStopped: session?.userStopped, - }); + const candidateScores = session?.candidateScores ?? candidateScoresFromLatest(latest); + const sessionOutcome = session + ? conversationalSessionOutcome({ + selectionAllowed: latest.selectionAllowed, + proposeAllowed, + confirmationAllowed: confirmationGate.confirmation_allowed, + nextFollowup: session.nextFollowup ?? null, + methods: session.methods, + userStopped: session.userStopped, + candidateScores, + discriminatorProbe: session.discriminatorProbe, + holdoutValidation: session.holdoutValidation, + snapshotCurrent: session.snapshotCurrent, + }) + : "collect_evidence"; const houseTable = parseRectificationHouseTable(latest.decisionReceipt?.house_table); const refinement = refinementFromDecisionReceipt(latest.decisionReceipt ?? null); + const separation = evaluateCandidateSeparation(candidateScores); + const contrastPacket = contrastPacketFromLatest(latest); return { result_id: latest.resultId, candidates: latest.candidates, @@ -348,7 +486,9 @@ export function latestResultToolProjection( indistinguishable_width_minutes: width, window_scan: windowScan, confirmation_gate: confirmationGate, - session_outcome: sessionOutcome, + session_outcome: sessionOutcomeView(sessionOutcome), + candidate_separation: separation, + candidate_contrast_packet: contrastPacket, event_dasha_ledger: refinement.event_dasha_ledger, event_fit_rate: refinement.event_fit_rate, dasha_agreement: refinement.dasha_agreement, @@ -369,8 +509,15 @@ export function latestResultToolProjection( eventFitRate: refinement.event_fit_rate, dashaAgreement: refinement.dasha_agreement, techniqueAuditTable: Array.isArray(latest.decisionReceipt?.technique_audit_table) - ? latest.decisionReceipt.technique_audit_table as Array<{ technique?: string; status?: string; note?: string }> + ? latest.decisionReceipt.technique_audit_table as Array<{ + technique?: string; + status?: string; + note?: string; + calculation_result_id?: string; + }> : [], + separation, + engineResultId: latest.resultId, }), ...(houseTable ? { house_table: houseTable } : {}), ...(typeof latest.decisionReceipt?.natal_recast === "object" && latest.decisionReceipt.natal_recast @@ -390,6 +537,8 @@ function collectingFollowupForParsed( const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt ?? null); const observations = internalObservationsFromWindowScan(windowScan); const refinement = refinementFromDecisionReceipt(latest.decisionReceipt ?? null); + const contrastPacket = contrastPacketFromLatest(latest); + const separation = evaluateCandidateSeparation(candidateScoresFromLatest(latest)); return buildMethodFollowupPlan({ evidence: parsed.evidence, activeFocus: parsed.conversationSummary.activeFocus, @@ -402,6 +551,9 @@ function collectingFollowupForParsed( eventProbes: refinement.discriminating_event_probes, askedProbeKeys: askedProbeKeysFromReceipt(latest.decisionReceipt), accepted: Boolean(parsed.case.acceptedTime), + candidatesSeparated: separation.sufficient, + contrastPacket, + holdoutValidation: holdoutStatusFromLatest(latest), }); } @@ -492,6 +644,9 @@ function parseDossierForTools(dossier: V9CaseDossier): DossierForTools { selectedTime: dossier.latestResult.selectedTime, selectionKind: dossier.latestResult.selectionKind, algorithmVersion: dossier.latestResult.algorithmVersion, + evidenceLedgerFingerprint: dossier.latestResult.evidenceLedgerFingerprint, + candidateRangeFingerprint: dossier.latestResult.candidateRangeFingerprint, + policyVersion: dossier.latestResult.policyVersion, decisionReceipt: dossier.latestResult.decisionReceipt, } : null, @@ -1331,7 +1486,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const offerCandidatesTool = createTool({ id: "rectification-offer-candidates", description: - "把已持久化的候选快照呈现给用户(当前候选/相对支持度,不是概率或确定性)。仅在 session_outcome 为 adopt_representative 或 awaiting_confirmation 时允许调用;访谈未停且用户未说先这样则拒绝。不会在同一回复中要求继续补证据。", + "把已持久化的候选快照呈现给用户(当前候选/相对支持度,不是概率或确定性)。仅在 session_outcome 为 adopt_representative、provisional_range 或 awaiting_confirmation 时允许调用;访谈仍在收集或候选区分、独立核对未完成则拒绝。不会在同一回复中要求继续补证据。", inputSchema: z.object({ caseId: z.string().uuid() }).strict(), execute: async (input) => { assertCaseRef(input); @@ -1347,24 +1502,45 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const collectingPlan = collectingFollowupForParsed(parsed, latest); const proposeAllowed = readProposeAllowed(latest.decisionReceipt); const userStopped = latestUserStoppedCollecting(parsed.turns); + const contrastPacket = contrastPacketFromLatest(latest); + const candidateScores = candidateScoresFromLatest(latest); + const currentSnapshot = snapshotSourceFromDossier(parsed, null); + const storedSnapshot = storedSnapshotSource(latest); + const scoreableCurrent = !storedSnapshot.scoreableEvidenceFingerprint + || scoreableSnapshotIsCurrent(storedSnapshot, currentSnapshot); + if (!scoreableCurrent) { + throw new RectificationToolServiceError("offer_not_allowed"); + } const projection = latestResultToolProjection(latest, { proposeAllowed, nextFollowup: collectingPlan.next_followup, methods: collectingPlan.methods, userStopped, + candidateScores, + holdoutValidation: holdoutStatusFromLatest(latest), + discriminatorProbe: selectDiscriminatorProbe(contrastPacket), + snapshotCurrent: true, }); - const sessionKind = (projection.session_outcome as { kind?: string }).kind; - if (sessionKind !== "adopt_representative" && sessionKind !== "awaiting_confirmation") { + const sessionKind = (projection.session_outcome as { kind?: string }).kind ?? ""; + if (!offerSessionKinds().includes(sessionKind)) { throw new RectificationToolServiceError("offer_not_allowed"); } if (isResumableStatus(dossier.case.status as RectificationCaseStatus)) { await transitionV9CaseStatus(accounting, userId, input.caseId, "candidate_ready"); } + const offered = { + ...projection, + candidate_snapshot: { + is_current: true, + stale_reason: null, + computed_from: currentSnapshot, + }, + }; await receipt("rectification-offer-candidates", "candidates.updated", "completed", { inputFingerprint, - resultFingerprint: hashResult(projection), + resultFingerprint: hashResult(offered), }); - return projection; + return offered; } catch (error) { await receipt("rectification-offer-candidates", "candidates.updated", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) }); throw error; diff --git a/frontend/tests/rectification-candidate-result.test.ts b/frontend/tests/rectification-candidate-result.test.ts index ffe0a517..7bb3ea72 100644 --- a/frontend/tests/rectification-candidate-result.test.ts +++ b/frontend/tests/rectification-candidate-result.test.ts @@ -44,12 +44,17 @@ test("low-confidence near ties never receive a recommendation badge", () => { assert.equal(isRecommendedRectificationCandidate(result, result.candidates[0]!), false); }); -test("only an unselected representative receives a recommendation badge", () => { +test("only an unselected separated representative receives a recommendation badge", () => { const result = parseRectificationCandidateResult({ ...camelCaseSnapshot, overallConfidence: "medium", confirmationAllowed: false, representativeTime: "05:07", + candidates: [ + { candidateId: CANDIDATE_ID, rank: 1, time: "05:07", relativeSupport: 62, tiedMinuteCount: 1 }, + { candidateId: SECOND_CANDIDATE_ID, rank: 2, time: "05:08", relativeSupport: 22, tiedMinuteCount: 1 }, + { candidateId: THIRD_CANDIDATE_ID, rank: 3, time: "05:09", relativeSupport: 16, tiedMinuteCount: 1 }, + ], }); assert.ok(result); diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index 7d93f25f..47923605 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -374,7 +374,7 @@ test("distinguish follow-up copy forbids competing-chart ranking", () => { assert.match(plan.next_followup?.user_prompt_hint ?? "", /不得发明年份/); }); -test("GET A/B card stays hidden once representative time can be offered", () => { +test("GET A/B card stays visible after coverage when candidates remain tied", () => { const card = projectRectificationChoiceCard({ evidence: [{ status: "confirmed", @@ -411,6 +411,11 @@ test("GET A/B card stays hidden once representative time can be offered", () => eventProbes: [MOVE_PROBE], selectionAllowed: true, proposeAllowed: true, + candidateScores: [ + { time: "05:00", score: 34 }, + { time: "05:01", score: 33 }, + { time: "05:02", score: 33 }, + ], activeFocus: { intent: "distinguish_candidates", targetDomain: "relocation", @@ -418,6 +423,54 @@ test("GET A/B card stays hidden once representative time can be offered", () => expectedAnswerSchema: { choice: SAMPLE_COPY }, }, }); + assert.ok(card); + assert.equal(card.prompt, SAMPLE_COPY.prompt); +}); + +test("GET A/B card stays hidden once candidates are separated and ready to offer", () => { + const card = projectRectificationChoiceCard({ + evidence: [{ + status: "confirmed", + domain: "education", + datePrecision: "year", + occurredFrom: "2016-01-01", + occurredTo: null, + }, { + status: "confirmed", + domain: "relationship", + datePrecision: "year", + occurredFrom: "2018-01-01", + occurredTo: null, + }, { + status: "confirmed", + domain: "career", + datePrecision: "year", + occurredFrom: "2019-01-01", + occurredTo: null, + }, { + status: "confirmed", + domain: "family", + datePrecision: "year", + occurredFrom: "2020-01-01", + occurredTo: null, + }, { + status: "draft", + domain: "occupation", + datePrecision: "unknown", + occurredFrom: null, + occurredTo: null, + eventKind: "occupation_note", + }], + eventProbes: [MOVE_PROBE], + selectionAllowed: true, + proposeAllowed: true, + holdoutValidation: "passed", + candidateScores: [ + { time: "05:00", score: 62 }, + { time: "05:01", score: 22 }, + { time: "05:02", score: 16 }, + ], + }); assert.equal(card, null); }); diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts new file mode 100644 index 00000000..7a275428 --- /dev/null +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -0,0 +1,230 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildCandidateContrastPacket, + selectDiscriminatorProbe, +} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; +import { evaluateCandidateSeparation } from "../src/lib/rectification-agentic/core/candidate-separation.ts"; +import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts"; +import { + classifySnapshotStaleReason, + scoreableSnapshotIsCurrent, + snapshotIsCurrent, + type CandidateSnapshotSource, +} from "../src/lib/rectification-agentic/core/snapshot-source.ts"; +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 }, + { id: "c2", time: "05:02", score: 33 }, +]; + +const SEPARATED = [ + { id: "c0", time: "05:00", score: 62 }, + { id: "c1", time: "05:01", score: 22 }, + { id: "c2", time: "05:02", score: 16 }, +]; + +const CONTRAST_PROBE = selectDiscriminatorProbe(buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:02:05:00,05:01,05:02", + calculationResultId: "11111111-1111-4111-8111-111111111111", + engineProbes: [{ + semantic_key: "career.2018.dasha_activation", + candidate_split_hash: "career:2018:05:00|05:01", + domain: "career", + year: 2018, + user_meaning: "2018 年前后是否职责明显加重?", + information_gain: 0.21, + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:01", "05:02"] }, + { answer_class: "no", supports: ["05:01"], conflicts: ["05:00"] }, + ], + }], + vargaDifferences: [{ layer: "d10", signs: ["巨蟹", "狮子", "处女"] }], +})); + +function source(overrides: Partial = {}): CandidateSnapshotSource { + return { + birthProfileFingerprint: "birth-a", + scoreableEvidenceFingerprint: "score-a", + inferenceRevision: 3, + candidateSetVersion: "set-a", + scoringPolicyVersion: "policy-v2", + ...overrides, + }; +} + +test("does not adopt when method coverage is complete but candidates remain tied", () => { + const next = decideNextAction({ + methodCoverageAll: true, + proposeAllowed: true, + candidateScores: TIED, + discriminatorProbe: CONTRAST_PROBE, + }); + assert.equal(next.type, "ask_candidate_discriminator"); + assert.equal(next.separation.status, "not_separated"); + assert.ok((next.probe?.expectedOutcomes.length ?? 0) >= 2); +}); + +test("tied candidates prefer a discriminator probe with at least two predicted outcomes", () => { + const next = decideNextAction({ + methodCoverageAll: true, + proposeAllowed: true, + candidateScores: TIED, + discriminatorProbe: CONTRAST_PROBE, + }); + assert.equal(next.type, "ask_candidate_discriminator"); + assert.ok((next.probe?.expectedOutcomes.length ?? 0) >= 2); +}); + +test("holdout that has not passed cannot enter ready_to_adopt", () => { + const next = decideNextAction({ + methodCoverageAll: true, + proposeAllowed: true, + candidateScores: SEPARATED, + holdoutValidation: "not_started", + }); + assert.equal(next.type, "ask_holdout_validation"); + assert.notEqual(next.type, "ready_to_adopt"); +}); + +test("separated candidates with holdout passed are ready to adopt", () => { + const next = decideNextAction({ + methodCoverageAll: true, + proposeAllowed: true, + candidateScores: SEPARATED, + holdoutValidation: "passed", + }); + assert.equal(next.type, "ready_to_adopt"); +}); + +test("missing method coverage stays in fact collection even if scores look separated", () => { + const next = decideNextAction({ + methodCoverageAll: false, + proposeAllowed: true, + candidateScores: SEPARATED, + discriminatorProbe: CONTRAST_PROBE, + }); + assert.equal(next.type, "ask_fact_collection"); +}); + +test("D9/D10 sign differences synthesize a contrast probe when engine probes are empty", () => { + const packet = buildCandidateContrastPacket({ + candidateSetVersion: "set-a", + calculationResultId: "22222222-2222-4222-8222-222222222222", + vargaDifferences: [ + { layer: "d9", signs: ["天秤", "天蝎", "射手"] }, + { layer: "d10", signs: ["巨蟹", "狮子", "处女"] }, + ], + }); + const probe = selectDiscriminatorProbe(packet); + assert.ok(probe); + assert.ok(probe.expectedOutcomes.length >= 2); + assert.equal(probe.sourceFeatures[0]?.calculationResultId, "22222222-2222-4222-8222-222222222222"); + assert.match(probe.question, /职业前事|事业盘/); +}); + +test("non-scoreable occupation note does not change the scoreable evidence fingerprint", () => { + const dated = [{ + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + status: "confirmed" as const, + eventKind: "business_start", + domain: "career", + occurredFrom: "2026-07-19", + occurredTo: null, + datePrecision: "day" as const, + summary: "注册公司", + }]; + const withNote = [...dated, { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", + status: "confirmed" as const, + eventKind: "occupation_note", + domain: "occupation", + occurredFrom: null, + occurredTo: null, + datePrecision: "unknown" as const, + summary: "长期一直是程序员", + }]; + const before = evidenceLedgerFingerprint(dated as never); + const after = evidenceLedgerFingerprint(withNote as never); + assert.equal(after, before); + const snapshot = source({ scoreableEvidenceFingerprint: before }); + const current = source({ scoreableEvidenceFingerprint: after, inferenceRevision: snapshot.inferenceRevision }); + assert.equal(snapshotIsCurrent(snapshot, current), true); + assert.equal(scoreableSnapshotIsCurrent(snapshot, current), true); +}); + +test("scoreable occupation evidence requires a new inference revision", () => { + const before = source(); + const afterScoreable = source({ + scoreableEvidenceFingerprint: "score-b", + inferenceRevision: before.inferenceRevision + 1, + }); + assert.equal(classifySnapshotStaleReason(before, afterScoreable), "scoreable_evidence_changed"); + assert.equal(afterScoreable.inferenceRevision, before.inferenceRevision + 1); +}); + +test("candidate cards must be created from the current inference and scoreable revisions", () => { + const current = source({ inferenceRevision: 4 }); + const card = source({ inferenceRevision: 3 }); + assert.equal(classifySnapshotStaleReason(card, current), "inference_revision_changed"); + const matching = source({ inferenceRevision: 4 }); + assert.equal(snapshotIsCurrent(matching, current), true); +}); + +test("stale reasons distinguish birth profile from scoreable evidence", () => { + const current = source(); + assert.equal( + classifySnapshotStaleReason(source({ birthProfileFingerprint: "birth-b" }), current), + "birth_profile_changed", + ); + assert.equal( + classifySnapshotStaleReason(source({ scoreableEvidenceFingerprint: "score-b" }), current), + "scoreable_evidence_changed", + ); + assert.equal( + classifySnapshotStaleReason(source({ candidateSetVersion: "set-b" }), current), + "candidate_set_superseded", + ); +}); + +test("34/33/33 is a tie, not a recommended winner", () => { + const separation = evaluateCandidateSeparation(TIED); + assert.equal(separation.status, "not_separated"); + assert.equal(separation.sufficient, false); + assert.deepEqual(separation.credibleRange, ["05:00", "05:01", "05:02"]); + assert.equal(separation.representativeTime, "05:00"); +}); + +test("final report does not claim executed techniques without calculationResultId", () => { + const report = buildSkillVerificationReport({ + representativeTime: "05:00", + widthMinutes: 3, + candidates: [ + { time: "05:00", rank: 1, relativeSupport: 34 }, + { time: "05:01", rank: 2, relativeSupport: 33 }, + { time: "05:02", rank: 3, relativeSupport: 33 }, + ], + separation: evaluateCandidateSeparation(TIED), + techniqueAuditTable: [ + { technique: "D9", status: "executed", note: "no id" }, + { technique: "D10", status: "executed", note: "has id", calculation_result_id: "33333333-3333-4333-8333-333333333333" }, + ], + eventFitRate: { + matched: 8, + total: 9, + percent: 89, + label: "8/9", + user_meaning: "这段时间窗对已收事件有一定解释力", + unique_minute_claim: false, + }, + }); + assert.match(report, /基本并列/); + assert.match(report, /事件拟合程度,不是候选区分程度/); + assert.match(report, /\| D9 \| input_covered \|/); + assert.match(report, /\| D10 \| executed \|/); + assert.doesNotMatch(report, /当前推荐/); +}); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 914ff0ea..60a3dc53 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -812,21 +812,27 @@ test("precision stage lagna_frame waits for uncovered career before asking anoth assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY); }); -test("lagna_frame after classic coverage does not keep blocking representative time cards", () => { +test("lagna_frame after classic coverage still discriminates when candidates are tied", () => { const plan = buildMethodFollowupPlan({ evidence: CLASSIC_COVERAGE, precisionStage: "lagna_frame", }); assert.equal(plan.next_followup?.source, "precision_stage"); assert.equal(plan.next_followup?.ask_theme, "dated_event"); - assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), false); + assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), true); + assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods, { separated: true }), false); assert.equal(conversationalSessionOutcome({ selectionAllowed: true, proposeAllowed: true, confirmationAllowed: false, nextFollowup: plan.next_followup, methods: plan.methods, - }), "adopt_representative"); + candidateScores: [ + { time: "05:00", score: 34 }, + { time: "05:01", score: 33 }, + { time: "05:02", score: 33 }, + ], + }), "discriminate_candidates"); }); test("precision stage d4 asks home change not family, and d5 asks education", () => { @@ -1007,7 +1013,7 @@ test("career evidence does not cover occupation; occupation still blocks until a }), "collect_evidence"); }); -test("draft occupation_note without a date covers occupation and unblocks offering", () => { +test("draft occupation_note without a date covers occupation and does not adopt a tie", () => { const plan = buildMethodFollowupPlan({ evidence: [ { status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null }, @@ -1027,12 +1033,17 @@ test("draft occupation_note without a date covers occupation and unblocks offeri }); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered"); assert.notEqual(plan.next_followup?.method_id, "occupation"); - assert.equal(conversationalSessionOutcome({ + assert.notEqual(conversationalSessionOutcome({ selectionAllowed: true, proposeAllowed: true, confirmationAllowed: false, nextFollowup: plan.next_followup, methods: plan.methods, + candidateScores: [ + { time: "05:00", score: 34 }, + { time: "05:01", score: 33 }, + { time: "05:02", score: 33 }, + ], }), "adopt_representative"); }); @@ -1059,15 +1070,76 @@ test("stale occupation collect focus does not keep interviewing after occupation targetKind: "occupation_note", }, }); - assert.notEqual(plan.next_followup?.method_id, "active_focus"); - assert.notEqual(plan.next_followup?.method_id, "occupation"); + assert.notEqual(conversationalSessionOutcome({ + selectionAllowed: true, + proposeAllowed: true, + confirmationAllowed: false, + nextFollowup: plan.next_followup, + methods: plan.methods, + candidateScores: [ + { time: "05:00", score: 34 }, + { time: "05:01", score: 33 }, + { time: "05:02", score: 33 }, + ], + }), "adopt_representative"); +}); + +test("D9/D10 contrast after occupation coverage asks a discriminator, not adopt", () => { + const plan = buildMethodFollowupPlan({ + evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"), + contrastPacket: { + candidateSetVersion: "05:00-05:02", + vargaDifferences: [ + { layer: "d9", signs: ["天秤", "天蝎", "射手"] }, + { layer: "d10", signs: ["巨蟹", "狮子", "处女"] }, + ], + probes: [{ + probeId: "contrast:varga.d10.巨蟹|狮子|处女", + candidateSetVersion: "05:00-05:02", + question: "当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事。", + expectedOutcomes: [ + { outcomeId: "supports_巨蟹", supportsCandidateIds: ["巨蟹"], conflictsCandidateIds: ["狮子", "处女"] }, + { outcomeId: "supports_狮子", supportsCandidateIds: ["狮子"], conflictsCandidateIds: ["巨蟹", "处女"] }, + { outcomeId: "supports_处女", supportsCandidateIds: ["处女"], conflictsCandidateIds: ["巨蟹", "狮子"] }, + ], + candidateSplitHash: "varga.d10.巨蟹|狮子|处女", + informationGain: 0.12, + sourceFeatures: [{ technique: "D10", calculationResultId: RESULT_ID }], + domain: "career", + year: null, + semanticKey: "varga.d10.巨蟹|狮子|处女", + }], + }, + }); + assert.equal(plan.next_followup?.intent, "distinguish_candidates"); + assert.match(plan.next_followup?.user_prompt_hint ?? "", /职业前事|事业盘/); assert.equal(conversationalSessionOutcome({ selectionAllowed: true, proposeAllowed: true, confirmationAllowed: false, nextFollowup: plan.next_followup, methods: plan.methods, - }), "adopt_representative"); + discriminatorProbe: { + probeId: "contrast:varga.d10", + candidateSetVersion: "05:00-05:02", + question: "核对一段还没用进评分的职业前事", + expectedOutcomes: [ + { outcomeId: "a", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:01"] }, + { outcomeId: "b", supportsCandidateIds: ["05:01"], conflictsCandidateIds: ["05:00"] }, + ], + candidateSplitHash: "varga.d10", + informationGain: 0.12, + sourceFeatures: [{ technique: "D10", calculationResultId: RESULT_ID }], + domain: "career", + year: null, + semanticKey: "varga.d10", + }, + candidateScores: [ + { time: "05:00", score: 34 }, + { time: "05:01", score: 33 }, + { time: "05:02", score: 33 }, + ], + }), "discriminate_candidates"); }); @@ -1125,10 +1197,10 @@ test("high information_gain leftover probe still blocks offering after coverage" confirmationAllowed: false, nextFollowup: plan.next_followup, methods: plan.methods, - }), "collect_evidence"); + }), "discriminate_candidates"); }); -test("event_probe does not block offering once blocking methods are covered", () => { +test("event_probe still discriminates after coverage when candidates remain tied", () => { const plan = buildMethodFollowupPlan({ evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"), eventProbes: [{ @@ -1144,14 +1216,20 @@ test("event_probe does not block offering once blocking methods are covered", () role: "reverse_verify", }], }); - assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), false); + assert.equal(plan.next_followup?.source, "event_probe"); + assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), true); assert.equal(conversationalSessionOutcome({ selectionAllowed: true, proposeAllowed: true, confirmationAllowed: false, nextFollowup: plan.next_followup, methods: plan.methods, - }), "adopt_representative"); + candidateScores: [ + { time: "05:00", score: 34 }, + { time: "05:01", score: 33 }, + { time: "05:02", score: 33 }, + ], + }), "discriminate_candidates"); }); test("accepted time reverse-verifies an uncovered year in a covered domain", () => { @@ -1206,6 +1284,12 @@ test("declining occupation covers the method; declining horary is skipped_by_pol confirmationAllowed: false, nextFollowup: plan.next_followup, methods: plan.methods, + candidateScores: [ + { time: "05:00", score: 62 }, + { time: "05:01", score: 22 }, + { time: "05:02", score: 16 }, + ], + holdoutValidation: "passed", }), "adopt_representative"); }); @@ -1214,12 +1298,19 @@ test("horary follow-up does not block propose once occupation is covered", () => evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"), }); assert.equal(plan.next_followup?.method_id, "horary"); + assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), false); assert.equal(conversationalSessionOutcome({ selectionAllowed: true, proposeAllowed: true, confirmationAllowed: false, nextFollowup: plan.next_followup, methods: plan.methods, + candidateScores: [ + { time: "05:00", score: 62 }, + { time: "05:01", score: 22 }, + { time: "05:02", score: 16 }, + ], + holdoutValidation: "passed", }), "adopt_representative"); }); @@ -1275,6 +1366,7 @@ test("user stop with selection_allowed may offer the escape hatch", async () => latestResult: candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "04:48", + evidenceLedgerFingerprint: null, candidates: [ { candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 }, { candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 }, @@ -1306,3 +1398,104 @@ test("user stop with selection_allowed may offer the escape hatch", async () => ); }); +test("offer-candidates refuses a 34/33/33 tie after method coverage", async () => { + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture({ + evidence: [ + { ...educationEvidence, domain: "education" }, + { + id: "44444444-4444-4444-8444-444444444442", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "relationship_start", + domain: "relationship", + occurred_from: "2018-01-01", + occurred_to: null, + date_precision: "year", + summary: "感情变化", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:07.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444443", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "career_entry", + domain: "career", + occurred_from: "2019-01-01", + occurred_to: null, + date_precision: "year", + summary: "工作变化", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:08.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444446", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "family_event", + domain: "family", + occurred_from: "2020-01-01", + occurred_to: null, + date_precision: "year", + summary: "家人变化", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:09.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444445", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "occupation_note", + domain: "occupation", + occurred_from: null, + occurred_to: null, + date_precision: "unknown", + summary: "长期一直是程序员", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:10.000Z", + }, + ], + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + representativeTime: "05:00", + evidenceLedgerFingerprint: null, + decisionReceipt: { + propose_allowed: true, + window_scan: { + scanned: true, + d9_lagna_count: 3, + d10_lagna_count: 3, + d9_candidates_differ: true, + d10_candidates_differ: true, + d9_sign_names: ["天秤", "天蝎", "射手"], + d10_sign_names: ["巨蟹", "狮子", "处女"], + }, + }, + candidates: [ + { candidate_id: CANDIDATE_ID, rank: 1, time: "05:00", relative_support: 34, tied_minute_count: 1 }, + { candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "05:01", relative_support: 33, tied_minute_count: 1 }, + { candidate_id: THIRD_CANDIDATE_ID, rank: 3, time: "05:02", relative_support: 33, tied_minute_count: 1 }, + ], + }), + }), + }); + const tools = createRectificationV9Tools({ + userId: USER_ID, + caseId: CASE_ID, + turnId: TURN_ID, + accounting: accounting.client as never, + }); + await assert.rejects( + () => (tools["rectification-offer-candidates"] as unknown as { + execute(input: unknown): Promise; + }).execute({ caseId: CASE_ID }), + (error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed", + ); +}); + diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index eb90e33a..58686d5e 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -71,7 +71,9 @@ test("system prompt carries only high-priority boundaries, never the method copy assert.match(prompt, /工具执行过程保持静默/); assert.match(prompt, /思考过程必须用简体中文/); assert.match(prompt, /skill_verification_report/); - assert.match(prompt, /精度阶段追问不挡出牌/); + assert.match(prompt, /ask_candidate_discriminator/); + assert.match(prompt, /方法覆盖已齐只进入候选区分/); + assert.doesNotMatch(prompt, /方法覆盖已齐且 propose_allowed 时本轮 adopt/); assert.match(prompt, /不得询问外貌、体质、胎记或疤痕/); assert.match(prompt, /expectedAnswerSchema.choice/); assert.match(prompt, /不要写 choice/); diff --git a/frontend/tests/rectification-v9-test-support.ts b/frontend/tests/rectification-v9-test-support.ts index 53c0acdd..3f6d36c0 100644 --- a/frontend/tests/rectification-v9-test-support.ts +++ b/frontend/tests/rectification-v9-test-support.ts @@ -226,6 +226,8 @@ export function candidateSnapshotFixture(overrides: { selectedTime?: string | null; selectionKind?: string | null; candidates?: unknown[]; + evidenceLedgerFingerprint?: string | null; + decisionReceipt?: Record; } = {}) { return { result_id: RESULT_ID, @@ -244,12 +246,15 @@ export function candidateSnapshotFixture(overrides: { confirmation_allowed: overrides.confirmationAllowed ?? false, representative_candidate_id: overrides.representativeTime ? CANDIDATE_ID : null, overall_confidence: "medium", + ...overrides.decisionReceipt, }, execution_ledger: [{ method: "d1-rashi", status: "executed" }], representative_time: overrides.representativeTime ?? null, selected_time: overrides.selectedTime ?? null, selection_kind: overrides.selectionKind ?? null, - evidence_ledger_fingerprint: "b".repeat(64), + evidence_ledger_fingerprint: overrides.evidenceLedgerFingerprint === undefined + ? "b".repeat(64) + : overrides.evidenceLedgerFingerprint, candidate_range_fingerprint: "c".repeat(64), skill_version: "9.0.0", algorithm_version: "rectification-v5",