fix(rectification): unify discriminator question contract and rank by information gain
Independent Staging Quality Gate / validate (push) Failing after 11m51s
Independent Staging Quality Gate / publish (push) Has been skipped

Python and TypeScript now share a four-option probe contract, persist Focus before asking, and pick the highest-value renderable probe instead of preferring low-gain career events over D24.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-28 00:25:35 +08:00
co-authored by Cursor
parent 85db4591d3
commit 7f82428b44
20 changed files with 1175 additions and 141 deletions
+18
View File
@@ -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
@@ -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) {
@@ -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;
}
@@ -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<V9Agen
return true;
};
const discriminatorInvariant = async (): Promise<{ ok: true } | { ok: false; errorCode: string }> => {
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<AttemptOutcome> => {
let inputTokens = 0;
let outputTokens = 0;
@@ -848,7 +869,11 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (mapped === "max_steps" || mapped === "provider_error") {
return failedAttempt(attemptId, mapped);
}
if (await flushPersistedPrompt()) return completeAttempt();
if (await flushPersistedPrompt()) {
const invariant = await discriminatorInvariant();
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);
return completeAttempt();
}
if (!answerText.trim()) {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
@@ -863,6 +888,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
const invariant = await discriminatorInvariant();
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);
return completeAttempt();
} finally {
clearTimeout(timeout);
@@ -9,6 +9,7 @@
*/
import type { AnswerClass } from "../core/types";
import { completeStyleOptions, clippedProbeLabel } from "./probe-question-contract";
import type { DiscriminatingEventProbe, EventProbeChoiceKind, EventProbeStyleOption } from "./refinement-packet";
import type { InternalVargaObservation } from "./varga-observations";
@@ -235,8 +236,12 @@ function hypothesisFor(
): Hypothesis | null {
const domain = followupDomain(followup);
const probe = pickProbe(probes, domain, followup);
const styleOptions = followup.style_options ?? probe?.style_options ?? [];
if (!probe?.event_family?.trim()) return null;
const styleOptions = completeStyleOptions({
choiceKind: followup.choice_kind ?? probe.choice_kind,
styleOptions: followup.style_options ?? probe.style_options ?? [],
});
if (!styleOptions) return null;
const period = periodFor(evidence, domain, probes, birthDate, followup);
const prompt = eventLockPrompt(period, probe.event_family);
const why = probe.user_meaning?.trim() || followup.user_prompt_hint.trim();
@@ -297,11 +302,7 @@ function hypothesisKind(
}
function clippedCopy(value: unknown, min: number, max: number): 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_CHOICE_COPY.test(text)) return null;
return text;
return clippedProbeLabel(value, min, max);
}
function isAnswerClass(value: unknown): value is AnswerClass {
@@ -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<ReturnType<typeof completeStyleOptions>>;
}>;
function renderableEventProbe(
probe: DiscriminatingEventProbe,
askedKeys: ReadonlySet<string>,
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<string>,
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<MethodFollowup, "must_not_label" | "choice_frame">,
): 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<string>;
topCandidateTimes?: readonly string[];
}): RankedDiscriminator[] {
const top = input.topCandidateTimes ?? [];
const rows: RankedDiscriminator[] = [];
const seen = new Set<string>();
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",
@@ -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<string, unknown>;
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<string>();
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<AnswerClass, ProbeStyleOption>();
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,
});
}
@@ -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<Record<string, unknown>> | null;
currentQuestion?: Readonly<Record<string, unknown>> | null;
followupHint?: string | null;
questionContract?: Readonly<Record<string, unknown>> | null;
} = {},
): Record<string, unknown> {
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<string, unknown> = {
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);
}
+60 -1
View File
@@ -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<string, unknown>,
extras: {
openQuestion: ReturnType<typeof openQuestionFromPersistedFocus>;
focusStatus: string;
selectedProbeId?: string | null;
selectionScore?: number | null;
},
): Record<string, unknown> {
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<string, unknown>
: 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<DossierForTools["latestResult"]>,
@@ -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,
@@ -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/);
});
@@ -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", () => {
@@ -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<DiscriminatingEventProbe> = {}) => 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" },
]);
});
@@ -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,
@@ -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);
});
@@ -69,15 +69,18 @@ function discriminatorFollowup(overrides: Partial<MethodFollowup> = {}): 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, " "));
});
@@ -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!/);
});
+6 -1
View File
@@ -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
@@ -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
+50
View File
@@ -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"}],
}))
+6
View File
@@ -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 = {