fix(rectification): reject synthetic stale probes
This commit is contained in:
@@ -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",
|
||||
}],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user