diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0af32f7a..f422491d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6187,6 +6187,7 @@ - 相关记录:BUG-367、BUG-398、BUG-400、BUG-401、BUG-403、BUG-404 - 复发自:BUG-403(动态四选项合同未贯穿 Python/排序)、BUG-404(Focus 与口语问题仍可分裂) - 修复版本:10.0.13 +- 后续复发:BUG-407 ## BUG-406 | 点选题干只在无障碍 legend 里,卡片上看不到 @@ -6204,3 +6205,19 @@ - 复发自:BUG-401(卡片隐藏题干)叠加 BUG-405(正文不再复述题干) - 修复版本:待发布 +## BUG-407 | 区分题目录被快照投影饿死,低分职业题压过已评分的高分 D24 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:生时纠正 GET 点选卡、Mastra 工具读路径、`POST /api/rectification/agent`、contrast packet、method-followup +- 用户现象:训练事件已经够、推理层已有高信息量 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。 +- 防复发:出题目录必须来自推理探针加引擎探针,不得只吃 Python 事件探针或快照投影时刻。Adopt/展示投影 fail-closed 不得饿死出题。已打开的低分区分卡不得挡住更高分目录赢家。 +- 相关记录:BUG-405、BUG-406 +- 复发自:BUG-405(排序公式对,目录被投影饿死,已打开低分卡锁题) +- 修复版本:待发布 + diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index ae826733..fe81a81e 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -21,16 +21,15 @@ import { resolveSessionLanguageModel } from "@/lib/model-catalog"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin"; -import { previousInferenceFromReceipt, askedDiscriminatorKeys } from "@/lib/rectification-agentic/v9/inference-adapter"; +import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter"; import { parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card"; import { classifyRectificationTurnIntent, optionIdForAnswerClass, } from "@/lib/rectification-agentic/v9/turn-intent-classifier"; -import { decideFromDossier, contrastPacketFromDossier } from "@/lib/rectification-agentic/v9/decision-from-dossier"; +import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus } from "@/lib/rectification-agentic/v9/server-focus"; import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup"; -import { refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet"; export const runtime = "nodejs"; export const maxDuration = 240; @@ -383,17 +382,15 @@ export async function POST(request: Request) { } else { const decision = decideFromDossier(dossier); if (decision.nextAction === "ask_candidate_discriminator") { - const refinement = refinementFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null); + const catalog = rectificationFollowupCatalog( + dossier.latestResult, + dossier.evidence, + ); const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "discriminate_candidates", - eventProbes: refinement.discriminating_event_probes, - askedProbeKeys: askedDiscriminatorKeys( - dossier.latestResult?.decisionReceipt, - dossier.evidence, - ), - contrastPacket: contrastPacketFromDossier(dossier), + ...catalog, candidatesSeparated: false, }); const persisted = await persistServerOwnedFocus({ 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 7e42d427..9fd0ad0f 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -80,8 +80,11 @@ export type EngineContrastProbe = Readonly<{ information_gain?: number; expected_outcomes?: readonly Readonly<{ answer_class?: string; + outcomeId?: string; supports?: readonly string[]; + supportsCandidateIds?: readonly string[]; conflicts?: readonly string[]; + conflictsCandidateIds?: readonly string[]; }>[]; left_time?: string; right_time?: string; @@ -312,11 +315,12 @@ export function buildCandidateContrastPacket(input: { candidateTimes: input.candidateTimes ?? [], transitions: input.transitions ?? [], }); + const presentKeys = new Set(fromEngine.map((item) => item.semanticKey)); const fromVarga = vargaProbe( remainingSplits, input.candidateSetVersion, input.calculationResultId ?? null, - asked, + new Set([...asked, ...presentKeys]), ); const probes = [...fromEngine, ...(fromVarga ? [fromVarga] : [])] .sort((left, right) => right.informationGain - left.informationGain); @@ -515,15 +519,30 @@ export function conflictProbesFromContrast( }); } +function inferredContrastChoiceKind( + semanticKey: string, + explicit?: ContrastChoiceKind, +): ContrastChoiceKind { + if (explicit === "varga_style" || explicit === "event_quality" || explicit === "existence") { + return explicit; + } + const layer = semanticKey.match(/^varga\.(d\d+)/)?.[1]; + if (layer === "d9" || layer === "d10") return "varga_style"; + if (layer === "d24" || layer === "d5") return "event_quality"; + return "existence"; +} + function probeFromEngine( probe: EngineContrastProbe, candidateSetVersion: string, calculationResultId: string | null, ): CandidateDiscriminatorProbe | null { const outcomes = (probe.expected_outcomes ?? []).flatMap((row) => { - const outcomeId = typeof row.answer_class === "string" ? row.answer_class : ""; - const supports = row.supports ?? []; - const conflicts = row.conflicts ?? []; + const outcomeId = typeof row.answer_class === "string" && row.answer_class.trim() + ? row.answer_class + : typeof row.outcomeId === "string" ? row.outcomeId : ""; + const supports = row.supports ?? row.supportsCandidateIds ?? []; + const conflicts = row.conflicts ?? row.conflictsCandidateIds ?? []; if (!outcomeId) return []; return [{ outcomeId, supportsCandidateIds: supports, conflictsCandidateIds: conflicts }]; }); @@ -553,7 +572,7 @@ function probeFromEngine( domain: probe.domain ?? null, year: probe.year ?? null, semanticKey, - choiceKind: probe.choice_kind, + choiceKind: inferredContrastChoiceKind(semanticKey, probe.choice_kind), styleOptions: styleOptionsFromEngine(probe.style_options), }; } 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 e3613a09..f430afd5 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -10,6 +10,7 @@ import { selectDiscriminatorProbe, volunteeredDomainsFromEvidence, type CandidateContrastPacket, + type EngineContrastProbe, } from "../core/candidate-contrast-packet.ts"; import { decideRectification, @@ -84,6 +85,22 @@ export function candidateScoresFromDossier(latest: DecisionDossier["latestResult return authoritativeCandidateProjection(latest).scores; } +const CLOCK_TIME = /^(?:[01]\d|2[0-3]):[0-5]\d$/; + +export function discriminatorCandidateTimes( + latest: DecisionDossier["latestResult"], +): string[] { + const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null); + const fromInference = [...new Set( + (inference?.candidates ?? []) + .filter((item) => item.status !== "eliminated") + .map((item) => item.time) + .filter((time) => CLOCK_TIME.test(time)), + )]; + if (fromInference.length >= 2) return fromInference; + return candidateScoresFromDossier(latest).map((item) => item.time); +} + function holdoutStatusFromInference(inference: ReturnType) { if (!inference) return "unavailable" as const; const hasHoldout = inference.events.some((item) => item.usage === "holdout"); @@ -105,15 +122,47 @@ function holdoutStatusFromState(state: InferenceState) { return "unavailable" as const; } -export function contrastPacketFromDossier(dossier: DecisionDossier): CandidateContrastPacket { - const windowScan = windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null); - const refinement = refinementFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null); - const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); - const candidateScores = candidateScoresFromDossier(dossier.latestResult); +export function contrastPacketFromLatestResult( + latest: DecisionDossier["latestResult"], + evidence: DecisionDossier["evidence"] = [], +): CandidateContrastPacket { + const windowScan = windowScanFromDecisionReceipt(latest?.decisionReceipt ?? null); + const refinement = refinementFromDecisionReceipt(latest?.decisionReceipt ?? null); + const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null); + const answered = new Set((inference?.answered_probes ?? []).map((item) => item.id)); + const fromInference: EngineContrastProbe[] = (inference?.probes ?? []).flatMap((probe) => { + if (answered.has(probe.id) || probe.information_gain <= 0) return []; + if (probe.source === "known_event_quality") return []; + return [{ + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + domain: probe.domain, + year: probe.year > 0 ? probe.year : undefined, + user_meaning: probe.question, + information_gain: probe.information_gain, + expected_outcomes: probe.expected_outcomes, + candidate_ids: probe.candidate_ids, + }]; + }); + const merged = mergeEngineProbes( + fromInference, + refinement.discriminating_event_probes.map((probe) => ({ + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + domain: probe.domain, + year: probe.year, + user_meaning: probe.user_meaning, + information_gain: probe.information_gain, + expected_outcomes: probe.expected_outcomes, + candidate_ids: probe.candidate_ids, + choice_kind: probe.choice_kind, + style_options: probe.style_options, + })), + ); return buildCandidateContrastPacket({ - candidateSetVersion: inference?.candidate_set_id ?? dossier.latestResult?.resultId ?? "none", - calculationResultId: dossier.latestResult?.resultId ?? null, - engineProbes: refinement.discriminating_event_probes, + candidateSetVersion: inference?.candidate_set_id ?? latest?.resultId ?? "none", + calculationResultId: latest?.resultId ?? null, + engineProbes: merged, vargaDifferences: [ ...(windowScan?.d9_candidates_differ && windowScan.d9_sign_names.length >= 2 ? [{ layer: "d9", signs: windowScan.d9_sign_names }] @@ -122,16 +171,57 @@ export function contrastPacketFromDossier(dossier: DecisionDossier): CandidateCo ? [{ layer: "d10", signs: windowScan.d10_sign_names }] : []), ], - candidateTimes: candidateScores.map((item) => item.time), + candidateTimes: discriminatorCandidateTimes(latest), transitions: windowScan?.transitions ?? [], - askedKeys: askedDiscriminatorKeys( - dossier.latestResult?.decisionReceipt, - dossier.evidence, - ), - volunteeredDomains: volunteeredDomainsFromEvidence(dossier.evidence), + askedKeys: askedDiscriminatorKeys(latest?.decisionReceipt, evidence), + volunteeredDomains: volunteeredDomainsFromEvidence(evidence), }); } +function mergeEngineProbes( + ...groups: ReadonlyArray +): EngineContrastProbe[] { + const byKey = new Map(); + for (const group of groups) { + for (const probe of group ?? []) { + const key = probe.semantic_key?.trim() ?? ""; + if (!key) continue; + const current = byKey.get(key); + if (!current || (probe.information_gain ?? 0) > (current.information_gain ?? 0)) { + byKey.set(key, probe); + } + } + } + return [...byKey.values()]; +} + +export function contrastPacketFromDossier(dossier: DecisionDossier): CandidateContrastPacket { + return contrastPacketFromLatestResult(dossier.latestResult, dossier.evidence); +} + +export function rectificationFollowupCatalog( + latest: DecisionDossier["latestResult"], + evidence: DecisionDossier["evidence"] = [], +) { + const receipt = latest?.decisionReceipt ?? null; + const refinement = refinementFromDecisionReceipt(receipt); + const inference = previousInferenceFromReceipt(receipt); + return { + contrastPacket: contrastPacketFromLatestResult(latest, evidence), + topCandidateTimes: discriminatorCandidateTimes(latest), + askedProbeKeys: askedDiscriminatorKeys(receipt, evidence), + eventProbes: refinement.discriminating_event_probes, + eventClarificationProbes: refinement.event_clarification_probes, + evidenceCollectionProbes: refinement.evidence_collection_probes, + precisionStage: refinement.precision_stage?.current ?? null, + nakshatraBoundary: refinement.nakshatra_boundary, + oosBlindPrompts: refinement.oos_blind_prompts, + holdoutEvents: (inference?.events ?? []) + .filter((item) => item.usage === "holdout") + .map((item) => ({ domain: item.domain, year: item.year })), + }; +} + function contrastPacketFromState(state: InferenceState): CandidateContrastPacket { const answered = new Set(state.answered_probes.map((item) => item.probe_id)); return buildCandidateContrastPacket({ diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts index de64ae0a..5b2fafcb 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -6,15 +6,13 @@ * Card identity is the persisted focus UUID plus the inference revision. */ -import { askedKeysFromLedgerEvidence } from "../core/candidate-contrast-packet.ts"; -import { askedProbeKeysFromReceipt, previousInferenceFromReceipt } from "./inference-adapter"; +import { previousInferenceFromReceipt } from "./inference-adapter"; import { - contrastPacketFromDossier, decideFromDossier, + rectificationFollowupCatalog, } from "./decision-from-dossier"; import { evidenceLedgerFingerprint } from "./tool-service"; import { projectRectificationChoiceCard } from "./method-followup"; -import { refinementFromDecisionReceipt } from "./refinement-packet"; import { internalObservationsFromWindowScan, windowScanFromDecisionReceipt, @@ -62,11 +60,11 @@ export function choiceCardFromCaseDossier(dossier: { }; turns?: readonly Readonly<{ role: string; text: string | null }>[]; }): RectificationChoiceCard | null { - const windowScan = windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null); - const observations = internalObservationsFromWindowScan(windowScan); - const refinement = refinementFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null); + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); + const observations = internalObservationsFromWindowScan( + windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null), + ); const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); - const contrastPacket = contrastPacketFromDossier(dossier); const decision = decideFromDossier(dossier, { currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never), }); @@ -74,35 +72,23 @@ export function choiceCardFromCaseDossier(dossier: { .reverse() .find((turn) => turn.role === "assistant") ?.text ?? null; - const holdoutEvents = (inference?.events ?? []) - .filter((item) => item.usage === "holdout") - .map((item) => ({ domain: item.domain, year: item.year })); return projectRectificationChoiceCard({ evidence: dossier.evidence, activeFocus: dossier.conversationSummary.activeFocus, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, observations, sessionOutcome: decision.sessionOutcome, + ...catalog, precisionStage: decision.precisionStage === "collect_events" ? "collect_events" : decision.precisionStage === "ready_to_adopt" ? "ready_to_adopt" - : refinement.precision_stage?.current, - nakshatraBoundary: refinement.nakshatra_boundary, - oosBlindPrompts: refinement.oos_blind_prompts, - eventProbes: refinement.discriminating_event_probes, - eventClarificationProbes: refinement.event_clarification_probes, - evidenceCollectionProbes: refinement.evidence_collection_probes, - askedProbeKeys: [ - ...askedProbeKeysFromReceipt(dossier.latestResult?.decisionReceipt), - ...askedKeysFromLedgerEvidence(dossier.evidence), - ], + : catalog.precisionStage, accepted: Boolean(dossier.case.acceptedTime), selectionAllowed: decision.selectionAllowed, proposeAllowed: decision.proposeAllowed, confirmationAllowed: decision.canConfirmExactMinute, caseRevision: inference?.revision ?? 0, - contrastPacket, candidateScores: decision.separation.ranked.map((item) => ({ time: item.time, score: item.score, @@ -111,7 +97,6 @@ export function choiceCardFromCaseDossier(dossier: { latestAssistantText, candidatesSeparated: decision.separation.sufficient, holdoutValidation: decision.holdoutValidation, - holdoutEvents, }); } diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 7fc80550..71addedc 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -36,6 +36,8 @@ * Coverage complete never means adopt. Horary does not block cards. * A/B/C/D choice frames attach only when candidates already diverge * (event probes, precision stage, varga observation, nakshatra, or holdout). + * An already-open distinguish card yields if the live catalog winner is a + * different probe. Do not keep a low-gain Python event card over D24. */ import { @@ -499,9 +501,19 @@ function followupOwnedProbe( }; } +function persistedFocusProbeKey(focus: MethodFollowupFocus | null | undefined): string { + const key = focus?.expectedAnswerSchema?.semantic_key; + return typeof key === "string" && key.trim() ? key.trim() : ""; +} + +function rankedDiscriminatorKey(row: RankedDiscriminator | null): string { + if (!row) return ""; + return row.eventProbe?.semantic_key ?? row.contrastProbe?.semanticKey ?? ""; +} + function rankRenderableDiscriminators(input: { eventProbes: readonly DiscriminatingEventProbe[]; - contrastProbe: CandidateDiscriminatorProbe | null; + contrastProbes: readonly CandidateDiscriminatorProbe[]; askedKeys: ReadonlySet; topCandidateTimes?: readonly string[]; }): RankedDiscriminator[] { @@ -520,7 +532,9 @@ function rankRenderableDiscriminators(input: { for (const probe of input.eventProbes) { push(renderableEventProbe(probe, input.askedKeys, top)); } - push(input.contrastProbe ? renderableContrastProbe(input.contrastProbe, input.askedKeys, top) : null); + for (const probe of input.contrastProbes) { + 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)); } @@ -811,6 +825,7 @@ export function buildMethodFollowupPlan(input: { accepted?: boolean; candidatesSeparated?: boolean; contrastPacket?: CandidateContrastPacket | null; + topCandidateTimes?: readonly string[]; holdoutValidation?: HoldoutValidationStatus; holdoutEvents?: readonly Readonly<{ domain: string; year: number | null }>[]; }): MethodFollowupPlan { @@ -879,7 +894,7 @@ export function buildMethodFollowupPlan(input: { const sessionOutcome = input.sessionOutcome ?? "collect_evidence"; const candidatesSeparated = input.candidatesSeparated === true; - const contrastProbe = selectDiscriminatorProbe(input.contrastPacket ?? null); + const contrastProbes = candidatesSeparated ? [] : [...(input.contrastPacket?.probes ?? [])]; // 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; @@ -887,6 +902,20 @@ export function buildMethodFollowupPlan(input: { focus && (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"), ); const coverageComplete = blockingMethodsCovered(methods); + const askedKeys = new Set([ + ...(input.askedProbeKeys ?? []), + ...askedKeysFromLedgerEvidence(input.evidence), + ]); + const rankedDiscriminators = dashaCovered && meetsAcceptanceEventQuality(input.evidence) + ? rankRenderableDiscriminators({ + eventProbes: remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys), + contrastProbes, + askedKeys, + topCandidateTimes: input.topCandidateTimes, + }) + : []; + const bestDiscriminator = rankedDiscriminators[0] ?? null; + const catalogWinnerKey = rankedDiscriminatorKey(bestDiscriminator); const staleCollectFocus = Boolean( focus && focus.intent === "collect_method_evidence" @@ -898,9 +927,17 @@ export function buildMethodFollowupPlan(input: { || (focus.targetDomain === "horary" && horaryStatus !== "uncovered") ), ); + const staleDiscriminatorFocus = Boolean( + focus + && focus.intent === "distinguish_candidates" + && catalogWinnerKey + && persistedFocusProbeKey(focus) + && persistedFocusProbeKey(focus) !== catalogWinnerKey + ); if ( focus && !staleCollectFocus + && !staleDiscriminatorFocus && (sessionOutcome !== "adopt_representative" && sessionOutcome !== "validated_range" && sessionOutcome !== "exact_minute_confirmed" @@ -1018,18 +1055,6 @@ export function buildMethodFollowupPlan(input: { let next: MethodFollowup | null = null; const stage = input.precisionStage ?? null; - const askedKeys = new Set([ - ...(input.askedProbeKeys ?? []), - ...askedKeysFromLedgerEvidence(input.evidence), - ]); - const rankedDiscriminators = dashaCovered && meetsAcceptanceEventQuality(input.evidence) - ? rankRenderableDiscriminators({ - eventProbes: remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys), - contrastProbe: !candidatesSeparated ? contrastProbe : null, - askedKeys, - }) - : []; - const bestDiscriminator = rankedDiscriminators[0] ?? null; const followupFromRanked = (ranked: RankedDiscriminator): MethodFollowup => { if (ranked.kind === "event" && ranked.eventProbe) { const conflictProbe = ranked.eventProbe; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 2c864fb6..ed4cded1 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -87,6 +87,10 @@ import { resolveEvidenceQuote, } from "@/lib/rectification-agentic/v9/evidence-quote"; import { projectTurnDecision } from "@/lib/rectification-agentic/v9/turn-decision"; +import { + contrastPacketFromLatestResult, + rectificationFollowupCatalog, +} from "@/lib/rectification-agentic/v9/decision-from-dossier"; import { QUESTION_CONTRACT_VERSION } from "@/lib/rectification-agentic/v9/probe-question-contract"; import { posteriorMap, @@ -174,43 +178,17 @@ function holdoutStatusFromLatest(latest: NonNullable item.usage === "holdout"); if (!hasHoldout) return "unavailable"; if (inference.holdout_passed === true) return "passed"; - if (inference.holdout_passed === false || inference.result_status === "validation_failed") return "failed"; + if (inference.holdout_passed === false || inference.result_status === "validation_failed") { + return "failed"; + } return "not_started"; } -function holdoutEventsFromLatest(latest: NonNullable | null | undefined) { - const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null); - return (inference?.events ?? []) - .filter((item) => item.usage === "holdout") - .map((item) => ({ domain: item.domain, year: item.year })); -} - function contrastPacketFromLatest( latest: NonNullable | null | undefined, evidence: DossierForTools["evidence"] = [], ) { - const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null); - const windowScan = windowScanFromDecisionReceipt(latest?.decisionReceipt ?? null); - const refinement = refinementFromDecisionReceipt(latest?.decisionReceipt ?? null); - const candidateTimes = candidateScoresFromLatest(latest).map((item) => item.time); - const vargaDifferences = [ - ...(windowScan?.d9_candidates_differ && windowScan.d9_sign_names.length >= 2 - ? [{ layer: "d9", signs: windowScan.d9_sign_names }] - : []), - ...(windowScan?.d10_candidates_differ && windowScan.d10_sign_names.length >= 2 - ? [{ layer: "d10", signs: windowScan.d10_sign_names }] - : []), - ]; - return buildCandidateContrastPacket({ - candidateSetVersion: inference?.candidate_set_id ?? latest?.resultId ?? "none", - calculationResultId: latest?.resultId ?? null, - engineProbes: refinement.discriminating_event_probes, - vargaDifferences, - candidateTimes, - transitions: windowScan?.transitions ?? [], - askedKeys: askedDiscriminatorKeys(latest?.decisionReceipt, evidence), - volunteeredDomains: volunteeredDomainsFromEvidence(evidence), - }); + return contrastPacketFromLatestResult(latest, evidence); } function snapshotSourceFromDossier( @@ -251,9 +229,9 @@ function safeCaseProjection( const latest = dossier.latestResult; const windowScan = windowScanFromDecisionReceipt(latest?.decisionReceipt ?? null); const observations = internalObservationsFromWindowScan(windowScan); - const refinement = refinementFromDecisionReceipt(latest?.decisionReceipt ?? null); + const catalog = rectificationFollowupCatalog(latest, dossier.evidence); + const contrastPacket = catalog.contrastPacket; const accepted = Boolean(caseRow.acceptedTime); - const contrastPacket = contrastPacketFromLatest(latest, dossier.evidence); const candidateScores = candidateScoresFromLatest(latest); const holdoutValidation = holdoutStatusFromLatest(latest); const separation = evaluateCandidateSeparation(candidateScores); @@ -268,19 +246,11 @@ function safeCaseProjection( declinedTopics: dossier.conversationSummary.declinedSkippedTopics, observations, sessionOutcome: "collect_evidence", - precisionStage: refinement.precision_stage?.current, - nakshatraBoundary: refinement.nakshatra_boundary, - oosBlindPrompts: refinement.oos_blind_prompts, - eventProbes: refinement.discriminating_event_probes, - eventClarificationProbes: refinement.event_clarification_probes, - evidenceCollectionProbes: refinement.evidence_collection_probes, - askedProbeKeys: askedDiscriminatorKeys(latest?.decisionReceipt, dossier.evidence), + ...catalog, birthDate: String(compute.baselineBirthSnapshot.birth_date ?? "") || null, accepted, candidatesSeparated: separation.sufficient, - contrastPacket, holdoutValidation, - holdoutEvents: holdoutEventsFromLatest(latest), }); const userStopped = dossier.case.status === "paused"; const confirmationGate = buildConfirmationGate({ @@ -324,17 +294,10 @@ function safeCaseProjection( declinedTopics: dossier.conversationSummary.declinedSkippedTopics, observations, sessionOutcome, - precisionStage: refinement.precision_stage?.current, - nakshatraBoundary: refinement.nakshatra_boundary, - oosBlindPrompts: refinement.oos_blind_prompts, - eventProbes: refinement.discriminating_event_probes, - eventClarificationProbes: refinement.event_clarification_probes, - evidenceCollectionProbes: refinement.evidence_collection_probes, - askedProbeKeys: askedDiscriminatorKeys(latest?.decisionReceipt, dossier.evidence), + ...catalog, birthDate: String(compute.baselineBirthSnapshot.birth_date ?? "") || null, accepted, candidatesSeparated: separation.sufficient, - contrastPacket, holdoutValidation, }); const birthContext = safeBirthContext(compute); @@ -630,8 +593,7 @@ function collectingFollowupForParsed( ) { const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt ?? null); const observations = internalObservationsFromWindowScan(windowScan); - const refinement = refinementFromDecisionReceipt(latest.decisionReceipt ?? null); - const contrastPacket = contrastPacketFromLatest(latest, parsed.evidence); + const catalog = rectificationFollowupCatalog(latest, parsed.evidence); const separation = evaluateCandidateSeparation(candidateScoresFromLatest(latest)); return buildMethodFollowupPlan({ evidence: parsed.evidence, @@ -639,18 +601,10 @@ function collectingFollowupForParsed( declinedTopics: parsed.conversationSummary.declinedSkippedTopics, observations, sessionOutcome: "collect_evidence", - precisionStage: refinement.precision_stage?.current, - nakshatraBoundary: refinement.nakshatra_boundary, - oosBlindPrompts: refinement.oos_blind_prompts, - eventProbes: refinement.discriminating_event_probes, - eventClarificationProbes: refinement.event_clarification_probes, - evidenceCollectionProbes: refinement.evidence_collection_probes, - askedProbeKeys: askedDiscriminatorKeys(latest.decisionReceipt, parsed.evidence), + ...catalog, accepted: Boolean(parsed.case.acceptedTime), candidatesSeparated: separation.sufficient, - contrastPacket, holdoutValidation: holdoutStatusFromLatest(latest), - holdoutEvents: holdoutEventsFromLatest(latest), }); } @@ -660,7 +614,8 @@ function sessionAwareFollowupForParsed( options?: { birthDate?: string | null; snapshotCurrent?: boolean }, ) { const collectingPlan = collectingFollowupForParsed(parsed, latest); - const contrastPacket = contrastPacketFromLatest(latest, parsed.evidence); + const catalog = rectificationFollowupCatalog(latest, parsed.evidence); + const contrastPacket = catalog.contrastPacket; const candidateScores = candidateScoresFromLatest(latest); const holdoutValidation = holdoutStatusFromLatest(latest); const sessionOutcome = conversationalSessionOutcome({ @@ -690,7 +645,6 @@ function sessionAwareFollowupForParsed( } const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt ?? null); const observations = internalObservationsFromWindowScan(windowScan); - const refinement = refinementFromDecisionReceipt(latest.decisionReceipt ?? null); const separation = evaluateCandidateSeparation(candidateScores); return { plan: buildMethodFollowupPlan({ @@ -699,19 +653,11 @@ function sessionAwareFollowupForParsed( declinedTopics: parsed.conversationSummary.declinedSkippedTopics, observations, sessionOutcome, - precisionStage: refinement.precision_stage?.current, - nakshatraBoundary: refinement.nakshatra_boundary, - oosBlindPrompts: refinement.oos_blind_prompts, - eventProbes: refinement.discriminating_event_probes, - eventClarificationProbes: refinement.event_clarification_probes, - evidenceCollectionProbes: refinement.evidence_collection_probes, - askedProbeKeys: askedDiscriminatorKeys(latest.decisionReceipt, parsed.evidence), + ...catalog, birthDate: options?.birthDate ?? null, accepted: Boolean(parsed.case.acceptedTime), candidatesSeparated: separation.sufficient, - contrastPacket, holdoutValidation, - holdoutEvents: holdoutEventsFromLatest(latest), }), contrastPacket, sessionOutcome, diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index fce15aa2..26d7152d 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -7,8 +7,9 @@ import { publicDecisionFields, } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts"; +import { selectDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { contrastPacketFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; -import { conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { buildMethodFollowupPlan, conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts"; const SEPARATED = [ { time: "04:48", score: 58 }, @@ -145,6 +146,158 @@ test("recorded education evidence does not suppress an unasked D24 discriminator assert.equal(packet.probes[0]?.expectedOutcomes.at(-1)?.outcomeId, "unsure"); }); +test("scored inference catalog outranks a low-gain Python career probe when snapshot candidates are empty", () => { + const careerOutcomes = [ + { answer_class: "yes", supports: ["04:45", "05:00", "05:14"], conflicts: ["05:15"] }, + { answer_class: "weak_yes", supports: ["04:45", "05:00", "05:14"], conflicts: ["05:15"] }, + { answer_class: "no", supports: ["05:15"], conflicts: ["04:45", "05:00", "05:14"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ]; + const d24Outcomes = [ + { answer_class: "yes", supports: ["04:47"], conflicts: ["04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15"] }, + { answer_class: "weak_yes", supports: ["04:51", "04:53"], conflicts: ["04:47", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15"] }, + { answer_class: "no", supports: ["04:59"], conflicts: ["04:47", "04:51", "04:53", "05:00", "05:07", "05:12", "05:14", "05:15"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ]; + const inferenceCandidates = [ + { id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 22, posterior_score: 22, probability: 0.51, status: "active", rank: 1, strong_conflict_count: 0 }, + { id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 17, posterior_score: 17, probability: 0.15, status: "active", rank: 2, strong_conflict_count: 0 }, + { id: "05:12", time: "05:12", cluster_range: ["05:12", "05:14"], prior_score: 17, posterior_score: 17, probability: 0.15, status: "equivalent", rank: 3, strong_conflict_count: 0 }, + { id: "05:14", time: "05:14", cluster_range: ["05:12", "05:14"], prior_score: 17, posterior_score: 17, probability: 0.15, status: "equivalent", rank: 4, strong_conflict_count: 0 }, + { id: "04:47", time: "04:47", cluster_range: ["04:47", "04:47"], prior_score: 6, posterior_score: 6, probability: 0.01, status: "active", rank: 5, strong_conflict_count: 0 }, + { id: "04:51", time: "04:51", cluster_range: ["04:51", "04:51"], prior_score: 6, posterior_score: 6, probability: 0.01, status: "active", rank: 6, strong_conflict_count: 0 }, + { id: "04:53", time: "04:53", cluster_range: ["04:53", "04:53"], prior_score: 6, posterior_score: 6, probability: 0.01, status: "active", rank: 7, strong_conflict_count: 0 }, + { id: "04:59", time: "04:59", cluster_range: ["04:59", "04:59"], prior_score: 6, posterior_score: 6, probability: 0.01, status: "active", rank: 8, strong_conflict_count: 0 }, + { id: "05:15", time: "05:15", cluster_range: ["05:15", "05:15"], prior_score: 3, posterior_score: 3, probability: 0.004, status: "active", rank: 9, strong_conflict_count: 0 }, + ]; + const evidence = [ + { status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" }, + { status: "confirmed", domain: "relationship", datePrecision: "day", occurredFrom: "2024-08-08", occurredTo: null, eventKind: "relationship_end" }, + { status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_entry" }, + { status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2018-01-01", occurredTo: null, eventKind: "family_event" }, + ]; + const dossier = { + evidence, + conversationSummary: { activeFocus: null, declinedSkippedTopics: [] }, + latestResult: { + resultId: "result-empty-snapshot", + candidates: [], + decisionReceipt: { + discriminating_event_probes: [{ + role: "distinguish", + phase: "candidate_discriminator", + year: 2023, + year_label: "2023 年前后", + domain: "career", + event_family: "入职、升职或职责明显加重", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: false, + unique_minute_claim: false, + user_meaning: "时间范围锁定 2023 年前后;领域锁定 career。", + choice_kind: "existence", + information_gain: 0.56, + semantic_key: "career.2023.dasha_activation", + candidate_split_hash: "500ce694938305201fbab9ba", + candidate_ids: ["04:45", "05:00", "05:14", "05:15"], + expected_outcomes: careerOutcomes, + style_options: [ + { label: "明确发生且时间吻合", answer_class: "yes" }, + { label: "发生过但程度较弱", answer_class: "weak_yes" }, + { label: "明确没有发生", answer_class: "no" }, + { label: "这段记不清楚", answer_class: "unsure" }, + ], + }], + inference_state: { + algorithm_version: "rectification-inference-v1", + candidate_set_id: "04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:07,05:12,05:14,05:15", + revision: 1, + phase: "discrimination", + result_status: "discriminating", + range_start: "04:45", + range_end: "05:15", + candidates: inferenceCandidates, + events: [], + probes: [{ + id: "probe:career.2023.dasha_activation:500ce694938305201fbab9ba", + year: 2023, + domain: "career", + source: "dasha_activation", + question: "时间范围锁定 2023 年前后;领域锁定 career。", + semantic_key: "career.2023.dasha_activation", + candidate_ids: ["04:45", "05:00", "05:14", "05:15"], + information_gain: 0.56, + expected_outcomes: careerOutcomes, + candidate_split_hash: "500ce694938305201fbab9ba", + }, { + id: "contrast:varga.d24.04:47/04:51|04:53/04:59/05:00/05:07|05:12/05:14|05:15", + year: 0, + domain: "education", + source: "varga_contrast", + question: "引擎给出的区分机会绑定 D24。", + semantic_key: "varga.d24.04:47/04:51|04:53/04:59/05:00/05:07|05:12/05:14|05:15", + candidate_ids: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15"], + information_gain: 2.503258334775646, + expected_outcomes: d24Outcomes, + candidate_split_hash: "04:45-05:15:varga.d24", + }], + answered_probes: [], + rounds: [], + entropy: 2.0, + representative_time: "05:00", + credible_range: ["05:00", "05:14"], + }, + window_scan: { + scanned: true, + d24_lagna_count: 6, + d24_candidates_differ: true, + transitions: [ + { layer: "d24", at: "04:48", from_sign: "白羊座", to_sign: "金牛座" }, + { layer: "d24", at: "04:54", from_sign: "金牛座", to_sign: "双子座" }, + { layer: "d24", at: "05:00", from_sign: "双子座", to_sign: "巨蟹座" }, + { layer: "d24", at: "05:06", from_sign: "巨蟹座", to_sign: "狮子座" }, + { layer: "d24", at: "05:13", from_sign: "狮子座", to_sign: "处女座" }, + ], + }, + }, + }, + case: { acceptedTime: null }, + }; + const packet = contrastPacketFromDossier(dossier); + const selected = selectDiscriminatorProbe(packet); + assert.match(selected?.semanticKey ?? "", /^varga\.d24\./); + assert.ok((selected?.informationGain ?? 0) > 2); + assert.doesNotMatch(selected?.semanticKey ?? "", /career\.2023/); + + const plan = buildMethodFollowupPlan({ + evidence, + eventProbes: packet.probes.flatMap((probe) => probe.semanticKey.startsWith("career.") + ? [{ + year: 2023, + year_label: "2023 年前后", + domain: "career", + event_family: "入职、升职或职责明显加重", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: false, + unique_minute_claim: false, + user_meaning: "时间范围锁定 2023 年前后。", + role: "distinguish", + phase: "candidate_discriminator", + information_gain: 0.56, + semantic_key: "career.2023.dasha_activation", + candidate_split_hash: "500ce694938305201fbab9ba", + candidate_ids: ["04:45", "05:00", "05:14", "05:15"], + expected_outcomes: careerOutcomes, + }] + : []), + contrastPacket: packet, + candidatesSeparated: false, + }); + assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d24\./); + assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /career\.2023/); +}); + test("public candidate cards follow the inference ranking and hide an inconsistent state", () => { const decision = decideRectification({ methodCoverageAll: true, diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 6805ed1e..1e9ccf25 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -1825,6 +1825,52 @@ test("low-gain career event probe does not outrank a renderable high-gain D24 co assert.ok((plan.next_followup?.selection_score ?? 0) > 0.56); }); +test("already-open low-gain career card yields to the high-gain D24 catalog winner", () => { + const plan = buildMethodFollowupPlan({ + evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"), + eventProbes: [{ + ...CAREER_CONFLICT_PROBE, + year: 2023, + year_label: "2023 年前后", + semantic_key: "career.2023.dasha_activation", + information_gain: 0.56, + candidate_split_hash: "set-test:career:2023", + }], + contrastPacket: { + candidateSetVersion: "05:00-05:14", + vargaDifferences: [], + probes: [{ + probeId: "contrast:varga.d24.05:00/05:07|05:10|05:14", + candidateSetVersion: "05:00-05:14", + question: "当前几个候选在学业盘上还分得开。", + expectedOutcomes: [ + { outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:07", "05:10", "05:14"] }, + { outcomeId: "no", supportsCandidateIds: ["05:07", "05:10", "05:14"], conflictsCandidateIds: ["05:00"] }, + ], + candidateSplitHash: "varga.d24.05:00/05:07|05:10|05:14", + informationGain: 2.503258, + sourceFeatures: [{ technique: "D24", calculationResultId: RESULT_ID }], + domain: "education", + year: null, + semanticKey: "varga.d24.05:00/05:07|05:10|05:14", + choiceKind: "event_quality", + }], + }, + candidatesSeparated: false, + activeFocus: { + intent: "distinguish_candidates", + targetDomain: "career", + targetKind: "career_entry", + expectedAnswerSchema: { + semantic_key: "career.2023.dasha_activation", + candidate_split_hash: "set-test:career:2023", + }, + }, + }); + assert.equal(plan.next_followup?.semantic_key, "varga.d24.05:00/05:07|05:10|05:14"); + assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /career\.2023/); +}); + const DUMP_COVERAGE = [ { status: "confirmed" as const,