diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2d5517d0..195f3edb 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6686,3 +6686,19 @@ - 复发自:无 - 修复版本:待发布 + +## BUG-437 | 区分卡因过期候选集前缀被二次加前缀,界面无卡且 Agent 只承接不提问 + +- 状态:resolved +- 首次发现:2026-08-29 +- 最近更新:2026-08-29 +- 影响面:`probeFromEngine` 的 `candidate_split_hash` 归一化、`projectRectificationChoiceCard` 身份校验、`scoreAndPersistCurrentEvidence` 组包的 `candidateSetVersion`、GET `choice_card` +- 用户现象:`current_question` 是合法选择题(`kind=choice`、`focus_id` 与 `probe_id` 齐全、题干已生成),`choice_card` 却是 null。Agent 按提示第 4 条只承接不复述,正文只剩「请你凭印象选一个」,界面没有卡片,用户无法继续。`dropped_probes` 为空,看不出被抑制的原因。 +- 触发条件:某一轮打分改变了候选分钟列表(例如 05:05 换成 05:00),此前铸出的 varga 对比探针仍带旧候选集前缀,随后任意一次读路径重建对比包。 +- 根因:`scoreAndPersistCurrentEvidence` 组对比包时 `candidateSetVersion` 取上一轮 `inference_state.candidate_set_id`,`candidateTimes` 取本轮 `score.candidates`,于是新写入的探针 hash 带着旧集合前缀,而同一份 state 的 `candidate_set_id` 按新候选生成。之后 `probeFromEngine` 用 `hash.includes(candidateSetVersion)` 判断是否已有前缀,旧前缀不含新集合 id,于是再加一层前缀得到双前缀 hash。落 focus 时 `stampChoiceSchemaWithProbe` 写的是 state 里探针的单前缀原值,`projectRectificationChoiceCard` 的 `candidate_split_hash` 全等校验因此永远不成立,直接 return null,且不记入任何丢弃账本。 +- 修复:新增 `splitHashForCandidateSet`,hash 为 `<候选集 id>:<语义键>` 形态时按当前候选集重铸,已带当前前缀时保持不变,不透明引擎 hash 只加一次前缀,杜绝前缀叠加。新增 `isSameCandidateSplit`,两侧同为 `<任意候选集 id>:<同一语义键>` 时视为同一 split(语义键本身已编码候选分组),`projectRectificationChoiceCard` 改用它做身份校验,语义键不同或不透明 hash 不同仍然 fail closed。组包 `candidateSetVersion` 改为按本轮 `candidateRange` 与 `score.candidates` 计算的 `candidateSetId`,与写入 state 的候选集同源。Skill 版本保持 `10.0.13`,未放宽 confirmation gate。 +- 验证:`frontend/tests/rectification-choice-card.test.ts` 锁定「探针 hash 带旧候选集前缀时 GET 仍返回卡片」(去掉修复即失败)与「持久化 split 属于另一探针时仍隐藏」;`rectification-candidate-contrast-packet.test.ts` 锁定前缀重铸幂等、不透明 hash 只加一次、split 身份忽略前缀但不忽略探针、组包按本轮候选集作用域。`npx tsc --noEmit`;`npx tsx --test tests/rectification-*.test.ts tests/birth-time-rectification-contract.test.ts tests/agentic-rectification-*.test.ts` 658 项 0 失败;全量 `npx tsx --test tests/*.test.ts` 失败集合与改动前一致(23 项数据库/部署环境类)。 +- 防复发:探针 `candidate_split_hash` 只能有一层候选集前缀,任何读路径不得对已带前缀的 hash 再加前缀。打分写入的对比包必须与本轮候选集同源,不得沿用上一轮 `candidate_set_id`。卡片身份校验不得因候选集前缀差异静默吞掉合法卡片。 +- 相关记录:BUG-410、BUG-411、BUG-432 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts index 16215a4c..27c7c8f9 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -667,6 +667,46 @@ function inferredContrastChoiceKind( return "existence"; } +/** + * Split hashes are `:` for varga contrasts and + * `:` for dated event probes. A stored + * probe can carry the set id of an earlier scoring round; re-prefixing such a + * hash would stack prefixes and make the persisted focus schema unmatchable, so + * a stale set prefix is re-minted against the current set instead. + */ +export function splitHashForCandidateSet( + rawHash: string | null | undefined, + semanticKey: string, + candidateSetVersion: string, +): string { + const scoped = `${candidateSetVersion}:${semanticKey}`; + const hash = rawHash?.trim() ?? ""; + if (!hash) return scoped; + if (hash === scoped) return hash; + if (hash.endsWith(`:${semanticKey}`)) return scoped; + if (hash.startsWith(`${candidateSetVersion}:`)) return hash; + return `${candidateSetVersion}:${hash}`; +} + +/** + * Two hashes describe the same split when they are equal, or when both are + * `:`. The semantic key of a + * varga contrast already encodes its candidate groups, so only the set prefix + * can differ, and a stale prefix must not hide an otherwise valid card. + */ +export function isSameCandidateSplit( + left: string | null | undefined, + right: string | null | undefined, + semanticKey: string | null | undefined, +): boolean { + const a = left?.trim() ?? ""; + const b = right?.trim() ?? ""; + if (a === b) return true; + const key = semanticKey?.trim() ?? ""; + if (!key || !a || !b) return false; + return a.endsWith(`:${key}`) && b.endsWith(`:${key}`); +} + function probeFromEngine( probe: EngineContrastProbe, candidateSetVersion: string, @@ -684,11 +724,11 @@ function probeFromEngine( if (outcomes.length < 2) return null; if ((probe.information_gain ?? 0) <= 0) return null; const semanticKey = probe.semantic_key ?? `${probe.domain ?? "career"}.${probe.year ?? "contrast"}`; - const split = probe.candidate_split_hash - ? (probe.candidate_split_hash.includes(candidateSetVersion) - ? probe.candidate_split_hash - : `${candidateSetVersion}:${probe.candidate_split_hash}`) - : `${candidateSetVersion}:${semanticKey}`; + const split = splitHashForCandidateSet( + probe.candidate_split_hash, + semanticKey, + candidateSetVersion, + ); const question = probe.question ?? probe.user_meaning ?? ""; if (!question.trim()) return null; const candidateIds = [...new Set(outcomes.flatMap((row) => [ diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 789fd031..a57c7de1 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -63,6 +63,7 @@ import { decideRectification, type HoldoutValidationStatus } from "../core/recti import { datedDomainsFromEvidence, isStructuredDiscriminator, + isSameCandidateSplit, mentionedVargaKeysFromLedgerEvidence, vargaLayerCovered, vargaLayerFromSemanticKey, @@ -1818,7 +1819,14 @@ export function projectRectificationChoiceCard( : null; if (input.activeFocus?.intent !== followup.intent) return null; if (followup.semantic_key && schemaRow?.semantic_key !== followup.semantic_key) return null; - if (followup.candidate_split_hash && schemaRow?.candidate_split_hash !== followup.candidate_split_hash) return null; + if ( + followup.candidate_split_hash + && !isSameCandidateSplit( + typeof schemaRow?.candidate_split_hash === "string" ? schemaRow.candidate_split_hash : null, + followup.candidate_split_hash, + followup.semantic_key, + ) + ) return null; if ( !followup.semantic_key && !followup.candidate_split_hash diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 16af6338..b8119f4a 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -105,6 +105,7 @@ import { inspectDiscriminatorProbes, mentionedVargaKeysFromLedgerEvidence, } from "@/lib/rectification-agentic/core/candidate-contrast-packet"; +import { candidateSetId } from "@/lib/rectification-agentic/core/build-state"; import { offerSessionKinds } from "@/lib/rectification-agentic/core/decide-next-action"; import { candidateSnapshotSource, @@ -882,9 +883,16 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); const refinement = refinementFromDecisionReceipt(receipt); const windowScan = windowScanFromDecisionReceipt(receipt); + // The packet must be scoped to the candidate set this round produced. Using + // the previous round's set id mints split hashes that no later read can + // match, which silently suppresses the persisted choice card. + const scoredCandidateSetId = candidateSetId( + parsed.case.candidateRange.start_time, + parsed.case.candidateRange.end_time, + score.candidates.map((item) => item.time), + ); const contrastPacket = buildCandidateContrastPacket({ - candidateSetVersion: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null)?.candidate_set_id - ?? score.engineResultId, + candidateSetVersion: scoredCandidateSetId, calculationResultId: score.engineResultId, engineProbes: refinement.discriminating_event_probes, vargaDifferences: [ diff --git a/frontend/tests/rectification-candidate-contrast-packet.test.ts b/frontend/tests/rectification-candidate-contrast-packet.test.ts index 4b39eaeb..92194429 100644 --- a/frontend/tests/rectification-candidate-contrast-packet.test.ts +++ b/frontend/tests/rectification-candidate-contrast-packet.test.ts @@ -1,11 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { readFileSync } from "node:fs"; + import { buildCandidateContrastPacket, + isSameCandidateSplit, mentionedVargaKeysFromLedgerEvidence, remainingVargaSplits, selectDiscriminatorProbe, + splitHashForCandidateSet, volunteeredDomainsFromEvidence, } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; @@ -167,3 +171,62 @@ test("occupation mention does not skip remaining D10; answered varga.d10 still d }); assert.equal(answeredPacket.probes.some((item) => item.semanticKey.startsWith("varga.d10.")), false); }); + +test("a stale candidate-set prefix is re-minted instead of stacked", () => { + const current = "05:00-05:07:05:00,05:06,05:07"; + const stale = "05:00-05:07:05:00,05:05,05:07"; + const key = "varga.d9.05:00|05:06/05:07"; + assert.equal(splitHashForCandidateSet(`${stale}:${key}`, key, current), `${current}:${key}`); + assert.equal(splitHashForCandidateSet(`${current}:${key}`, key, current), `${current}:${key}`); + assert.equal(splitHashForCandidateSet(null, key, current), `${current}:${key}`); + // opaque engine hashes stay scoped exactly once + assert.equal(splitHashForCandidateSet("f4cc4975", "career.2023", current), `${current}:f4cc4975`); + assert.equal( + splitHashForCandidateSet(`${current}:f4cc4975`, "career.2023", current), + `${current}:f4cc4975`, + ); +}); + +test("engine probes keep one candidate-set prefix after the set changes", () => { + const current = "05:00-05:07:05:00,05:06,05:07"; + const stale = "05:00-05:07:05:00,05:05,05:07"; + const key = "varga.d9.05:00|05:06/05:07"; + const packet = buildCandidateContrastPacket({ + candidateSetVersion: current, + candidateTimes: ["05:00", "05:06", "05:07"], + engineProbes: [{ + semantic_key: key, + candidate_split_hash: `${stale}:${key}`, + domain: "relationship", + user_meaning: "亲密关系里更接近下面哪一种相处方式?", + information_gain: 1.53, + choice_kind: "varga_style", + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] }, + { answer_class: "no", supports: ["05:07"], conflicts: ["05:00", "05:06"] }, + ], + }], + }); + const probe = packet.probes.find((item) => item.semanticKey === key); + assert.ok(probe); + assert.equal(probe.candidateSplitHash, `${current}:${key}`); + assert.doesNotMatch(probe.candidateSplitHash, /05:05/); +}); + +test("split identity ignores the candidate-set prefix but not the probe", () => { + const key = "varga.d9.05:00|05:06/05:07"; + assert.equal(isSameCandidateSplit(`a:${key}`, `b:${key}`, key), true); + assert.equal(isSameCandidateSplit(`a:${key}`, `a:varga.d10.05:00|05:06/05:07`, key), false); + assert.equal(isSameCandidateSplit("f4cc4975", "a:f4cc4975", "career.2023"), false); + assert.equal(isSameCandidateSplit("f4cc4975", "f4cc4975", "career.2023"), true); +}); + +test("scoring scopes the contrast packet to the candidate set it just produced", () => { + const source = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); + const packetCall = source.slice( + source.indexOf("const scoredCandidateSetId = candidateSetId("), + source.indexOf("const inference = buildCaseInferenceState("), + ); + assert.ok(packetCall.includes("candidateSetVersion: scoredCandidateSetId")); + assert.doesNotMatch(packetCall, /candidateSetVersion:\s*previousInferenceFromReceipt/); +}); diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index 9a4ab6fc..f45f034d 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -1376,3 +1376,136 @@ test("dasha existence cards lock the event and use supplied dynamic labels", () assert.equal(copy.option_a, DYNAMIC_STYLE_OPTIONS[0].label); assert.equal(copy.option_c, DYNAMIC_STYLE_OPTIONS[2].label); }); + +const D9_STALE_KEY = "varga.d9.05:00|05:06/05:07"; +const CURRENT_SET_ID = "05:00-05:07:05:00,05:06,05:07"; +// The probe was minted while the candidate set still held 05:05 instead of 05:06. +const STALE_SET_ID = "05:00-05:07:05:00,05:05,05:07"; +const D9_STYLE_OPTIONS = [ + { sign: "天秤座", label: "和谐、美感、合作", answer_class: "yes" }, + { sign: "天蝎座", label: "深刻、占有欲、转化", answer_class: "weak_yes" }, + { sign: "射手座", label: "自由、哲学、冒险", answer_class: "no" }, + { label: "这段记不清楚", answer_class: "unsure" }, +] as const; + +function staleSplitDossier(schemaHash: string) { + return { + evidence: [ + { id: "e-career-a", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: "2020-04-01", eventKind: "career_entry" }, + { id: "e-career-b", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-10-01", occurredTo: "2020-10-01", eventKind: "career_exit" }, + { id: "e-rel-end", status: "confirmed", domain: "relationship", datePrecision: "day", occurredFrom: "2024-08-08", occurredTo: "2024-08-08", eventKind: "relationship_end" }, + { id: "e-rel-start", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2024-05-01", occurredTo: "2024-05-31", eventKind: "relationship_start" }, + ], + conversationSummary: { + activeFocus: { + id: FOCUS_ID, + questionId: `probe:${D9_STALE_KEY}`, + intent: "distinguish_candidates", + targetDomain: "relationship", + targetKind: null, + expectedAnswerSchema: { + choice: { + prompt: "2024 年前后,相处方式更接近其中一种?", + option_a: D9_STYLE_OPTIONS[0].label, + option_b: D9_STYLE_OPTIONS[1].label, + option_c: D9_STYLE_OPTIONS[2].label, + option_d: D9_STYLE_OPTIONS[3].label, + options: [ + { key: "A", label: D9_STYLE_OPTIONS[0].label, answer_class: "yes" }, + { key: "B", label: D9_STYLE_OPTIONS[1].label, answer_class: "weak_yes" }, + { key: "C", label: D9_STYLE_OPTIONS[2].label, answer_class: "no" }, + { key: "D", label: D9_STYLE_OPTIONS[3].label, answer_class: "unsure" }, + ], + }, + probe_id: `contrast:${D9_STALE_KEY}`, + semantic_key: D9_STALE_KEY, + candidate_split_hash: schemaHash, + choice_kind: "varga_style", + }, + }, + declinedSkippedTopics: [], + }, + latestResult: { + resultId: "66666666-6666-4666-8666-666666666666", + selectionAllowed: false, + candidates: [ + { time: "05:00", relativeSupport: 34 }, + { time: "05:06", relativeSupport: 33 }, + { time: "05:07", relativeSupport: 33 }, + ], + decisionReceipt: { + window_scan: { + scanned: true, + d9_candidates_differ: true, + d9_sign_names: ["天秤座", "天蝎座", "射手座"], + transitions: [ + { layer: "d9", at: "05:06", from_sign: "天秤座", to_sign: "天蝎座" }, + { layer: "d9", at: "05:07", from_sign: "天蝎座", to_sign: "射手座" }, + ], + }, + inference_state: { + algorithm_version: "rectification-inference-v1", + candidate_set_id: CURRENT_SET_ID, + revision: 1, + phase: "discrimination", + result_status: "discriminating", + range_start: "05:00", + range_end: "05:07", + candidates: [ + { id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 34, posterior_score: 34, probability: 0.34, status: "active", rank: 1, strong_conflict_count: 0 }, + { id: "05:06", time: "05:06", cluster_range: ["05:06", "05:06"], prior_score: 33, posterior_score: 33, probability: 0.33, status: "active", rank: 2, strong_conflict_count: 0 }, + { id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 33, posterior_score: 33, probability: 0.33, status: "active", rank: 3, strong_conflict_count: 0 }, + ], + events: [ + { id: "e-career-a", domain: "career", year: 2020, precision: "month", usage: "training" }, + { id: "e-career-b", domain: "career", year: 2020, precision: "month", usage: "training" }, + { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day", usage: "training" }, + { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month", usage: "holdout" }, + ], + answered_probes: [], + probes: [{ + id: `contrast:${D9_STALE_KEY}`, + semantic_key: D9_STALE_KEY, + // minted one scoring round earlier, so the prefix names the old set + candidate_split_hash: `${STALE_SET_ID}:${D9_STALE_KEY}`, + domain: "relationship", + year: 0, + question: "亲密关系里更接近下面哪一种相处方式?", + candidate_ids: ["05:00", "05:06", "05:07"], + information_gain: 1.53, + source: "varga_contrast", + choice_kind: "varga_style", + style_options: D9_STYLE_OPTIONS, + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] }, + { answer_class: "weak_yes", supports: ["05:06"], conflicts: ["05:00", "05:07"] }, + { answer_class: "no", supports: ["05:07"], conflicts: ["05:00", "05:06"] }, + ], + }], + rounds: [], + entropy: 1.5, + representative_time: "05:00", + credible_range: ["05:00", "05:07"], + }, + }, + }, + case: { acceptedTime: null }, + turns: [], + }; +} + +test("GET still renders the card when the probe hash carries an older candidate set prefix", () => { + const card = choiceCardFromCaseDossier( + staleSplitDossier(`${STALE_SET_ID}:${D9_STALE_KEY}`) as never, + ); + assert.ok(card, "a stale candidate-set prefix must not silence the persisted card"); + assert.equal(card.probe_id, `contrast:${D9_STALE_KEY}`); + assert.equal(card.question_id, `probe:${D9_STALE_KEY}`); +}); + +test("GET keeps hiding the card when the persisted split belongs to another probe", () => { + const card = choiceCardFromCaseDossier( + staleSplitDossier(`${CURRENT_SET_ID}:varga.d10.05:00|05:06/05:07`) as never, + ); + assert.equal(card, null); +});