From 1c0ee8db139e3841708a9bc11f525f63c386a4ae Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 27 Aug 2026 00:28:14 +0800 Subject: [PATCH] fix(rectification): reject synthetic stale probes --- docs/BUG_HISTORY.md | 16 ++++ .../components/rectification-choice-card.tsx | 2 +- .../v9/inference-adapter.ts | 7 ++ .../v9/method-followup.ts | 74 +------------------ .../rectification-agentic/v9/server-focus.ts | 8 +- .../tests/rectification-agentic-entry.test.ts | 4 + .../tests/rectification-choice-card.test.ts | 64 ++++++++++++++++ .../tests/rectification-eight-method.test.ts | 14 ++-- .../tests/rectification-server-focus.test.ts | 64 +++++++++++++++- 9 files changed, 166 insertions(+), 87 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6a7daeca..b4cf930c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 +- 修复版本:待发布 diff --git a/frontend/src/components/rectification-choice-card.tsx b/frontend/src/components/rectification-choice-card.tsx index f4666159..555870a9 100644 --- a/frontend/src/components/rectification-choice-card.tsx +++ b/frontend/src/components/rectification-choice-card.tsx @@ -42,7 +42,7 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) { aria-label={props.card.scoring ? "校正判断" : "盘外核对"} >
- {props.card.prompt} + {props.card.prompt} {props.card.why ?

{props.card.why}

: null}
{primary.map((option, index) => ( diff --git a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts index 4d9c1e81..35a26169 100644 --- a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts +++ b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts @@ -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}`, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 23b2df3e..2eeec19c 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -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, - askedKeys: ReadonlySet = 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], diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index ee802c9e..42068e4c 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -47,14 +47,12 @@ function schemaProbeId(schema: Readonly> | 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): { diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 1c2afe63..5ce8e266 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -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, /\{props\.card\.prompt\}<\/legend>/); +}); + test("adopted time offers a consultation handoff without unique-minute copy", () => { assert.match(chat, /用这个时间看盘/); assert.match(chat, /onStartConsultation/); diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index 77f51f34..1a803f15 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -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); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 2223dd1b..9195b117 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -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", () => { diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index 6ec76c49..7fea0718 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -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", }], }, },