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
@@ -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);
}