fix(rectification): unify discriminator question contract and rank by information gain
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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!/);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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"}],
|
||||
}))
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user