fix(rectification): reject synthetic stale probes
Independent Staging Quality Gate / validate (push) Successful in 13m3s
Independent Staging Quality Gate / publish (push) Successful in 16m3s

This commit is contained in:
Jesse_Chen
2026-08-27 00:28:14 +08:00
parent 7d6b88c886
commit 1c0ee8db13
9 changed files with 166 additions and 87 deletions
+16
View File
@@ -6107,3 +6107,19 @@
- 相关记录:BUG-391、BUG-392
- 复发自:BUG-391
- 修复版本:待发布
## BUG-401 | 零信息增益质量卡伪造 probe,点选永远 stale 且一条线索就打断采集
- 状态:resolved(本地修复,待发布)
- 首次发现:2026-08-26
- 最近更新:2026-08-26
- 影响面:`method_followup_plan`、`stampChoiceSchemaWithProbe`、`persistServerOwnedFocus`、生时纠正 A/B/C/D 卡片
- 用户现象:只记入一条「2016 年 9 月上大学」后就出现高考发挥选择题;题干在 Agent 正文和卡片重复,点击任一选项返回 `stale_probe`。
- 触发条件:账本只有一条学业事件;引擎 receipt 生成 `known_event_quality`,但该条目 `information_gain=0`、`candidate_ids=[]`、`expected_outcomes=[]`,且没有进入 `inference_state.probes`。
- 根因:BUG-389 让 `known_event_quality` 在 3 条训练事件 / 2 个领域门槛之前插队;`stampChoiceSchemaWithProbe` 在已有 inference state 却找不到匹配探针时仍合成 `probe_id`。持久化 focus 因而引用不存在的探针,点选链路按正确的 fail-closed 校验返回 `stale_probe`。卡片又可视化重复渲染 Agent 已问出的题干。
- 修复:删除采用门前的零增益质量卡分支;所有 scoring focus 统一要求正信息增益、真实候选映射和同一 inference probe,匹配失败不再合成 identity、也不持久化。读取旧案例时忽略遗留 `clarify_event` focus 并继续收集其他领域事件。卡片 legend 保留给无障碍读取,但视觉上只显示 A/B/C/D,题干由 Agent 正文展示。
- 验证:6 个定向套件 152/152;`npm run lint` 0 error(17 条既有 warning);`next build --webpack` 完整通过。默认 Turbopack 在隔离 worktree 因 `node_modules` 指向工作区外的符号链接而报环境错误,非源码错误。
- 防复发:scoring choice schema 在已有 inference state 时必须绑定真实 probe;零增益或没有候选映射的条目不得成为点选题;一条事件后继续跨领域收集,题干不得在正文与卡片重复。
- 相关记录:BUG-389、BUG-390、BUG-398、BUG-400
- 复发自:BUG-389
- 修复版本:待发布
@@ -42,7 +42,7 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
aria-label={props.card.scoring ? "校正判断" : "盘外核对"}
>
<fieldset className="birth-time-choice-question" disabled={props.pending || props.disabled || Boolean(selectedKey)}>
<legend>{props.card.prompt}</legend>
<legend className="sr-only">{props.card.prompt}</legend>
{props.card.why ? <p className="rectification-choice-why">{props.card.why}</p> : null}
<div className="birth-time-primary-choices">
{primary.map((option, index) => (
@@ -255,6 +255,13 @@ export function stampChoiceSchemaWithProbe(
probe_id: preferredId,
})) ?? null;
if (preferredKey && !matched) {
if (state) {
const unstamped = { ...schema };
delete unstamped.probe_id;
delete unstamped.semantic_key;
delete unstamped.candidate_split_hash;
return { ...unstamped, scoring };
}
return {
...schema,
probe_id: preferredId ?? `probe:${preferredKey}`,
@@ -337,49 +337,6 @@ function remainingReverseVerifyProbes(
return [...dasha, ...fallback].slice(0, MAX_REVERSE_VERIFY);
}
const QUALITY_ENCODED_RE = /失利|失常|复读|没考好|考砸|发挥不好|发挥失常|发挥异常|压力很大/;
function qualityAlreadyEncoded(
evidence: readonly MethodFollowupEvidence[],
domain: string,
year: number,
): boolean {
const nearby = existenceNearbyYears(domain);
return evidence.some((item) => {
if (item.status !== "confirmed" && item.status !== "draft" && item.status !== "pending_confirmation") {
return false;
}
if (item.domain !== domain) return false;
const itemYear = evidenceYear(item);
if (itemYear === null) return false;
if (Math.abs(itemYear - year) > nearby) return false;
return QUALITY_ENCODED_RE.test(item.summary ?? "");
});
}
function remainingQualityProbes(
probes: readonly DiscriminatingEventProbe[] | undefined,
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
askedKeys: ReadonlySet<string> = new Set(),
): DiscriminatingEventProbe[] {
const rows: DiscriminatingEventProbe[] = [];
for (const probe of probes ?? []) {
if (probe.source !== "known_event_quality") continue;
if (probe.domain !== "education") continue;
if (declined.has(probe.domain)) continue;
if (!probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue;
if (qualityAlreadyEncoded(evidence, probe.domain, probe.year)) continue;
const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
const split = probe.candidate_split_hash ?? "";
if (askedKeys.has(semantic) || (split && askedKeys.has(split))) continue;
rows.push(probe);
}
return rows
.sort((left, right) => (right.information_gain ?? 0) - (left.information_gain ?? 0))
.slice(0, MAX_REVERSE_VERIFY);
}
function remainingConflictProbes(
probes: readonly DiscriminatingEventProbe[] | undefined,
evidence: readonly MethodFollowupEvidence[],
@@ -767,7 +724,9 @@ 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;
// Legacy known-event quality cards were never backed by an inference probe.
// Ignore them so existing cases resume evidence collection instead of exposing a stale card.
const focus = input.activeFocus?.intent === "clarify_event" ? null : input.activeFocus ?? null;
const keepAcceptedFocus = Boolean(
focus && (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"),
);
@@ -907,14 +866,6 @@ export function buildMethodFollowupPlan(input: {
...(input.askedProbeKeys ?? []),
...askedKeysFromLedgerEvidence(input.evidence),
]);
const qualityProbe = dashaCovered
? remainingQualityProbes(
input.eventClarificationProbes ?? input.eventProbes,
input.evidence,
declined,
askedKeys,
)[0] ?? null
: null;
const conflictProbe = dashaCovered && meetsAcceptanceEventQuality(input.evidence)
? remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys)[0] ?? null
: null;
@@ -932,25 +883,6 @@ export function buildMethodFollowupPlan(input: {
),
source: "method_coverage",
});
} else if (qualityProbe) {
next = makeFollowup({
method_id: PROBE_METHOD_ID[qualityProbe.domain],
intent: "clarify_event",
ask_theme: REVERSE_VERIFY_THEME[qualityProbe.domain],
domain: qualityProbe.domain,
kind_hint: REVERSE_VERIFY_KIND[qualityProbe.domain],
user_prompt_hint: ask(
`已记下 ${qualityProbe.year_label} 的经历。按 choice_frame 问那次是否${qualityProbe.event_family}。对得上写入账本并重算;对不上关闭该问。不要发明年份。`,
REVERSE_VERIFY_VARGA[qualityProbe.domain],
),
source: "event_probe",
information_gain: qualityProbe.information_gain ?? 0,
semantic_key: qualityProbe.semantic_key ?? `${qualityProbe.domain}.${qualityProbe.year}`,
candidate_split_hash: qualityProbe.candidate_split_hash,
probe_year: qualityProbe.year,
choice_kind: qualityProbe.choice_kind ?? "event_quality",
style_options: qualityProbe.style_options,
}, true, true);
} else if (conflictProbe && (!coverageComplete || !candidatesSeparated || (conflictProbe.information_gain ?? 0) >= 0.08)) {
next = makeFollowup({
method_id: PROBE_METHOD_ID[conflictProbe.domain],
@@ -47,14 +47,12 @@ function schemaProbeId(schema: Readonly<Record<string, unknown>> | null | undefi
export function shouldSkipDiscriminatorFollowup(followup: MethodFollowup): PersistServerFocusStatus | null {
if (
followup.source === "event_probe"
&& followup.choice_kind !== "event_quality"
&& (followup.information_gain ?? 0) <= 0
) {
return "zero_information_gain";
}
if (
followup.intent === "distinguish_candidates"
&& followup.choice_kind !== "event_quality"
&& ((followup.candidate_ids?.length ?? 0) < 2 || (followup.expected_outcomes?.length ?? 0) < 2)
) {
return "zero_information_gain";
@@ -82,15 +80,17 @@ function expectedAnswerSchemaFor(
candidate_split_hash: followup.candidate_split_hash ?? null,
choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence",
};
return stampChoiceSchemaWithProbe(
const state = previousInferenceFromReceipt(decisionReceipt ?? null);
const stamped = stampChoiceSchemaWithProbe(
schema,
previousInferenceFromReceipt(decisionReceipt ?? null),
state,
questionId,
{
semantic_key: followup.semantic_key,
candidate_split_hash: followup.candidate_split_hash,
},
);
return state && stamped.scoring !== false && !schemaProbeId(stamped) ? null : stamped;
}
export function openQuestionFromPersistedFocus(result: PersistServerFocusResult): {
@@ -658,6 +658,10 @@ test("the Agent prompt cannot offer candidates while asking for more evidence",
assert.doesNotMatch(tools, /offer_selection/);
});
test("choice cards keep the question accessible without repeating it visually", () => {
assert.match(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
});
test("adopted time offers a consultation handoff without unique-minute copy", () => {
assert.match(chat, /用这个时间看盘/);
assert.match(chat, /onStartConsultation/);
@@ -310,6 +310,70 @@ test("GET card without a persisted focus UUID is not tappable", () => {
assert.equal(distinguish, null);
});
test("GET hides a legacy known-event quality focus and resumes evidence collection", () => {
const card = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
}],
eventClarificationProbes: [{
year: 2016,
year_label: "2016 年前后",
domain: "education",
event_family: "高考或重要考试发挥明显失常、压力很大",
source: "known_event_quality",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "已知事件质量",
role: "clarify",
phase: "event_clarification",
choice_kind: "event_quality",
semantic_key: "education.2016",
candidate_split_hash: "education:2016",
candidate_ids: [],
expected_outcomes: [],
information_gain: 0,
}],
activeFocus: {
id: FOCUS_ID,
intent: "clarify_event",
targetDomain: "education",
targetKind: null,
expectedAnswerSchema: {
choice: SAMPLE_COPY,
probe_id: "probe:education.2016",
semantic_key: "education.2016",
candidate_split_hash: "education:2016",
scoring: true,
choice_kind: "event_quality",
},
},
});
assert.equal(card, null);
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
}],
activeFocus: {
id: FOCUS_ID,
intent: "clarify_event",
targetDomain: "education",
targetKind: null,
expectedAnswerSchema: { choice: SAMPLE_COPY },
},
});
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
});
test("GET card stays hidden on an empty ledger even if the Agent already wrote choice copy", () => {
const withoutCopy = projectRectificationChoiceCard({ evidence: [] });
assert.equal(withoutCopy, null);
@@ -279,19 +279,15 @@ test("dasha conflict probe does not jump ahead of method rotation before accepta
assert.notEqual(plan.next_followup?.source, "event_probe");
});
test("known exam quality of a recorded year stamps a choice card before method rotation", () => {
test("known exam quality does not create a scoring card after one recorded event", () => {
const plan = buildMethodFollowupPlan({
evidence: [datedEvidence("education", "2016")],
eventProbes: [EDUCATION_QUALITY_PROBE, CAREER_CONFLICT_PROBE],
});
assert.equal(plan.next_followup?.source, "event_probe");
assert.equal(plan.next_followup?.choice_kind, "event_quality");
assert.equal(plan.next_followup?.intent, "clarify_event");
assert.equal(plan.next_followup?.domain, "education");
assert.equal(plan.next_followup?.choice_frame?.scoring, true);
assert.equal(plan.next_followup?.choice_frame?.period, "2016 年前后");
assert.notEqual(plan.next_followup?.domain, "career");
assert.notEqual(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
assert.notEqual(plan.next_followup?.choice_kind, "event_quality");
});
test("career known-event quality does not jump the adoption gate", () => {
@@ -97,16 +97,62 @@ test("zero information gain does not open a discriminator", () => {
);
});
test("recorded event quality still opens a card at zero information gain", () => {
test("recorded event quality cannot open a zero-information scoring card", () => {
assert.equal(
shouldSkipDiscriminatorFollowup(discriminatorFollowup({
choice_kind: "event_quality",
information_gain: 0,
})),
null,
"zero_information_gain",
);
});
test("an unmatched scoring identity is skipped instead of being synthesized", async () => {
const followup = discriminatorFollowup({
semantic_key: "education.synthetic",
candidate_split_hash: "education:synthetic",
});
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
throw new Error("must not persist an unmatched scoring focus");
},
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: {
inference_state: {
algorithm_version: "birth-time-event-scoring-v1",
candidates: [
{ id: "05:00", birth_time: "05:00", posterior: 0.5 },
{ id: "05:20", birth_time: "05:20", posterior: 0.5 },
],
answered_probes: [],
probes: [{
id: "p-live",
semantic_key: "education.live",
candidate_split_hash: "education:live",
domain: "education",
year: 2016,
question: "live",
candidate_ids: ["05:00", "05:20"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
information_gain: 0.2,
source: "dasha_activation",
}],
},
},
followup,
});
assert.equal(result.status, "skipped");
assert.equal(accounting.calls.length, 0);
});
test("duplicate focus conflict does not throw", async () => {
const followup = discriminatorFollowup({ source: "precision_stage", information_gain: 0.2 });
const accounting = fakeAccounting({
@@ -173,6 +219,20 @@ test("contrast probe is not replaced by an already-answered education quality pr
expected_outcomes: [],
information_gain: 0,
source: "known_event_quality",
}, {
id: "contrast:varga.d24.05:00/05:06|05:07",
semantic_key: "varga.d24.05:00/05:06|05:07",
candidate_split_hash: "varga.d24.05:00/05:06|05:07",
domain: "education",
year: 0,
question: "学业盘候选差异",
candidate_ids: ["05:00", "05:06", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] },
{ answer_class: "no", supports: ["05:06", "05:07"], conflicts: ["05:00"] },
],
information_gain: 0.16,
source: "varga_contrast",
}],
},
},