fix(rectification): stop a stale candidate-set prefix from hiding the choice card
Scoring scoped the contrast packet to the previous round's candidate set while minting probes for the new one, so every later read re-prefixed the stored hash and the persisted focus schema could never match. Mint one prefix per set and compare splits by probe identity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8
This commit is contained in:
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -667,6 +667,46 @@ function inferredContrastChoiceKind(
|
||||
return "existence";
|
||||
}
|
||||
|
||||
/**
|
||||
* Split hashes are `<candidate set id>:<semantic key>` for varga contrasts and
|
||||
* `<candidate set id>:<opaque engine hash>` 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
|
||||
* `<some candidate set id>:<the same semantic key>`. 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) => [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user