diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d5309fbd..add93f12 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6170,3 +6170,21 @@ - 相关记录:BUG-367、BUG-398、BUG-400、BUG-401、BUG-402、BUG-403 - 复发自:BUG-367(结构化点选旁路)、BUG-400(正文与卡片题干分离)、BUG-403(局部否定仍依赖 Agent 工具) - 修复版本:10.0.13(待 staging 发布) +- 后续复发:BUG-405 + +## BUG-405 | 区分探针问题合同未统一,低信息量事件题压过可渲染高信息量分盘题 + +- 状态:resolved(代码修复完成,staging 发布待确认) +- 首次发现:2026-08-27 +- 最近更新:2026-08-27 +- 影响面:生时纠正候选区分探针、点选题四选项合同、方法追问排序、active Focus 持久化、Agent 可见投影、`POST /api/rectification/agent` +- 用户现象:候选比较阶段出现无法点选的自由文本问题,或先问信息量很低的事业存在题,而更高信息量的 D24 等分盘区分机会被跳过;点选「没有」或自然语言回答无法更新后验,流程退回泛问经历。 +- 触发条件:Python 区分探针只给出 yes/no 结局、不带完整 `style_options`;TypeScript 曾用测试夹具补四选项,生产路径却 fail-closed 不出卡;`method-followup` 在冲突事件探针与 contrast 探针之间按 if/else 分支优先,而不是按信息量全局排序;`ask_candidate_discriminator` 且没有合法 active Focus 时仍可能进入完整 Agent。 +- 根因:问题合同、可渲染性、探针排序和 Focus 持久化是四套规则。BUG-403/BUG-404 要求动态四选项,但 Python 从未发出完整 `style_options`,存在题无法服务端补全;冲突事件探针因分支顺序可以压过更高 `information_gain` 的 contrast 探针;Agent 仍能看见原始 `candidate_contrast_packet` / `current_probe`,并在没有卡片时把区分题说出来。 +- 修复:新增跨运行时 `probe-question-v1` 合同。存在题/质量题可由服务端补全为 `yes / weak_yes / no / unsure`;分盘风格标签保持动态,并补 `都不是这些特质` 与 `这段记不清楚`。只有 `isRenderableProbe` 通过的探针进入排序;冲突事件探针与 contrast 探针按 `information_gain × novelty × coverage - penalty` 全局取最高。先持久化 Focus 再暴露问题;compare-candidates 对 Agent 隐藏原始探针包。缺少合法 Focus 时先修复或收口到可信区间,不进入完整 Agent;流结束后若仍是 discriminator 且没有合法 Focus,记 `state_invariant_failed`。Skill 版本保持 `10.0.13`。 +- 验证:`frontend/tests/rectification-probe-question-contract.test.ts`、choice-card / server-focus / contrast-packet / eight-method 排序回归、turn-decision 隐藏 `current_probe`、route 在 `runV9AgentTurn` 前修复 Focus、Python `tests/test_probe_question_contract.py` 与 `tests/test_rectification_event_probes.py` 四选项合同。 +- 防复发:区分题必须同时满足四选项合同、可渲染、全局信息量排序、Focus 先于提问。不得恢复冲突探针优先于 contrast 的分支;不得把原始探针包交给 Agent;不得在 `current_question=null` 时把 discriminator 当成成功口语轮次。 +- 相关记录:BUG-367、BUG-398、BUG-400、BUG-401、BUG-403、BUG-404 +- 复发自:BUG-403(动态四选项合同未贯穿 Python/排序)、BUG-404(Focus 与口语问题仍可分裂) +- 修复版本:10.0.13 + diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 58f7e3e4..ae826733 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -21,12 +21,16 @@ 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 } from "@/lib/rectification-agentic/v9/inference-adapter"; +import { previousInferenceFromReceipt, askedDiscriminatorKeys } 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 { 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; @@ -308,11 +312,8 @@ export async function POST(request: Request) { try { const dossier = await loadV9CaseDossier(accounting, userId, caseId); const focus = dossier.conversationSummary.activeFocus; - if (focus) { - const choice = parseAgentChoiceCopy(focus.expectedAnswerSchema); - if (!choice) { - return completedMessageResponse("当前问题已更新,请刷新后重新作答。", requestId, caseId); - } + const choice = focus ? parseAgentChoiceCopy(focus.expectedAnswerSchema) : null; + if (focus && choice) { let classified = null; try { classified = await classifyRectificationTurnIntent(selectedModel, { @@ -379,6 +380,81 @@ export async function POST(request: Request) { await transitionV9CaseStatus(accounting, userId, caseId, "paused"); return completedMessageResponse(applied.narration, requestId, caseId); } + } else { + const decision = decideFromDossier(dossier); + if (decision.nextAction === "ask_candidate_discriminator") { + const refinement = refinementFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null); + 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), + candidatesSeparated: false, + }); + const persisted = await persistServerOwnedFocus({ + accounting, + userId, + caseId, + activeFocus: focus, + decisionReceipt: dossier.latestResult?.decisionReceipt, + followup: plan.next_followup, + }); + const open = openQuestionFromPersistedFocus(persisted); + if (open && persisted.focus) { + let classified = null; + try { + classified = await classifyRectificationTurnIntent(selectedModel, { + focus: persisted.focus, + userMessage: parsed.data.message ?? "", + caseStatus, + signal: request.signal, + }); + } catch { + classified = null; + } + if (classified?.intent === "answer_current_focus" && classified.answer_class) { + const optionId = optionIdForAnswerClass(persisted.focus, classified.answer_class); + if (optionId) { + const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + const applied = await applyRectificationChoice(accounting, { + userId, + caseId, + sessionId, + actionId: requestId, + action: CHOICE_ACTION, + focusId: persisted.focus.id, + questionId: persisted.focus.questionId, + probeId: typeof persisted.focus.expectedAnswerSchema.probe_id === "string" + ? persisted.focus.expectedAnswerSchema.probe_id + : null, + optionId, + expectedRevision: previous?.revision ?? 0, + userDisplay: parsed.data.message ?? null, + }); + return completedMessageResponse(applied.narration, requestId, caseId); + } + } + const narration = "请点选下面的选项作答。点选和一句话回答会按同一规则计入当前区分题。"; + await persistV9DeterministicTurn(accounting, userId, caseId, { + requestId, + userMessage: parsed.data.message ?? null, + assistantMessage: narration, + }); + return completedMessageResponse(narration, requestId, caseId); + } + const narration = "目前没有可继续区分的问题。当前几个候选构成可信区间,不再泛问已经覆盖过的经历。"; + await persistV9DeterministicTurn(accounting, userId, caseId, { + requestId, + userMessage: parsed.data.message ?? null, + assistantMessage: narration, + }); + return completedMessageResponse(narration, requestId, caseId); + } } } catch (error) { if (error instanceof RectificationToolServiceError) { 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 ff86e256..7e42d427 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -5,6 +5,11 @@ import type { AnswerClass, ConflictProbe } from "./types.ts"; import { d9StyleLabel, d10StyleLabel } from "../v9/varga-type-tables.ts"; +import { + completeStyleOptions, + isRenderableProbe, + rankDiscriminatorScore, +} from "../v9/probe-question-contract.ts"; export type ContrastChoiceKind = "existence" | "varga_style" | "event_quality"; @@ -342,15 +347,134 @@ function vargaDifferencesForPacket(input: { export function selectDiscriminatorProbe( packet: CandidateContrastPacket | null | undefined, + options?: { askedKeys?: readonly string[]; topCandidateTimes?: readonly string[] }, ): CandidateDiscriminatorProbe | null { - const ranked = (packet?.probes ?? []).filter((probe) => { - const ids = new Set(probe.expectedOutcomes.flatMap((row) => [ + const asked = new Set(options?.askedKeys ?? []); + const ranked = (packet?.probes ?? []).flatMap((probe) => { + const completed = withCompletedContrastOptions(probe); + if (!completed) return []; + const ids = [...new Set(completed.expectedOutcomes.flatMap((row) => [ ...row.supportsCandidateIds, ...row.conflictsCandidateIds, - ])); - return probe.expectedOutcomes.length >= 2 && probe.informationGain > 0 && ids.size >= 2; + ]))]; + if (!isRenderableProbe({ + informationGain: completed.informationGain, + candidateIds: ids, + expectedOutcomeCount: completed.expectedOutcomes.length, + choiceKind: completed.choiceKind, + styleOptions: completed.styleOptions, + })) return []; + const askedAlready = asked.has(completed.semanticKey) + || asked.has(completed.candidateSplitHash) + || asked.has(completed.probeId); + return [{ + probe: completed, + score: rankDiscriminatorScore({ + informationGain: completed.informationGain, + asked: askedAlready, + candidateIds: ids, + topCandidateTimes: options?.topCandidateTimes, + }), + }]; + }).sort((left, right) => right.score - left.score || right.probe.informationGain - left.probe.informationGain); + return ranked[0]?.probe ?? null; +} + +function withCompletedContrastOptions( + probe: CandidateDiscriminatorProbe, +): CandidateDiscriminatorProbe | null { + const mapped = probe.styleOptions?.map((item) => ({ + label: item.label, + answer_class: item.answerClass, + ...(item.sign ? { sign: item.sign } : {}), + })) ?? []; + const incoming = mapped.length >= 2 ? mapped : [...mapped, ...inferredVargaStyleIncoming(probe)]; + const choiceKind = effectiveContrastChoiceKind({ + ...probe, + styleOptions: incoming.map((item) => ({ + label: item.label, + answerClass: item.answer_class as AnswerClass, + ...(item.sign ? { sign: item.sign } : {}), + })), }); - return ranked[0] ?? null; + const styleOptions = completeStyleOptions({ + choiceKind, + styleOptions: incoming, + }); + if (!styleOptions) return null; + const outcomes = withUnsureOutcome(probe.expectedOutcomes); + return { + ...probe, + choiceKind, + expectedOutcomes: outcomes, + styleOptions: styleOptions.map((item) => ({ + label: item.label, + answerClass: item.answer_class, + ...(item.sign ? { sign: item.sign } : {}), + })), + }; +} + +function inferredVargaStyleIncoming( + probe: CandidateDiscriminatorProbe, +): Array<{ label: string; answer_class: AnswerClass; sign?: string }> { + const parsed = signsFromVargaProbe(probe); + if (!parsed) return []; + const labelFor = parsed.layer === "d9" ? d9StyleLabel : d10StyleLabel; + const classes = ["yes", "weak_yes", "no"] as const; + return parsed.signs.slice(0, 3).flatMap((sign, index) => { + const label = labelFor(sign); + const answerClass = classes[index]; + if (!label || !answerClass) return []; + return [{ label, answer_class: answerClass, sign }]; + }); +} + +function signsFromVargaProbe( + probe: CandidateDiscriminatorProbe, +): { layer: "d9" | "d10"; signs: string[] } | null { + const match = probe.semanticKey.match(/^varga\.(d9|d10)\.(.+)$/); + const layer = match?.[1] === "d9" || match?.[1] === "d10" ? match[1] : null; + const fromKey = match?.[2] + ?.split(/[|/]/) + .map((item) => item.trim()) + .filter((item) => item && !/^\d{1,2}:\d{2}$/.test(item)) + ?? []; + const fromOutcomes = probe.expectedOutcomes.flatMap((row) => { + const token = row.outcomeId.replace(/^supports_/, "").trim(); + return token && !/^\d{1,2}:\d{2}$/.test(token) ? [token] : []; + }); + const signs = (fromKey.length >= 2 ? fromKey : fromOutcomes).slice(0, 3); + if (!layer || signs.length < 2) return null; + return { layer, signs }; +} + +function effectiveContrastChoiceKind(probe: CandidateDiscriminatorProbe): ContrastChoiceKind { + const key = probe.semanticKey; + if (probe.choiceKind === "varga_style" && (probe.styleOptions?.length ?? 0) < 2) { + if (key.startsWith("varga.d24") || key.startsWith("varga.d5")) return "event_quality"; + if (key.startsWith("varga.d9") || key.startsWith("varga.d10")) return "varga_style"; + return "existence"; + } + if (probe.choiceKind === "varga_style" || probe.choiceKind === "event_quality" || probe.choiceKind === "existence") { + return probe.choiceKind; + } + if (key.startsWith("varga.d24") || key.startsWith("varga.d5")) return "event_quality"; + if (key.startsWith("varga.d9") || key.startsWith("varga.d10")) return "varga_style"; + return "existence"; +} + +function withUnsureOutcome( + outcomes: readonly ContrastExpectedOutcome[], +): readonly ContrastExpectedOutcome[] { + const rows = [...outcomes]; + if (!rows.some((row) => row.outcomeId === "unsure")) { + rows.push({ outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] }); + } + if (!rows.some((row) => row.outcomeId === "no") && rows.some((row) => row.outcomeId === "weak_yes")) { + rows.push({ outcomeId: "no", supportsCandidateIds: [], conflictsCandidateIds: [] }); + } + return rows; } export function conflictProbesFromContrast( @@ -513,15 +637,22 @@ function remainingStyleOptions( split: RemainingVargaSplit, kind: ContrastChoiceKind, ): readonly ContrastStyleOption[] | undefined { - if (kind !== "varga_style") return undefined; - const classes = ["yes", "weak_yes", "no"] as const; - const options = split.groups.slice(0, 3).flatMap((group, index) => { - const sign = split.signs[index]; - if (!sign) return []; - const label = split.layer === "d10" ? d10StyleLabel(sign) : d9StyleLabel(sign); - return [{ label, answerClass: classes[index] ?? "unsure", sign }]; - }); - return options.length >= 2 ? uniquifyStyleLabels(options) : undefined; + const incoming = kind === "varga_style" + ? split.groups.slice(0, 3).flatMap((group, index) => { + const sign = split.signs[index]; + if (!sign) return []; + const label = split.layer === "d10" ? d10StyleLabel(sign) : d9StyleLabel(sign); + const classes = ["yes", "weak_yes", "no"] as const; + return [{ label, answer_class: classes[index] ?? "unsure", sign }]; + }) + : []; + const completed = completeStyleOptions({ choiceKind: kind, styleOptions: incoming }); + if (!completed) return undefined; + return uniquifyStyleLabels(completed.map((item) => ({ + label: item.label, + answerClass: item.answer_class, + ...(item.sign ? { sign: item.sign } : {}), + }))); } function uniquifyStyleLabels( @@ -558,24 +689,31 @@ function remainingOutcomes( allMinutes: readonly string[], kind: ContrastChoiceKind, ): ContrastExpectedOutcome[] { + let rows: ContrastExpectedOutcome[]; if (kind === "varga_style" && groups.length === 2) { - return [ + rows = [ { outcomeId: "yes", supportsCandidateIds: groups[0], conflictsCandidateIds: groups[1] }, { outcomeId: "weak_yes", supportsCandidateIds: groups[1], conflictsCandidateIds: groups[0] }, + { outcomeId: "no", supportsCandidateIds: [], conflictsCandidateIds: [] }, { outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] }, ]; - } - if (groups.length === 2) { - return [ + } else if (groups.length === 2) { + rows = [ { outcomeId: "yes", supportsCandidateIds: groups[0], conflictsCandidateIds: groups[1] }, { outcomeId: "weak_yes", supportsCandidateIds: groups[0], conflictsCandidateIds: groups[1] }, { outcomeId: "no", supportsCandidateIds: groups[1], conflictsCandidateIds: groups[0] }, + { outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] }, ]; + } else { + const classes = ["yes", "weak_yes", "no"] as const; + rows = groups.slice(0, 3).map((group, index) => ({ + outcomeId: classes[index] ?? `group_${index}`, + supportsCandidateIds: group, + conflictsCandidateIds: allMinutes.filter((time) => !group.includes(time)), + })); + if (!rows.some((row) => row.outcomeId === "unsure")) { + rows.push({ outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] }); + } } - const classes = ["yes", "weak_yes", "no"] as const; - return groups.slice(0, 3).map((group, index) => ({ - outcomeId: classes[index] ?? `group_${index}`, - supportsCandidateIds: group, - conflictsCandidateIds: allMinutes.filter((time) => !group.includes(time)), - })); + return rows; } diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 6475b3be..47d14d59 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -26,6 +26,8 @@ import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "./case-st import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt"; import { agentGenerationSettings } from "../../agent-generation-settings.ts"; import { toAgentModelFinishReason } from "../../agent-observability.ts"; +import { decideFromDossier } from "./decision-from-dossier"; +import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card"; import { resolveExactSkillPackage, type ResolvedSkillPackageIdentity, @@ -785,6 +787,25 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise => { + try { + const latest = await loadV9CaseDossier(accounting, userId, caseId); + const decision = decideFromDossier(latest); + if (decision.nextAction !== "ask_candidate_discriminator") return { ok: true }; + const focus = latest.conversationSummary.activeFocus; + if ( + focus + && isPersistedFocusId(focus.id) + && parseAgentChoiceCopy(focus.expectedAnswerSchema) + ) { + return { ok: true }; + } + return { ok: false, errorCode: "state_invariant_failed" }; + } catch { + return { ok: false, errorCode: "state_invariant_failed" }; + } + }; + const completeAttempt = async (): Promise => { let inputTokens = 0; let outputTokens = 0; @@ -848,7 +869,11 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise max) return null; - if (FORBIDDEN_CHOICE_COPY.test(text)) return null; - return text; + return clippedProbeLabel(value, min, max); } function isAnswerClass(value: unknown): value is AnswerClass { diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index daef13ff..7fc80550 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -55,14 +55,22 @@ import { type CandidateDiscriminatorProbe, } from "../core/candidate-contrast-packet.ts"; import { candidateIdsFromProbe, isValidDistinguishProbe } from "../core/distinguish-contract.ts"; +import { + completeStyleOptions, + isRenderableProbe, + rankDiscriminatorScore, +} from "./probe-question-contract.ts"; import type { SessionOutcomeKind } from "./confirmation-gate.ts"; import { meetsAcceptanceEventQuality, trainingScoreableGate } from "./evidence-model"; import type { DiscriminatingEventProbe, + EventProbeDomain, + EventProbeStyleOption, NakshatraBoundary, OosBlindPrompt, PrecisionStageId, } from "./refinement-packet"; +import { EVENT_PROBE_DOMAINS } from "./refinement-packet"; import type { InternalVargaObservation } from "./varga-observations"; @@ -108,6 +116,8 @@ export type MethodFollowup = Readonly<{ answer_class: string; sign?: string; }>[]; + selection_score?: number; + probe_id?: string; }>; export type MethodFollowupPlan = Readonly<{ @@ -361,6 +371,159 @@ function remainingConflictProbes( .slice(0, MAX_REVERSE_VERIFY); } +type RankedDiscriminator = Readonly<{ + kind: "event" | "contrast"; + score: number; + eventProbe?: DiscriminatingEventProbe; + contrastProbe?: CandidateDiscriminatorProbe; + styleOptions: NonNullable>; +}>; + +function renderableEventProbe( + probe: DiscriminatingEventProbe, + askedKeys: ReadonlySet, + topCandidateTimes: readonly string[], +): RankedDiscriminator | null { + const candidateIds = probe.candidate_ids ?? candidateIdsFromProbe(probe); + const styleOptions = completeStyleOptions({ + choiceKind: probe.choice_kind, + styleOptions: probe.style_options, + }); + if (!styleOptions || !isValidDistinguishProbe({ ...probe, role: "distinguish" })) return null; + if (!isRenderableProbe({ + informationGain: probe.information_gain, + candidateIds, + expectedOutcomeCount: probe.expected_outcomes?.length, + choiceKind: probe.choice_kind, + styleOptions, + })) return null; + const key = probe.semantic_key ?? `${probe.domain}.${probe.year}`; + const asked = askedKeys.has(key) || Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash)); + return { + kind: "event", + eventProbe: probe, + styleOptions, + score: rankDiscriminatorScore({ + informationGain: probe.information_gain ?? 0, + asked, + candidateIds, + topCandidateTimes, + }), + }; +} + +function renderableContrastProbe( + probe: CandidateDiscriminatorProbe, + askedKeys: ReadonlySet, + topCandidateTimes: readonly string[], +): RankedDiscriminator | null { + const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [ + ...row.supportsCandidateIds, + ...row.conflictsCandidateIds, + ]))]; + const styleOptions = completeStyleOptions({ + choiceKind: probe.choiceKind, + styleOptions: probe.styleOptions?.map((item) => ({ + label: item.label, + answer_class: item.answerClass, + ...(item.sign ? { sign: item.sign } : {}), + })), + }); + if (!styleOptions || !isRenderableProbe({ + informationGain: probe.informationGain, + candidateIds, + expectedOutcomeCount: probe.expectedOutcomes.length, + choiceKind: probe.choiceKind, + styleOptions, + })) return null; + const asked = askedKeys.has(probe.semanticKey) + || askedKeys.has(probe.candidateSplitHash) + || askedKeys.has(probe.probeId); + return { + kind: "contrast", + contrastProbe: probe, + styleOptions, + score: rankDiscriminatorScore({ + informationGain: probe.informationGain, + asked, + candidateIds, + topCandidateTimes, + }), + }; +} + +function followupEventFamily(domain: string, kind: string): string { + if (kind === "event_quality") return "学业、考试发挥或学习压力出现明显变化"; + if (kind === "varga_style") { + return domain === "relationship" ? "相处方式更接近其中一种" : "做事风格更接近其中一种"; + } + return "这段经历是否发生过"; +} + +function followupOwnedProbe( + item: Omit, +): DiscriminatingEventProbe | null { + if (!item.style_options?.length) return null; + if (!item.domain || !EVENT_PROBE_DOMAINS.includes(item.domain as EventProbeDomain)) return null; + const kind = item.choice_kind ?? "existence"; + const styleOptions: EventProbeStyleOption[] = []; + for (const row of item.style_options) { + const answer = row.answer_class; + if (answer !== "yes" && answer !== "weak_yes" && answer !== "no" && answer !== "unsure") return null; + styleOptions.push({ + label: row.label, + answer_class: answer, + ...(row.sign ? { sign: row.sign } : {}), + }); + } + if (styleOptions.length !== 4) return null; + return { + year: item.probe_year ?? 0, + year_label: item.probe_year ? `${item.probe_year} 年前后` : "当前这几个候选", + domain: item.domain as EventProbeDomain, + event_family: followupEventFamily(item.domain, kind), + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: item.user_prompt_hint, + role: "distinguish", + phase: "candidate_discriminator", + information_gain: item.information_gain, + semantic_key: item.semantic_key, + candidate_split_hash: item.candidate_split_hash, + candidate_ids: item.candidate_ids, + expected_outcomes: item.expected_outcomes, + choice_kind: kind, + style_options: styleOptions, + }; +} + +function rankRenderableDiscriminators(input: { + eventProbes: readonly DiscriminatingEventProbe[]; + contrastProbe: CandidateDiscriminatorProbe | null; + askedKeys: ReadonlySet; + topCandidateTimes?: readonly string[]; +}): RankedDiscriminator[] { + const top = input.topCandidateTimes ?? []; + const rows: RankedDiscriminator[] = []; + const seen = new Set(); + const push = (row: RankedDiscriminator | null) => { + if (!row) return; + const key = row.eventProbe?.semantic_key + ?? row.contrastProbe?.semanticKey + ?? ""; + if (!key || seen.has(key)) return; + seen.add(key); + rows.push(row); + }; + for (const probe of input.eventProbes) { + push(renderableEventProbe(probe, input.askedKeys, top)); + } + push(input.contrastProbe ? renderableContrastProbe(input.contrastProbe, input.askedKeys, top) : null); + 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)); +} + function coverage( methodId: MethodFollowupId, status: MethodCoverageStatus, @@ -658,6 +821,12 @@ export function buildMethodFollowupPlan(input: { ): MethodFollowup => { const base = { ...item, must_not_label: false as const }; const attach = forceChoice ?? shouldAttachChoiceFrame(base, input.evidence); + const keyed = Boolean(base.semantic_key) && [ + ...(input.eventProbes ?? []), + ...(input.eventClarificationProbes ?? []), + ...(input.evidenceCollectionProbes ?? []), + ].some((probe) => probe.semantic_key === base.semantic_key); + const ownedProbe = keyed ? null : followupOwnedProbe(base); return { ...base, choice_frame: attach @@ -665,6 +834,7 @@ export function buildMethodFollowupPlan(input: { observations: input.observations, evidence: input.evidence, probes: [ + ...(ownedProbe ? [ownedProbe] : []), ...(input.eventProbes ?? []), ...(input.eventClarificationProbes ?? []), ...(input.evidenceCollectionProbes ?? []), @@ -852,9 +1022,74 @@ export function buildMethodFollowupPlan(input: { ...(input.askedProbeKeys ?? []), ...askedKeysFromLedgerEvidence(input.evidence), ]); - const conflictProbe = dashaCovered && meetsAcceptanceEventQuality(input.evidence) - ? remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys)[0] ?? null - : null; + 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; + return makeFollowup({ + method_id: PROBE_METHOD_ID[conflictProbe.domain], + intent: "distinguish_candidates", + ask_theme: REVERSE_VERIFY_THEME[conflictProbe.domain], + domain: conflictProbe.domain, + kind_hint: REVERSE_VERIFY_KIND[conflictProbe.domain], + user_prompt_hint: ask( + `当前候选时间还分不开。按冲突分钟反推:${conflictProbe.year_label} 是否有${conflictProbe.event_family}。对得上写入账本并重算以筛窗;对不上关闭该问。不要问两套盘哪个更像。不确认唯一分钟。`, + REVERSE_VERIFY_VARGA[conflictProbe.domain], + ), + source: "event_probe", + information_gain: conflictProbe.information_gain ?? 0, + semantic_key: conflictProbe.semantic_key ?? `${conflictProbe.domain}.${conflictProbe.year}`, + candidate_split_hash: conflictProbe.candidate_split_hash, + probe_year: conflictProbe.year, + choice_kind: conflictProbe.choice_kind ?? "existence", + candidate_ids: conflictProbe.candidate_ids ?? candidateIdsFromProbe(conflictProbe), + expected_outcomes: conflictProbe.expected_outcomes, + style_options: ranked.styleOptions, + selection_score: ranked.score, + probe_id: conflictProbe.semantic_key, + }, true, true); + } + const contrast = ranked.contrastProbe!; + const domain = contrastFollowupDomain(contrast.domain); + const expectedOutcomes = contrast.expectedOutcomes.map((row) => ({ + answer_class: row.outcomeId, + supports: row.supportsCandidateIds, + conflicts: row.conflictsCandidateIds, + })); + return makeFollowup({ + method_id: PROBE_METHOD_ID[domain], + intent: "distinguish_candidates", + ask_theme: REVERSE_VERIFY_THEME[domain], + domain, + kind_hint: REVERSE_VERIFY_KIND[domain], + user_prompt_hint: ask( + contrast.question, + REVERSE_VERIFY_VARGA[domain], + "按候选盘面差异核对前事,不要问两套盘哪个更像。", + ), + source: "event_probe", + information_gain: contrast.informationGain, + semantic_key: contrast.semanticKey, + candidate_split_hash: contrast.candidateSplitHash, + probe_year: contrast.year ?? undefined, + choice_kind: contrast.choiceKind ?? "existence", + candidate_ids: [...new Set(contrast.expectedOutcomes.flatMap((row) => [ + ...row.supportsCandidateIds, + ...row.conflictsCandidateIds, + ]))], + expected_outcomes: expectedOutcomes, + style_options: ranked.styleOptions, + selection_score: ranked.score, + probe_id: contrast.probeId, + }, true, true); + }; if (!dashaCovered) { next = makeFollowup({ method_id: "dasha_events", @@ -869,27 +1104,8 @@ export function buildMethodFollowupPlan(input: { ), source: "method_coverage", }); - } else if (conflictProbe && (!coverageComplete || !candidatesSeparated || (conflictProbe.information_gain ?? 0) >= 0.08)) { - next = makeFollowup({ - method_id: PROBE_METHOD_ID[conflictProbe.domain], - intent: "distinguish_candidates", - ask_theme: REVERSE_VERIFY_THEME[conflictProbe.domain], - domain: conflictProbe.domain, - kind_hint: REVERSE_VERIFY_KIND[conflictProbe.domain], - user_prompt_hint: ask( - `当前候选时间还分不开。按冲突分钟反推:${conflictProbe.year_label} 是否有${conflictProbe.event_family}。对得上写入账本并重算以筛窗;对不上关闭该问。不要问两套盘哪个更像。不确认唯一分钟。`, - REVERSE_VERIFY_VARGA[conflictProbe.domain], - ), - source: "event_probe", - information_gain: conflictProbe.information_gain ?? 0, - semantic_key: conflictProbe.semantic_key ?? `${conflictProbe.domain}.${conflictProbe.year}`, - candidate_split_hash: conflictProbe.candidate_split_hash, - probe_year: conflictProbe.year, - choice_kind: conflictProbe.choice_kind ?? "existence", - candidate_ids: conflictProbe.candidate_ids ?? candidateIdsFromProbe(conflictProbe), - expected_outcomes: conflictProbe.expected_outcomes, - style_options: conflictProbe.style_options, - }, true, true); + } else if (bestDiscriminator && (!coverageComplete || !candidatesSeparated || bestDiscriminator.score >= 0.08)) { + next = followupFromRanked(bestDiscriminator); } else if (!relationshipCovered && !declined.has("relationship")) { next = makeFollowup({ method_id: "d9_relationship", @@ -968,31 +1184,6 @@ export function buildMethodFollowupPlan(input: { ), source: "method_coverage", }); - } else if (contrastProbe && !candidatesSeparated) { - const domain = contrastFollowupDomain(contrastProbe.domain); - next = makeFollowup({ - method_id: PROBE_METHOD_ID[domain], - intent: "distinguish_candidates", - ask_theme: REVERSE_VERIFY_THEME[domain], - domain, - kind_hint: REVERSE_VERIFY_KIND[domain], - user_prompt_hint: ask( - contrastProbe.question, - REVERSE_VERIFY_VARGA[domain], - "按候选盘面差异核对前事,不要问两套盘哪个更像。", - ), - source: "event_probe", - information_gain: contrastProbe.informationGain, - semantic_key: contrastProbe.semanticKey, - candidate_split_hash: contrastProbe.candidateSplitHash, - probe_year: contrastProbe.year ?? undefined, - choice_kind: contrastProbe.choiceKind ?? "existence", - style_options: contrastProbe.styleOptions?.map((item) => ({ - label: item.label, - answer_class: item.answerClass, - ...(item.sign ? { sign: item.sign } : {}), - })), - }, true, true); } else if (stage === "lagna_frame") { next = makeFollowup({ method_id: "dasha_events", diff --git a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts new file mode 100644 index 00000000..ef58879e --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts @@ -0,0 +1,174 @@ +/** + * Shared probe → choice-card contract. + * + * Python event probes and TypeScript cards must agree on four options that + * cover yes / weak_yes / no / unsure. Existence questions may be completed + * by the server; varga-style labels stay dynamic from candidate features. + */ + +import type { AnswerClass } from "../core/types"; + +export const QUESTION_CONTRACT_VERSION = "probe-question-v1"; + +export const ANSWER_CLASSES = ["yes", "weak_yes", "no", "unsure"] as const; + +export type ProbeQuestionKind = "existence" | "event_quality" | "varga_style"; + +export type ProbeStyleOption = Readonly<{ + label: string; + answer_class: AnswerClass; + sign?: string; +}>; + +export const EXISTENCE_STYLE_OPTIONS: readonly ProbeStyleOption[] = [ + { label: "明确发生且时间吻合", answer_class: "yes" }, + { label: "发生过但程度较弱", answer_class: "weak_yes" }, + { label: "明确没有发生", answer_class: "no" }, + { label: "这段记不清楚", answer_class: "unsure" }, +]; + +export const QUALITY_STYLE_OPTIONS: readonly ProbeStyleOption[] = [ + { label: "发挥明显失常或压力很大", answer_class: "yes" }, + { label: "有压力但不算明显失常", answer_class: "weak_yes" }, + { label: "发挥正常、没有明显失常", answer_class: "no" }, + { label: "这段记不清楚", answer_class: "unsure" }, +]; + +export const VARGA_NONE_STYLE_OPTION: ProbeStyleOption = { + label: "都不是这些特质", + answer_class: "no", +}; + +export const UNSURE_STYLE_OPTION: ProbeStyleOption = { + label: "这段记不清楚", + answer_class: "unsure", +}; + +const FORBIDDEN_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/; + +function isAnswerClass(value: unknown): value is AnswerClass { + return value === "yes" || value === "weak_yes" || value === "no" || value === "unsure"; +} + +export function probeQuestionKind(value: unknown): ProbeQuestionKind { + if (value === "varga_style" || value === "event_quality") return value; + return "existence"; +} + +export function clippedProbeLabel(value: unknown, min = 4, max = 80): string | null { + if (typeof value !== "string") return null; + const text = value.trim().replace(/\s+/g, " "); + if (text.length < min || text.length > max) return null; + if (FORBIDDEN_COPY.test(text)) return null; + return text; +} + +function catalogFor(kind: ProbeQuestionKind): readonly ProbeStyleOption[] { + return kind === "event_quality" ? QUALITY_STYLE_OPTIONS : EXISTENCE_STYLE_OPTIONS; +} + +function incomingOption(row: unknown): ProbeStyleOption | null { + if (!row || typeof row !== "object" || Array.isArray(row)) return null; + const record = row as Record; + const answerClass = record.answer_class ?? record.answerClass; + const label = clippedProbeLabel(record.label); + if (!label || !isAnswerClass(answerClass)) return null; + const sign = typeof record.sign === "string" && record.sign.trim() ? record.sign.trim() : undefined; + return sign ? { label, answer_class: answerClass, sign } : { label, answer_class: answerClass }; +} + +function uniquify(options: readonly ProbeStyleOption[]): ProbeStyleOption[] { + const seen = new Set(); + return options.map((option) => { + let label = option.label; + if (seen.has(label) && option.sign) label = `${label}(${option.sign})`; + if (seen.has(label)) label = `${label}·${option.answer_class}`; + seen.add(label); + return label === option.label ? option : { ...option, label }; + }); +} + +export function completeStyleOptions(input: { + choiceKind?: string | null; + styleOptions?: readonly unknown[] | null; +}): ProbeStyleOption[] | null { + const kind = probeQuestionKind(input.choiceKind); + const incoming = (input.styleOptions ?? []).flatMap((row) => { + const option = incomingOption(row); + return option ? [option] : []; + }); + const byClass = new Map(); + if (kind === "varga_style") { + for (const option of incoming) byClass.set(option.answer_class, option); + if (!byClass.has("unsure")) byClass.set("unsure", UNSURE_STYLE_OPTION); + const scoring = ANSWER_CLASSES.filter((item) => item !== "unsure" && byClass.has(item)); + if (scoring.length < 2) return null; + if (!byClass.has("no")) byClass.set("no", VARGA_NONE_STYLE_OPTION); + if (!byClass.has("weak_yes") || !byClass.has("yes")) return null; + } else { + for (const option of catalogFor(kind)) byClass.set(option.answer_class, option); + for (const option of incoming) byClass.set(option.answer_class, option); + } + const ordered = uniquify(ANSWER_CLASSES.map((answerClass) => byClass.get(answerClass)).filter((item): item is ProbeStyleOption => Boolean(item))); + if (!isRenderableStyleOptions(ordered)) return null; + return ordered; +} + +export function isRenderableStyleOptions(options: readonly ProbeStyleOption[] | null | undefined): boolean { + if (!options || options.length !== 4) return false; + const classes = new Set(options.map((item) => item.answer_class)); + const labels = new Set(options.map((item) => item.label)); + return ANSWER_CLASSES.every((item) => classes.has(item)) && labels.size === 4 + && options.every((item) => clippedProbeLabel(item.label) === item.label); +} + +export function isRenderableProbe(input: { + informationGain?: number | null; + candidateIds?: readonly string[] | null; + expectedOutcomeCount?: number | null; + choiceKind?: string | null; + styleOptions?: readonly unknown[] | null; +}): boolean { + const gain = typeof input.informationGain === "number" && Number.isFinite(input.informationGain) + ? input.informationGain + : 0; + if (gain <= 0) return false; + if ((input.candidateIds?.length ?? 0) < 2) return false; + if ((input.expectedOutcomeCount ?? 0) < 2) return false; + return completeStyleOptions({ + choiceKind: input.choiceKind, + styleOptions: input.styleOptions, + }) !== null; +} + +export function discriminatorPriority(input: { + informationGain: number; + semanticNovelty?: number; + topCandidateCoverage?: number; + repetitionPenalty?: number; +}): number { + const novelty = input.semanticNovelty ?? 1; + const coverage = input.topCandidateCoverage ?? 1; + const penalty = input.repetitionPenalty ?? 0; + return input.informationGain * novelty * coverage - penalty; +} + +export function rankDiscriminatorScore(input: { + informationGain: number; + asked?: boolean; + candidateIds?: readonly string[]; + topCandidateTimes?: readonly string[]; +}): number { + const asked = input.asked === true; + const top = input.topCandidateTimes ?? []; + const ids = input.candidateIds ?? []; + const coverage = top.length === 0 + ? 1 + : ids.filter((item) => top.includes(item)).length / top.length; + return discriminatorPriority({ + informationGain: input.informationGain, + semanticNovelty: asked ? 0.35 : 1, + topCandidateCoverage: coverage > 0 ? coverage : 0.25, + repetitionPenalty: asked ? 0.45 : 0, + }); +} diff --git a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts index 17bf1fc6..d02ee2d9 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts @@ -9,6 +9,9 @@ import { compactInferenceProjection, previousInferenceFromReceipt } from "./infe import { decideFromDossier } from "./decision-from-dossier"; import { evidenceLedgerFingerprint } from "./tool-service"; import type { V9CaseDossier } from "./tool-service"; +import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract"; +import { RECTIFICATION_SKILL_VERSION } from "./case-status"; +import { parseAgentChoiceCopy } from "./choice-card"; export const TURN_DECISION_MAX_BYTES = 6 * 1024; export const TURN_DECISION_RECENT_TURNS = 6; @@ -43,6 +46,7 @@ export function projectTurnDecision( nextAction?: Readonly> | null; currentQuestion?: Readonly> | null; followupHint?: string | null; + questionContract?: Readonly> | null; } = {}, ): Record { const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); @@ -70,24 +74,26 @@ export function projectTurnDecision( summary: clipText(item.summary, 160), })); const focus = dossier.conversationSummary.activeFocus; + const currentQuestion = extras.currentQuestion ?? (focus && parseAgentChoiceCopy(focus.expectedAnswerSchema) + ? { + question_id: focus.questionId, + focus_id: focus.id, + probe_id: typeof focus.expectedAnswerSchema.probe_id === "string" + ? focus.expectedAnswerSchema.probe_id + : null, + prompt: choicePromptFromSchema(focus.expectedAnswerSchema), + intent: focus.intent, + domain: focus.targetDomain, + } + : null); + const inferenceProjection = compactInferenceProjection(inference); const payload: Record = { projection: "turn_decision", case_id: dossier.case.caseId, case_revision: inference?.revision ?? 0, status: dossier.case.status, - current_question: extras.currentQuestion ?? (focus - ? { - question_id: focus.questionId, - focus_id: focus.id, - probe_id: typeof focus.expectedAnswerSchema.probe_id === "string" - ? focus.expectedAnswerSchema.probe_id - : null, - prompt: choicePromptFromSchema(focus.expectedAnswerSchema), - intent: focus.intent, - domain: focus.targetDomain, - } - : null), - current_probe: compactInferenceProjection(inference)?.next_probe ?? null, + current_question: currentQuestion, + current_probe: currentQuestion ? inferenceProjection?.next_probe ?? null : null, candidate_summary: { representative_time: dossier.latestResult?.representativeTime ?? null, selection_allowed: decision.selectionAllowed, @@ -96,7 +102,9 @@ export function projectTurnDecision( candidates, entropy: inference?.entropy ?? null, }, - inference: compactInferenceProjection(inference), + inference: currentQuestion || !inferenceProjection + ? inferenceProjection + : { ...inferenceProjection, next_probe: null }, next_action: extras.nextAction ?? { type: decision.nextAction, session_outcome: decision.sessionOutcome, @@ -113,6 +121,14 @@ export function projectTurnDecision( item.status === "draft" || item.status === "pending_confirmation" )).length, }, + question_contract: extras.questionContract ?? { + version: QUESTION_CONTRACT_VERSION, + git_sha: process.env.GITHUB_SHA + ?? process.env.VERCEL_GIT_COMMIT_SHA + ?? process.env.NEXT_PUBLIC_GIT_COMMIT + ?? null, + skill_version: RECTIFICATION_SKILL_VERSION, + }, }; return enforceTurnDecisionBudget(payload); } diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index b6adb659..2c864fb6 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -87,6 +87,7 @@ import { resolveEvidenceQuote, } from "@/lib/rectification-agentic/v9/evidence-quote"; import { projectTurnDecision } from "@/lib/rectification-agentic/v9/turn-decision"; +import { QUESTION_CONTRACT_VERSION } from "@/lib/rectification-agentic/v9/probe-question-contract"; import { posteriorMap, scoreDeltas, @@ -572,6 +573,57 @@ export function latestResultToolProjection( }; } +function agentVisibleLatestProjection( + projection: Record, + extras: { + openQuestion: ReturnType; + focusStatus: string; + selectedProbeId?: string | null; + selectionScore?: number | null; + }, +): Record { + const { + candidate_contrast_packet: _packet, + discriminating_event_probes: _probes, + event_clarification_probes: _clarify, + evidence_collection_probes: _collect, + candidate_contrast_opportunities: _opportunities, + inference_state: inference, + ...rest + } = projection; + const currentQuestion = extras.openQuestion + ? { + question_id: extras.openQuestion.question_id, + prompt: extras.openQuestion.prompt, + status: extras.openQuestion.status, + } + : null; + const compactInference = inference && typeof inference === "object" && !Array.isArray(inference) + ? inference as Record + : null; + return { + ...rest, + current_question: currentQuestion, + current_probe: null, + inference_state: currentQuestion && compactInference + ? compactInference + : compactInference + ? { ...compactInference, next_probe: null } + : null, + question_contract: { + version: QUESTION_CONTRACT_VERSION, + skill_version: RECTIFICATION_SKILL_VERSION, + git_sha: process.env.GITHUB_SHA + ?? process.env.VERCEL_GIT_COMMIT_SHA + ?? process.env.NEXT_PUBLIC_GIT_COMMIT + ?? null, + selected_probe_id: extras.selectedProbeId ?? null, + probe_selection_score: extras.selectionScore ?? null, + focus_persistence_status: extras.focusStatus, + }, + }; +} + function collectingFollowupForParsed( parsed: DossierForTools, latest: NonNullable, @@ -1759,7 +1811,14 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { accepted: Boolean(scored.parsed.case.acceptedTime), }); const projection = { - ...latestProjection, + ...agentVisibleLatestProjection(latestProjection, { + openQuestion: openQuestionFromPersistedFocus(persistedFocus), + focusStatus: persistedFocus.status, + selectedProbeId: collectingPlan.next_followup?.probe_id + ?? collectingPlan.next_followup?.semantic_key + ?? null, + selectionScore: collectingPlan.next_followup?.selection_score ?? null, + }), cached: scored.persisted.cached, candidate_range: scored.parsed.case.candidateRange, overall_confidence: scored.persisted.overallConfidence, diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index b97e427d..90f15760 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -300,6 +300,23 @@ test("turn_decision stays inside the configured byte budget", () => { assert.ok(!("baseline_birth_snapshot" in projection)); }); +test("turn_decision hides current_probe unless a valid current_question exists", () => { + const withFocus = projectTurnDecision(parseV9CaseDossier(choiceDossier())!); + assert.ok(withFocus.current_question); + assert.ok(withFocus.current_probe); + assert.equal((withFocus.question_contract as { version?: string }).version, "probe-question-v1"); + + const snapshot = candidateSnapshotFixture(); + Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() }); + const withoutFocus = projectTurnDecision(parseV9CaseDossier(dossierFixture({ + latestResult: snapshot, + conversationSummary: conversationSummaryFixture({ activeFocus: null }), + }))!); + assert.equal(withoutFocus.current_question, null); + assert.equal(withoutFocus.current_probe, null); + assert.equal((withoutFocus.inference as { next_probe?: unknown } | null)?.next_probe, null); +}); + test("truncated and timed-out runs return concrete finish reasons", () => { assert.equal(mapModelFinishToErrorCode({ finishReason: "length", @@ -384,6 +401,12 @@ test("rectification attempt timeout stays under the agent route budget", () => { assert.match(regenerate, /export const maxDuration = 240/); assert.ok(210_000 < 240_000); assert.match(agentRun, /RETRYABLE_ERROR_CODES = new Set\(\[/); + const retryable = agentRun.slice( + agentRun.indexOf("const RETRYABLE_ERROR_CODES"), + agentRun.indexOf("function streamFinishReason"), + ); + assert.match(agentRun, /state_invariant_failed/); + assert.doesNotMatch(retryable, /state_invariant_failed/); assert.doesNotMatch(agentRun, /stale_question/); assert.doesNotMatch(agentRun, /revision_conflict/); }); diff --git a/frontend/tests/rectification-candidate-contrast-packet.test.ts b/frontend/tests/rectification-candidate-contrast-packet.test.ts index d86851a2..d9291e12 100644 --- a/frontend/tests/rectification-candidate-contrast-packet.test.ts +++ b/frontend/tests/rectification-candidate-contrast-packet.test.ts @@ -24,16 +24,16 @@ 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.equal(probe.styleOptions?.length, 3); - assert.equal(probe.expectedOutcomes.length, 3); - assert.deepEqual(probe.expectedOutcomes.map((row) => row.supportsCandidateIds), [ + 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), [ ["05:00"], ["05:03"], ["05:04"], ]); }); -test("two-way remaining D9 maps C to unsure, not the other minute group", () => { +test("two-way remaining D9 keeps the other minute on weak_yes, not no", () => { const packet = buildCandidateContrastPacket({ candidateSetVersion: "05:00-05:04", candidateTimes: ["05:00", "05:04"], @@ -44,10 +44,11 @@ test("two-way remaining D9 maps C to unsure, not the other minute group", () => const probe = selectDiscriminatorProbe(packet); assert.ok(probe); assert.equal(probe.choiceKind, "varga_style"); - assert.equal(probe.styleOptions?.length, 2); + assert.equal(probe.styleOptions?.length, 4); assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "yes")?.supportsCandidateIds, ["05:00"]); assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "weak_yes")?.supportsCandidateIds, ["05:04"]); - assert.equal(probe.expectedOutcomes.some((row) => row.outcomeId === "no"), false); + assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "no")?.supportsCandidateIds, []); + assert.equal(probe.styleOptions?.some((item) => item.answerClass === "unsure"), true); }); test("remaining D7 enters the pool as family existence", () => { diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index bd69e70b..efbb4c00 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -270,29 +270,35 @@ test("choice frame requires a real non-empty event family", () => { }, { probes: [{ ...MOVE_PROBE, event_family: " " }] }), null); }); -test("choice frame rejects incomplete, duplicate-class, illegal, and duplicate-label style options", () => { - const build = (style_options: DiscriminatingEventProbe["style_options"]) => buildChoiceFrame({ +test("choice frame completes existence options and rejects illegal or non-renderable varga styles", () => { + const build = (style_options: DiscriminatingEventProbe["style_options"], extra: Partial = {}) => buildChoiceFrame({ method_id: "d4_home", ask_theme: "home_change", domain: "relocation", user_prompt_hint: "unused", - }, { probes: [{ ...MOVE_PROBE, style_options }] }); + }, { probes: [{ ...MOVE_PROBE, style_options, ...extra }] }); - assert.equal(build(DYNAMIC_STYLE_OPTIONS.slice(0, 3)), null); + assert.ok(build(DYNAMIC_STYLE_OPTIONS.slice(0, 3))); + assert.ok(build(undefined)); assert.equal(build([ - ...DYNAMIC_STYLE_OPTIONS, - { label: "另一种明确发生", answer_class: "yes" }, - ]), null); - assert.equal(build([ - { ...DYNAMIC_STYLE_OPTIONS[0], label: "外貌更接近第一种" }, - ...DYNAMIC_STYLE_OPTIONS.slice(1), - ]), null); - assert.equal(build([ - DYNAMIC_STYLE_OPTIONS[0], - { ...DYNAMIC_STYLE_OPTIONS[1], label: DYNAMIC_STYLE_OPTIONS[0].label }, - DYNAMIC_STYLE_OPTIONS[2], - DYNAMIC_STYLE_OPTIONS[3], - ]), null); + { label: "巨蟹相处主动热情", answer_class: "yes" }, + ], { choice_kind: "varga_style" }), null); +}); + +test("python-shaped existence probe without style_options still builds a four-option card", () => { + const { style_options: _unused, ...pythonProbe } = MOVE_PROBE; + const frame = buildChoiceFrame({ + method_id: "d4_home", + ask_theme: "home_change", + domain: "relocation", + user_prompt_hint: "unused", + }, { probes: [pythonProbe] }); + assert.ok(frame); + assert.equal(frame.option_a_answer_class, "yes"); + assert.equal(frame.option_b_answer_class, "weak_yes"); + assert.equal(frame.option_c_answer_class, "no"); + assert.equal(frame.option_d_answer_class, "unsure"); + assert.equal(frame.unsure_label, "这段记不清楚"); }); test("style options without a real event probe do not generate a card", () => { @@ -902,7 +908,7 @@ test("GET choice_card stays hidden without a persisted focus after 没有了", ( assert.equal(card, null); }); -test("dynamic option labels preserve order and carry their own answer class", () => { +test("dynamic option labels canonicalize to yes/weak_yes/no/unsure order", () => { const styleOptions = [ { label: "明确没有发生", answer_class: "no" }, { label: "这段记不清楚", answer_class: "unsure" }, @@ -920,10 +926,10 @@ test("dynamic option labels preserve order and carry their own answer class", () assert.ok(frame); const copy = serverOwnedChoiceCopy(frame); assert.deepEqual(copy?.options, [ - { key: "A", ...styleOptions[0] }, - { key: "B", ...styleOptions[1] }, - { key: "C", ...styleOptions[2] }, - { key: "D", ...styleOptions[3] }, + { key: "A", label: "明确发生且时间吻合", answer_class: "yes" }, + { key: "B", label: "发生过但程度较弱", answer_class: "weak_yes" }, + { key: "C", label: "明确没有发生", answer_class: "no" }, + { key: "D", label: "这段记不清楚", answer_class: "unsure" }, ]); }); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index ed39025f..6805ed1e 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -1255,6 +1255,10 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => { assert.match(skill, /D9\/D10 类型表是校时方法/); assert.doesNotMatch(skill, /±5 分钟确定性/); assert.doesNotMatch(skill, /KP 政策跳过不挡提出门/); + const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); + assert.match(tools, /function agentVisibleLatestProjection/); + assert.match(tools, /candidate_contrast_packet: _packet/); + assert.match(tools, /current_probe: null/); }); test("Mastra hides active candidates when the receipt range excludes one of them", () => { @@ -1780,6 +1784,47 @@ test("answered duty language skips window D10 and uses remaining D24", () => { assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d10/); }); +test("low-gain career event probe does not outrank a renderable high-gain D24 contrast probe", () => { + 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, + }); + assert.equal(plan.next_followup?.semantic_key, "varga.d24.05:00/05:07|05:10|05:14"); + assert.equal(plan.next_followup?.choice_frame?.option_d_answer_class, "unsure"); + assert.equal(plan.next_followup?.choice_frame?.period, "当前这几个候选"); + assert.match(plan.next_followup?.choice_frame?.prompt ?? "", /学业|考试发挥|学习压力/); + assert.doesNotMatch(plan.next_followup?.choice_frame?.prompt ?? "", /入职、升职/); + assert.ok((plan.next_followup?.selection_score ?? 0) > 0.56); +}); + const DUMP_COVERAGE = [ { status: "confirmed" as const, diff --git a/frontend/tests/rectification-probe-question-contract.test.ts b/frontend/tests/rectification-probe-question-contract.test.ts new file mode 100644 index 00000000..9a5dabd2 --- /dev/null +++ b/frontend/tests/rectification-probe-question-contract.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + EXISTENCE_STYLE_OPTIONS, + QUALITY_STYLE_OPTIONS, + QUESTION_CONTRACT_VERSION, + completeStyleOptions, + isRenderableProbe, + rankDiscriminatorScore, +} from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; + +test("existence probes complete to four answer classes without engine style_options", () => { + const completed = completeStyleOptions({ choiceKind: "existence" }); + assert.deepEqual(completed, [...EXISTENCE_STYLE_OPTIONS]); + assert.equal(QUESTION_CONTRACT_VERSION, "probe-question-v1"); +}); + +test("event_quality probes use quality labels and still cover unsure", () => { + const completed = completeStyleOptions({ choiceKind: "event_quality" }); + assert.deepEqual(completed, [...QUALITY_STYLE_OPTIONS]); + assert.equal(completed?.some((item) => item.answer_class === "unsure"), true); +}); + +test("varga-style probes stay dynamic and fail closed without two scoring labels", () => { + assert.equal(completeStyleOptions({ + choiceKind: "varga_style", + styleOptions: [{ label: "巨蟹相处主动热情", answer_class: "yes" }], + }), null); + const completed = completeStyleOptions({ + choiceKind: "varga_style", + styleOptions: [ + { label: "巨蟹相处主动热情", answer_class: "yes", sign: "巨蟹" }, + { label: "狮子独立强势", answer_class: "weak_yes", sign: "狮子" }, + ], + }); + assert.equal(completed?.length, 4); + assert.equal(completed?.find((item) => item.answer_class === "no")?.label, "都不是这些特质"); + assert.equal(completed?.find((item) => item.answer_class === "unsure")?.label, "这段记不清楚"); +}); + +test("illegal clock or appearance copy cannot become a renderable probe", () => { + assert.equal(isRenderableProbe({ + informationGain: 1.2, + candidateIds: ["05:00", "05:04"], + expectedOutcomeCount: 2, + choiceKind: "existence", + styleOptions: [ + { label: "08:12 左右发生", answer_class: "yes" }, + ], + }), true); + assert.equal(completeStyleOptions({ + choiceKind: "existence", + styleOptions: [{ label: "08:12 左右发生", answer_class: "yes" }], + })?.find((item) => item.answer_class === "yes")?.label, "明确发生且时间吻合"); + assert.equal(isRenderableProbe({ + informationGain: 1.2, + candidateIds: ["05:00", "05:04"], + expectedOutcomeCount: 2, + choiceKind: "varga_style", + styleOptions: [ + { label: "外貌更接近第一种", answer_class: "yes" }, + { label: "相处更独立", answer_class: "weak_yes" }, + ], + }), false); +}); + +test("asked probes keep a novelty penalty so unused high-gain probes rank first", () => { + const askedCareer = rankDiscriminatorScore({ + informationGain: 0.56, + asked: true, + candidateIds: ["05:00", "05:07"], + }); + const unusedD24 = rankDiscriminatorScore({ + informationGain: 2.5, + asked: false, + candidateIds: ["05:00", "05:07", "05:10"], + }); + assert.ok(unusedD24 > askedCareer); + assert.ok(unusedD24 > 0.56); +}); diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index 60940b02..e606d183 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -69,15 +69,18 @@ function discriminatorFollowup(overrides: Partial = {}): MethodF function invalidStyleFollowup( styleOptions: readonly EventProbeStyleOption[] | undefined, eventFamily = "升学结果或学习环境出现明显变化", + choiceKind: "existence" | "varga_style" | "event_quality" = "existence", ): MethodFollowup { const valid = discriminatorFollowup(); return { ...valid, + choice_kind: choiceKind, choice_frame: buildChoiceFrame({ method_id: "dasha_events", ask_theme: "dated_event", domain: "education", user_prompt_hint: "ask", + choice_kind: choiceKind, }, { probes: [{ year: 2016, @@ -92,6 +95,7 @@ function invalidStyleFollowup( role: "distinguish", information_gain: 0.4, semantic_key: "education:2016", + choice_kind: choiceKind, style_options: styleOptions, }], }), @@ -219,17 +223,12 @@ test("complete dynamic event options persist a server-owned focus", async () => }); }); -test("missing, duplicate, incomplete, or illegal dynamic options skip focus persistence", async () => { - await assertInvalidChoiceSkipped(invalidStyleFollowup(undefined)); - await assertInvalidChoiceSkipped(invalidStyleFollowup(DYNAMIC_STYLE_OPTIONS.slice(0, 3))); - await assertInvalidChoiceSkipped(invalidStyleFollowup([ - ...DYNAMIC_STYLE_OPTIONS, - { label: "另一种明确发生", answer_class: "yes" }, - ])); - await assertInvalidChoiceSkipped(invalidStyleFollowup([ - { ...DYNAMIC_STYLE_OPTIONS[0], label: "08:12 左右发生" }, - ...DYNAMIC_STYLE_OPTIONS.slice(1), - ])); +test("non-renderable varga styles and empty event family skip focus persistence", async () => { + await assertInvalidChoiceSkipped(invalidStyleFollowup( + [{ label: "巨蟹相处主动热情", answer_class: "yes" }], + "升学结果或学习环境出现明显变化", + "varga_style", + )); await assertInvalidChoiceSkipped(invalidStyleFollowup(DYNAMIC_STYLE_OPTIONS, " ")); }); diff --git a/frontend/tests/rectification-turn-intent-classifier.test.ts b/frontend/tests/rectification-turn-intent-classifier.test.ts index 191b4784..06259827 100644 --- a/frontend/tests/rectification-turn-intent-classifier.test.ts +++ b/frontend/tests/rectification-turn-intent-classifier.test.ts @@ -103,5 +103,8 @@ test("production intent handling contains no semantic regex or positional text p assert.doesNotMatch(classifier + fastPath, /\.test\([^\n]*(?:userMessage|user_message|message)/); assert.doesNotMatch(classifier + fastPath, /(?:userMessage|user_message|message)\.(?:match|search|includes|startsWith|endsWith)\(/); assert.ok(route.indexOf("classifyRectificationTurnIntent") < route.indexOf("runV9AgentTurn({")); + assert.ok(route.indexOf("persistServerOwnedFocus") < route.indexOf("runV9AgentTurn({")); + assert.ok(fastPath.includes("ask_candidate_discriminator")); + assert.match(fastPath, /目前没有可继续区分/); assert.doesNotMatch(route, /classified\.answer_class!/); }); diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index 88180372..34092735 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -36,6 +36,7 @@ from scripts.rectification.candidate_contrast import ( opportunity_from_probe, ) from scripts.rectification.case_holdout import holdout_domain_years +from scripts.rectification.probe_question_contract import complete_style_options from scripts.rectification.refinement_packet import match_level MAX_PROBES = 3 @@ -564,6 +565,9 @@ def _public_probe( payload.update(extra) if payload["role"] == "distinguish": payload["candidate_ids"] = candidate_ids_from_outcomes(payload.get("expected_outcomes") or []) + style_options = complete_style_options(payload.get("choice_kind"), payload.get("style_options")) + if style_options: + payload["style_options"] = style_options return payload @@ -717,6 +721,7 @@ def _evaluate_contexts( version = set_version or candidate_set_version([yes_times, no_times]) outcomes = [ {"answer_class": "yes", "supports": yes_times, "conflicts": no_times}, + {"answer_class": "weak_yes", "supports": yes_times, "conflicts": no_times}, {"answer_class": "no", "supports": no_times, "conflicts": yes_times}, {"answer_class": "unsure", "supports": [], "conflicts": []}, ] @@ -752,7 +757,7 @@ def _evaluate_contexts( "calculationResultId": None, }], ) - if distinguish_contract_errors(probe): + if not probe or distinguish_contract_errors(probe): return None return probe diff --git a/scripts/rectification/probe_question_contract.py b/scripts/rectification/probe_question_contract.py new file mode 100644 index 00000000..c4f70177 --- /dev/null +++ b/scripts/rectification/probe_question_contract.py @@ -0,0 +1,115 @@ +"""Shared probe → choice-card contract for Python event probes. + +Must stay aligned with frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts. +""" + +from __future__ import annotations + +import re +from typing import Any, Sequence + +QUESTION_CONTRACT_VERSION = "probe-question-v1" +ANSWER_CLASSES = ("yes", "weak_yes", "no", "unsure") +EXISTENCE_STYLE_OPTIONS: tuple[dict[str, str], ...] = ( + {"label": "明确发生且时间吻合", "answer_class": "yes"}, + {"label": "发生过但程度较弱", "answer_class": "weak_yes"}, + {"label": "明确没有发生", "answer_class": "no"}, + {"label": "这段记不清楚", "answer_class": "unsure"}, +) +QUALITY_STYLE_OPTIONS: tuple[dict[str, str], ...] = ( + {"label": "发挥明显失常或压力很大", "answer_class": "yes"}, + {"label": "有压力但不算明显失常", "answer_class": "weak_yes"}, + {"label": "发挥正常、没有明显失常", "answer_class": "no"}, + {"label": "这段记不清楚", "answer_class": "unsure"}, +) +VARGA_NONE_STYLE_OPTION = {"label": "都不是这些特质", "answer_class": "no"} +UNSURE_STYLE_OPTION = {"label": "这段记不清楚", "answer_class": "unsure"} +_FORBIDDEN = ("外貌", "体质", "胎记", "疤痕", "伤疤", "身高", "体型") +_CLOCK = re.compile(r"(?:[01]?\d|2[0-3]):[0-5]\d") + + +def probe_question_kind(value: Any) -> str: + if value in {"varga_style", "event_quality"}: + return str(value) + return "existence" + + +def clipped_probe_label(value: Any, minimum: int = 4, maximum: int = 80) -> str | None: + if not isinstance(value, str): + return None + text = " ".join(value.split()) + if len(text) < minimum or len(text) > maximum: + return None + if any(token in text for token in _FORBIDDEN) or _CLOCK.search(text): + return None + return text + + +def _incoming_option(row: Any) -> dict[str, str] | None: + if not isinstance(row, dict): + return None + answer = row.get("answer_class") or row.get("answerClass") + label = clipped_probe_label(row.get("label")) + if not label or answer not in ANSWER_CLASSES: + return None + payload = {"label": label, "answer_class": str(answer)} + sign = row.get("sign") + if isinstance(sign, str) and sign.strip(): + payload["sign"] = sign.strip() + return payload + + +def complete_style_options( + choice_kind: Any, + style_options: Sequence[Any] | None = None, +) -> list[dict[str, str]] | None: + kind = probe_question_kind(choice_kind) + incoming = [item for item in (_incoming_option(row) for row in (style_options or [])) if item] + by_class: dict[str, dict[str, str]] = {} + if kind == "varga_style": + for option in incoming: + by_class[option["answer_class"]] = option + by_class.setdefault("unsure", dict(UNSURE_STYLE_OPTION)) + scoring = [item for item in ANSWER_CLASSES if item != "unsure" and item in by_class] + if len(scoring) < 2: + return None + by_class.setdefault("no", dict(VARGA_NONE_STYLE_OPTION)) + if "yes" not in by_class or "weak_yes" not in by_class: + return None + else: + catalog = QUALITY_STYLE_OPTIONS if kind == "event_quality" else EXISTENCE_STYLE_OPTIONS + for option in catalog: + by_class[option["answer_class"]] = dict(option) + for option in incoming: + by_class[option["answer_class"]] = option + ordered: list[dict[str, str]] = [] + seen: set[str] = set() + for answer_class in ANSWER_CLASSES: + option = by_class.get(answer_class) + if not option: + return None + label = option["label"] + if label in seen and option.get("sign"): + label = f"{label}({option['sign']})" + if label in seen: + label = f"{label}·{answer_class}" + seen.add(label) + ordered.append({**option, "label": label}) + labels = {item["label"] for item in ordered} + classes = {item["answer_class"] for item in ordered} + if len(ordered) != 4 or labels != {item["label"] for item in ordered} or classes != set(ANSWER_CLASSES): + return None + if len(labels) != 4: + return None + return ordered + + +def is_renderable_probe(probe: dict[str, Any]) -> bool: + gain = probe.get("information_gain") + if not isinstance(gain, (int, float)) or gain <= 0: + return False + candidate_ids = probe.get("candidate_ids") or [] + outcomes = probe.get("expected_outcomes") or [] + if len(candidate_ids) < 2 or len(outcomes) < 2: + return False + return complete_style_options(probe.get("choice_kind"), probe.get("style_options")) is not None diff --git a/tests/test_probe_question_contract.py b/tests/test_probe_question_contract.py new file mode 100644 index 00000000..1c1a6121 --- /dev/null +++ b/tests/test_probe_question_contract.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import unittest + +from scripts.rectification.probe_question_contract import ( + ANSWER_CLASSES, + EXISTENCE_STYLE_OPTIONS, + QUALITY_STYLE_OPTIONS, + complete_style_options, + is_renderable_probe, +) + + +class ProbeQuestionContractTests(unittest.TestCase): + def test_existence_completes_four_options(self) -> None: + completed = complete_style_options("existence") + self.assertEqual(completed, [dict(item) for item in EXISTENCE_STYLE_OPTIONS]) + + def test_quality_covers_unsure(self) -> None: + completed = complete_style_options("event_quality") + self.assertEqual(completed, [dict(item) for item in QUALITY_STYLE_OPTIONS]) + self.assertEqual({item["answer_class"] for item in completed or []}, set(ANSWER_CLASSES)) + + def test_varga_style_needs_two_scoring_labels(self) -> None: + self.assertIsNone(complete_style_options("varga_style", [ + {"label": "巨蟹相处主动热情", "answer_class": "yes"}, + ])) + completed = complete_style_options("varga_style", [ + {"label": "巨蟹相处主动热情", "answer_class": "yes", "sign": "巨蟹"}, + {"label": "狮子独立强势", "answer_class": "weak_yes", "sign": "狮子"}, + ]) + self.assertIsNotNone(completed) + assert completed is not None + self.assertEqual(len(completed), 4) + self.assertEqual(next(item["label"] for item in completed if item["answer_class"] == "no"), "都不是这些特质") + + def test_clock_copy_is_dropped_and_catalog_fills_existence(self) -> None: + completed = complete_style_options("existence", [ + {"label": "08:12 左右发生", "answer_class": "yes"}, + ]) + self.assertEqual(completed[0]["label"], "明确发生且时间吻合") + + def test_unrenderable_varga_probe_is_rejected(self) -> None: + self.assertFalse(is_renderable_probe({ + "information_gain": 1.2, + "candidate_ids": ["05:00", "05:04"], + "expected_outcomes": [{}, {}], + "choice_kind": "varga_style", + "style_options": [{"label": "外貌更接近第一种", "answer_class": "yes"}], + })) diff --git a/tests/test_rectification_event_probes.py b/tests/test_rectification_event_probes.py index 17d6a950..7d9decc5 100644 --- a/tests/test_rectification_event_probes.py +++ b/tests/test_rectification_event_probes.py @@ -270,6 +270,12 @@ class EventProbesTest(unittest.TestCase): self.assertGreaterEqual(len(row["expected_outcomes"]), 2) self.assertEqual(row["tracks"], ["vimshottari", "narayana"]) self.assertFalse(row["unique_minute_claim"]) + self.assertEqual(len(row["style_options"]), 4) + self.assertEqual( + {item["answer_class"] for item in row["style_options"]}, + {"yes", "weak_yes", "no", "unsure"}, + ) + self.assertIn("这段记不清楚", {item["label"] for item in row["style_options"]}) def test_same_calendar_year_shift_is_not_a_boundary_year(self) -> None: built = {