diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f422491d..198a4750 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6214,8 +6214,8 @@ - 用户现象:训练事件已经够、推理层已有高信息量 D24 探针,界面仍出低信息量事业存在题;题干修复后选题内容仍不对。 - 触发条件:快照 `latest_result.candidates` 为空,或可信区间与推理活跃时刻不一致导致 `authoritativeCandidateProjection` fail-closed;Python `discriminating_event_probes` 仍给出低分事业题;已打开的事业区分卡把后续排序锁在原题上。 - 根因:BUG-405 把排序公式改成按信息量取最高,但读路径组包不读 `inference_state.probes`,remaining D24 又依赖快照时刻。时刻被投影饿死后目录只剩职业题。评分落库用 `score.candidates` 能把 D24 写进推理层,GET/工具随后丢掉。已打开的低分 distinguish Focus 还在 method-followup 里优先于重新排序。 -- 修复:GET、Agent、工具共用一份 `rectificationFollowupCatalog`。合并未回答的推理探针与 Python 事件探针,按 `semantic_key` 去重留更高信息量。拼 remaining splits 用推理未淘汰时刻,不把 adopt 投影的空 `scores` 当成出题目录。`varga.d24`/`d5` 按质量题、`d9`/`d10` 按风格题补全。method-followup 对 packet 里全部 contrast 探针排序;已打开但 semantic_key 不是当前目录赢家的区分卡让位。评分落库仍用当次 `score.candidates`。Skill 版本保持 `10.0.13`。 -- 验证:`frontend/tests/rectification-decision-authority.test.ts` 空快照仍选出 D24;`frontend/tests/rectification-eight-method.test.ts` 低分事业题与已打开事业卡都不得压过可渲染 D24。 +- 修复:GET、Agent、工具共用一份 `rectificationFollowupCatalog`。合并未回答的推理探针与 Python 事件探针,按 `semantic_key` 去重留更高信息量。拼 remaining splits 用推理未淘汰时刻,不把 adopt 投影的空 `scores` 当成出题目录。已有日期证据的领域不再出存在题(例如已记事业就不再问另一年入职);D9/D10/D24 等分盘区分题仍进池,按信息量全局取最高,不绑定某个领域。`varga.d24`/`d5` 按质量题、`d9`/`d10` 按风格题补全。已打开但 semantic_key 不是当前目录赢家的区分卡让位。评分落库仍用当次 `score.candidates`。Skill 版本保持 `10.0.13`。 +- 验证:`frontend/tests/rectification-decision-authority.test.ts` 空快照仍选出目录最高分;`frontend/tests/rectification-eight-method.test.ts` 已覆盖领域的存在题让位,D10 与 D24 谁分高问谁。 - 防复发:出题目录必须来自推理探针加引擎探针,不得只吃 Python 事件探针或快照投影时刻。Adopt/展示投影 fail-closed 不得饿死出题。已打开的低分区分卡不得挡住更高分目录赢家。 - 相关记录:BUG-405、BUG-406 - 复发自:BUG-405(排序公式对,目录被投影饿死,已打开低分卡锁题) 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 9fd0ad0f..e33036cb 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -251,6 +251,24 @@ export function askedEventProbeKeysFromLedgerEvidence( return [...keys]; } +export function datedDomainsFromEvidence( + evidence: readonly Readonly<{ + status?: string | null; + domain?: string | null; + occurredFrom?: string | null; + occurredTo?: string | null; + }>[], +): string[] { + const domains = new Set(); + for (const item of evidence) { + if (item.status && !LIVE_EVIDENCE.has(item.status)) continue; + if (!item.domain) continue; + const dated = [item.occurredFrom, item.occurredTo].some((value) => /^\d{4}/.test(value ?? "")); + if (dated) domains.add(item.domain); + } + return [...domains]; +} + export function volunteeredDomainsFromEvidence( evidence: readonly Readonly<{ status?: string | null; @@ -286,18 +304,21 @@ export function buildCandidateContrastPacket(input: { transitions?: readonly WindowScanTransition[]; askedKeys?: readonly string[]; volunteeredDomains?: readonly string[]; + providedDomains?: readonly string[]; }): CandidateContrastPacket { const asked = new Set(input.askedKeys ?? []); + const provided = new Set(input.providedDomains ?? []); const fromEngine = (input.engineProbes ?? []).flatMap((probe) => { const built = probeFromEngine(probe, input.candidateSetVersion, input.calculationResultId ?? null); if (!built) return []; const eventKey = built.domain && built.year ? `${built.domain}.${built.year}` : null; - const isStructured = built.choiceKind === "varga_style" || built.semanticKey.startsWith("varga."); + const isStructured = isStructuredDiscriminator(built); if ( asked.has(built.semanticKey) || asked.has(built.candidateSplitHash) || asked.has(built.probeId) || (!isStructured && eventKey && asked.has(eventKey)) + || (!isStructured && built.domain && provided.has(built.domain)) ) { return []; } @@ -316,13 +337,13 @@ export function buildCandidateContrastPacket(input: { transitions: input.transitions ?? [], }); const presentKeys = new Set(fromEngine.map((item) => item.semanticKey)); - const fromVarga = vargaProbe( + const fromVarga = vargaProbes( remainingSplits, input.candidateSetVersion, input.calculationResultId ?? null, new Set([...asked, ...presentKeys]), ); - const probes = [...fromEngine, ...(fromVarga ? [fromVarga] : [])] + const probes = [...fromEngine, ...fromVarga] .sort((left, right) => right.informationGain - left.informationGain); return { candidateSetVersion: input.candidateSetVersion, @@ -596,15 +617,23 @@ function styleOptionsFromEngine( return parsed.length > 0 ? parsed : undefined; } -function vargaProbe( +export function isStructuredDiscriminator(probe: Pick): boolean { + return probe.choiceKind === "varga_style" + || probe.choiceKind === "event_quality" + || probe.semanticKey.startsWith("varga."); +} + +function vargaProbes( remainingSplits: readonly RemainingVargaSplit[], candidateSetVersion: string, calculationResultId: string | null, asked: ReadonlySet, -): CandidateDiscriminatorProbe | null { - const remaining = remainingSplits.find((item) => !vargaLayerAsked(asked, item.layer)); - if (!remaining) return null; - return vargaProbeFromRemaining(remaining, candidateSetVersion, calculationResultId); +): CandidateDiscriminatorProbe[] { + return remainingSplits.flatMap((item) => { + if (vargaLayerAsked(asked, item.layer)) return []; + const probe = vargaProbeFromRemaining(item, candidateSetVersion, calculationResultId); + return probe ? [probe] : []; + }); } function vargaLayerAsked(asked: ReadonlySet, layer: string): boolean { diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index f430afd5..c7332c04 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -7,6 +7,7 @@ import { buildCandidateContrastPacket, + datedDomainsFromEvidence, selectDiscriminatorProbe, volunteeredDomainsFromEvidence, type CandidateContrastPacket, @@ -175,6 +176,7 @@ export function contrastPacketFromLatestResult( transitions: windowScan?.transitions ?? [], askedKeys: askedDiscriminatorKeys(latest?.decisionReceipt, evidence), volunteeredDomains: volunteeredDomainsFromEvidence(evidence), + providedDomains: datedDomainsFromEvidence(evidence), }); } diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 71addedc..c76128d8 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -26,10 +26,11 @@ * Method coverage asks for dated events in natural language. * Known-event quality probes (exam went badly for a year already * in the ledger) stamp a choice card as soon as that year is - * recorded. Dasha conflict probes wait until training event - * quality (3 training events in 2 training domains; holdout - * excluded), then jump ahead of remaining method rotation and - * block offering time cards so the window can be filtered. + * recorded. Dasha existence probes skip a domain once that domain + * already has dated evidence. Remaining chart discriminators + * (D9/D10/D24 and other varga splits) stay in the pool and the + * highest information-gain renderable probe is asked next, with + * no preferred domain. * If holdout is already reserved but training is still short, * keep collecting a dated event instead of discriminating. * Once blocking methods are covered, move into candidate discrimination. @@ -52,6 +53,8 @@ import { overlayChoicePromptFromSpoken } from "./turn-narration.ts"; import { decideRectification, type HoldoutValidationStatus } from "../core/rectification-decision.ts"; import { askedKeysFromLedgerEvidence, + datedDomainsFromEvidence, + isStructuredDiscriminator, selectDiscriminatorProbe, type CandidateContrastPacket, type CandidateDiscriminatorProbe, @@ -203,6 +206,19 @@ function existenceNearbyYears(domain: string): number { return domain === "education" ? EDUCATION_EXISTENCE_NEARBY_YEARS : 0; } +function probeDomainAlreadyCovered( + evidence: readonly MethodFollowupEvidence[], + domain: string, +): boolean { + return evidence.some((item) => { + if (item.status !== "confirmed" && item.status !== "draft" && item.status !== "pending_confirmation") { + return false; + } + if (item.domain !== domain) return false; + return evidenceYear(item) !== null; + }); +} + function probeYearAlreadyCovered( evidence: readonly MethodFollowupEvidence[], domain: string, @@ -362,7 +378,7 @@ function remainingConflictProbes( if (probe.source === "known_event_quality" || probe.role === "clarify") continue; if (!isValidDistinguishProbe({ ...probe, role: "distinguish" })) continue; if (declined.has(probe.domain)) continue; - if (probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue; + if (probeDomainAlreadyCovered(evidence, probe.domain)) 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; @@ -516,8 +532,10 @@ function rankRenderableDiscriminators(input: { contrastProbes: readonly CandidateDiscriminatorProbe[]; askedKeys: ReadonlySet; topCandidateTimes?: readonly string[]; + providedDomains?: readonly string[]; }): RankedDiscriminator[] { const top = input.topCandidateTimes ?? []; + const provided = new Set(input.providedDomains ?? []); const rows: RankedDiscriminator[] = []; const seen = new Set(); const push = (row: RankedDiscriminator | null) => { @@ -533,6 +551,7 @@ function rankRenderableDiscriminators(input: { push(renderableEventProbe(probe, input.askedKeys, top)); } for (const probe of input.contrastProbes) { + if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) continue; push(renderableContrastProbe(probe, input.askedKeys, top)); } return rows.sort((left, right) => right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0)); @@ -912,6 +931,7 @@ export function buildMethodFollowupPlan(input: { contrastProbes, askedKeys, topCandidateTimes: input.topCandidateTimes, + providedDomains: datedDomainsFromEvidence(input.evidence), }) : []; const bestDiscriminator = rankedDiscriminators[0] ?? null; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index ed4cded1..c2703280 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -99,6 +99,7 @@ import { import { buildCandidateContrastPacket, conflictProbesFromContrast, + datedDomainsFromEvidence, selectDiscriminatorProbe, volunteeredDomainsFromEvidence, } from "@/lib/rectification-agentic/core/candidate-contrast-packet"; @@ -1002,6 +1003,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { parsed.evidence, ), volunteeredDomains: volunteeredDomainsFromEvidence(parsed.evidence), + providedDomains: datedDomainsFromEvidence(parsed.evidence), }); const inference = buildCaseInferenceState({ range: parsed.case.candidateRange, diff --git a/frontend/tests/rectification-candidate-contrast-packet.test.ts b/frontend/tests/rectification-candidate-contrast-packet.test.ts index d9291e12..3e4b61a3 100644 --- a/frontend/tests/rectification-candidate-contrast-packet.test.ts +++ b/frontend/tests/rectification-candidate-contrast-packet.test.ts @@ -24,6 +24,8 @@ test("remaining D10 three-way outranks a skewed D24 split", () => { assert.equal(probe.choiceKind, "varga_style"); assert.match(probe.semanticKey, /varga\.d10/); assert.doesNotMatch(probe.semanticKey, /varga\.d24/); + assert.ok(packet.probes.some((item) => item.semanticKey.startsWith("varga.d24."))); + assert.ok(packet.probes.some((item) => item.semanticKey.startsWith("varga.d10."))); assert.equal(probe.styleOptions?.length, 4); assert.equal(probe.expectedOutcomes.length, 4); assert.deepEqual(probe.expectedOutcomes.filter((row) => row.outcomeId !== "unsure").map((row) => row.supportsCandidateIds), [ @@ -83,3 +85,28 @@ test("finance remaining splits stay out unless volunteered", () => { ); assert.equal(shown[0]?.layer, "d2"); }); + +test("existence probes leave the catalog once that domain already has dated evidence", () => { + const packet = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:14", + engineProbes: [{ + semantic_key: "career.2023.dasha_activation", + domain: "career", + year: 2023, + user_meaning: "时间范围锁定 2023 年前后。", + information_gain: 0.56, + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:14"] }, + { answer_class: "no", supports: ["05:14"], conflicts: ["05:00"] }, + ], + }], + providedDomains: ["career"], + candidateTimes: ["05:00", "05:07", "05:14"], + transitions: [ + { layer: "d24", at: "05:07", from_sign: "白羊座", to_sign: "金牛座" }, + { layer: "d24", at: "05:14", from_sign: "金牛座", to_sign: "双子座" }, + ], + }); + assert.equal(packet.probes.some((item) => item.semanticKey.includes("career.2023")), false); + assert.match(selectDiscriminatorProbe(packet)?.semanticKey ?? "", /^varga\.d24\./); +}); diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 26d7152d..0134e839 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -265,6 +265,7 @@ test("scored inference catalog outranks a low-gain Python career probe when snap }; const packet = contrastPacketFromDossier(dossier); const selected = selectDiscriminatorProbe(packet); + assert.equal(packet.probes.some((probe) => probe.semanticKey.includes("career.2023")), false); assert.match(selected?.semanticKey ?? "", /^varga\.d24\./); assert.ok((selected?.informationGain ?? 0) > 2); assert.doesNotMatch(selected?.semanticKey ?? "", /career\.2023/); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 1e9ccf25..daa8c83f 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -1057,19 +1057,19 @@ test("evidence batch returns the persisted choice prompt as open_question", asyn discriminating_event_probes: [{ year: 2023, year_label: "2023 年前后", - domain: "career", - event_family: "入职、升职或职责明显加重", + domain: "relocation", + event_family: "搬家、离乡或长期异地", source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: false, unique_minute_claim: false, - user_meaning: "年份锁定 2023 年前后。事件家族:入职、升职或职责明显加重。", + user_meaning: "年份锁定 2023 年前后。事件家族:搬家、离乡或长期异地。", role: "distinguish", phase: "candidate_discriminator", information_gain: 1.09, - semantic_key: "career.2023.dasha_activation", + semantic_key: "relocation.2023.dasha_activation", candidate_set_version: "set-test", - candidate_split_hash: "set-test:career:2023", + candidate_split_hash: "set-test:relocation:2023", candidate_ids: ["04:50", "05:20"], expected_outcomes: [ { answer_class: "yes", supports: ["04:50"], conflicts: ["05:20"] }, @@ -1158,7 +1158,7 @@ test("evidence batch returns the persisted choice prompt as open_question", asyn const schema = setFocus?.args.p_expected_answer_schema as { choice?: { prompt?: string } } | undefined; assert.match(schema?.choice?.prompt ?? "", /2023 年前后/); assert.match(result.open_question?.prompt ?? "", /2023 年前后/); - assert.match(result.open_question?.prompt ?? "", /入职、升职或职责明显加重/); + assert.match(result.open_question?.prompt ?? "", /搬家、离乡或长期异地/); assert.doesNotMatch(result.open_question?.prompt ?? "", /高考/); } finally { restore(); @@ -1548,6 +1548,30 @@ test("confirmed relationship evidence skips generic D9 followups unless a real p const probed = buildMethodFollowupPlan({ evidence, precisionStage: "d9_refine", + contrastPacket: { + candidateSetVersion: "05:00-05:14", + vargaDifferences: [], + probes: [{ + probeId: "contrast:varga.d9.05:00|05:14", + candidateSetVersion: "05:00-05:14", + question: "当前几个候选在关系盘上还分得开。", + expectedOutcomes: [ + { outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:14"] }, + { outcomeId: "weak_yes", supportsCandidateIds: ["05:14"], conflictsCandidateIds: ["05:00"] }, + ], + candidateSplitHash: "varga.d9.05:00|05:14", + informationGain: 1.4, + sourceFeatures: [{ technique: "D9", calculationResultId: RESULT_ID }], + domain: "relationship", + year: null, + semanticKey: "varga.d9.05:00|05:14", + choiceKind: "varga_style", + styleOptions: [ + { label: "相处更主动热情", answerClass: "yes", sign: "白羊座" }, + { label: "相处更深刻占有", answerClass: "weak_yes", sign: "天蝎座" }, + ], + }], + }, eventProbes: [{ ...CAREER_CONFLICT_PROBE, year: 2021, @@ -1560,7 +1584,8 @@ test("confirmed relationship evidence skips generic D9 followups unless a real p }); assert.equal(probed.next_followup?.domain, "relationship"); assert.equal(probed.next_followup?.source, "event_probe"); - assert.equal(probed.next_followup?.semantic_key, "relationship.2021.dasha_activation"); + assert.equal(probed.next_followup?.semantic_key, "varga.d9.05:00|05:14"); + assert.doesNotMatch(probed.next_followup?.semantic_key ?? "", /relationship\.2021/); }); test("d9_refine after relationship still asks uncovered career first", () => { @@ -2065,22 +2090,88 @@ test("structured paused state ends evidence collection without parsing user copy }); -test("same domain different year still asks a conflict probe", () => { - const plan = buildMethodFollowupPlan({ - evidence: [ - datedEvidence("education", "2016"), - datedEvidence("relationship", "2018"), - datedEvidence("career", "2015"), - datedEvidence("family", "2023"), - ], +test("covered-domain existence probes are skipped; the highest remaining discriminator wins", () => { + const evidence = [ + datedEvidence("education", "2016"), + datedEvidence("relationship", "2018"), + datedEvidence("career", "2015"), + datedEvidence("family", "2023"), + ]; + const skipped = buildMethodFollowupPlan({ + evidence, eventProbes: [{ ...CAREER_CONFLICT_PROBE, - information_gain: 0.21, + information_gain: 0.56, semantic_key: "career.2018.dasha_activation", }], }); - assert.equal(plan.next_followup?.source, "event_probe"); - assert.equal(plan.next_followup?.domain, "career"); + assert.notEqual(skipped.next_followup?.domain, "career"); + assert.notEqual(skipped.next_followup?.source, "event_probe"); + + const d24 = { + probeId: "contrast:varga.d24.05:00|05:07|05:14", + candidateSetVersion: "05:00-05:14", + question: "当前几个候选在学业盘上还分得开。", + expectedOutcomes: [ + { outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:07", "05:14"] }, + { outcomeId: "no", supportsCandidateIds: ["05:07", "05:14"], conflictsCandidateIds: ["05:00"] }, + ], + candidateSplitHash: "varga.d24.05:00|05:07|05:14", + informationGain: 2.5, + sourceFeatures: [{ technique: "D24", calculationResultId: RESULT_ID }], + domain: "education", + year: null, + semanticKey: "varga.d24.05:00|05:07|05:14", + choiceKind: "event_quality" as const, + }; + const d10 = { + probeId: "contrast:varga.d10.05:00|05:07|05:14", + candidateSetVersion: "05:00-05:14", + question: "当前几个候选在事业盘上还分得开。", + expectedOutcomes: [ + { outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:07", "05:14"] }, + { outcomeId: "weak_yes", supportsCandidateIds: ["05:07"], conflictsCandidateIds: ["05:00", "05:14"] }, + { outcomeId: "no", supportsCandidateIds: ["05:14"], conflictsCandidateIds: ["05:00", "05:07"] }, + ], + candidateSplitHash: "varga.d10.05:00|05:07|05:14", + informationGain: 3.1, + sourceFeatures: [{ technique: "D10", calculationResultId: RESULT_ID }], + domain: "career", + year: null, + semanticKey: "varga.d10.05:00|05:07|05:14", + choiceKind: "varga_style" as const, + styleOptions: [ + { label: "做事偏领导推进", answerClass: "yes" as const, sign: "白羊座" }, + { label: "做事偏研究转化", answerClass: "weak_yes" as const, sign: "天蝎座" }, + ], + }; + const highest = buildMethodFollowupPlan({ + evidence, + eventProbes: [{ + ...CAREER_CONFLICT_PROBE, + information_gain: 0.56, + semantic_key: "career.2018.dasha_activation", + }], + contrastPacket: { + candidateSetVersion: "05:00-05:14", + vargaDifferences: [], + probes: [d24, d10], + }, + candidatesSeparated: false, + }); + assert.equal(highest.next_followup?.semantic_key, d10.semanticKey); + assert.doesNotMatch(highest.next_followup?.semantic_key ?? "", /career\.2018/); + + const d24Wins = buildMethodFollowupPlan({ + evidence, + contrastPacket: { + candidateSetVersion: "05:00-05:14", + vargaDifferences: [], + probes: [d24, { ...d10, informationGain: 1.1 }], + }, + candidatesSeparated: false, + }); + assert.equal(d24Wins.next_followup?.semantic_key, d24.semanticKey); }); test("three dated events with one holdout keep collecting instead of discriminating", () => {