fix(rectification): stop discriminator followup from dropping user evidence
Decision and question ranking now share contrast option completion, so a missing style card cannot deadlock the interview with a dead-end reply. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6605,3 +6605,19 @@
|
||||
- 复发自:BUG-010
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-432 | 生时校正区分题出题层静默丢弃探针后无卡无记账死锁
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-29
|
||||
- 最近更新:2026-08-29
|
||||
- 影响面:`POST /api/rectification/agent` 消息分支、`decideFromDossier`、`buildMethodFollowupPlan`、口述采集 focus
|
||||
- 用户现象:点选两道区分题后,助手口述家人采集问但没有选项卡。用户用自由文本回答后,服务端回没有出路的终结话术;`current_question` 与 `choice_card` 均为 null,case 停在 `collecting_evidence`;该回合 receipt 的 phases / tools / tool_activities 全空,用户这句话里的新证据没有入账本。
|
||||
- 触发条件:训练证据已齐、已答 dasha 边界探针、剩余探针含无年份 varga 与缺 `style_options` 的 `varga_style`;方法覆盖未齐时出题层回落到口述采集;随后用户在无 active choice focus 时发自由文本。
|
||||
- 根因:`inspectDiscriminatorProbes` 用 `withCompletedContrastOptions` 把缺 `style_options` 的 `varga_style` 降级后仍视为可渲染,并选出无年份 varga 探针进入 `ask_candidate_discriminator`。`renderableContrastProbe` 仍走未降级的 `completeStyleOptions`,把同一探针静默丢弃。无年份桶只在覆盖齐时使用,于是回落到 `source=method_coverage` 的家人采集且不带 `choice_frame`。路由只信决策层:`persistServerOwnedFocus` skipped、`openQuestionFromPersistedFocus` 为 null 后直接返回终结话术,既不落证据也不跑 Agent。口述采集题不落 focus,刷新后问题丢失;卡片持久化失败时返回无正文下一步。
|
||||
- 修复:出题层与决策层共用 `withCompletedContrastOptions`;丢弃探针写入 `dropped_probes`。仅当出题层给出 `intent=distinguish_candidates` 且带 `choice_frame` 的 followup 时才进入 `ask_candidate_discriminator`,否则落到采集。消息分支拿不到可渲染卡且仍有 followup 时落入 `runV9AgentTurn`;仅当 `next_followup` 为 null 时才收口,话术给出继续采集或按区间看盘。口述采集持久化不带 choice 的 collect focus;卡片持久化失败时落到口述采集下一步。冲突探针写回时保留 `style_options`。Skill 版本保持 `10.0.13`。未放宽 confirmation gate,未增加「已确认唯一分钟」路径。
|
||||
- 验证:`frontend/tests/rectification-discriminator-followup-consistency.test.ts` 锁定同一探针池上决策层与出题层一致、缺 `style_options` 的 D9/D10 风格题在方法覆盖未齐时仍出区分卡、无星座时钟键 fail-closed 到采集、消息路径不再返回死路话术。`rectification-answer-choice.test.ts` 锁定口述采集后仍有 collect focus 与 `current_question`。`rectification-server-focus.test.ts` 锁定 collect schema 不是未渲染区分卡。`rectification-turn-intent-classifier.test.ts` 按新收口话术改断言。`npx tsc --noEmit` 与 `npx tsx --test tests/*.test.ts`。
|
||||
- 防复发:可渲染判定必须共用选项补全,不得一边降级接受一边静默丢弃。无卡时不得把用户消息当终结话术丢掉。有合法 choice schema 的 active focus 仍走轻量分类 + `applyRectificationChoice`。不得引入语义正则、A/B/C/D 位置推断或静态选项 fallback。
|
||||
- 相关记录:BUG-404、BUG-407、BUG-410、BUG-411
|
||||
- 复发自:BUG-404
|
||||
- 修复版本:待发布
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-f
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 240;
|
||||
|
||||
export const DISCRIMINATOR_EXHAUSTED_NARRATION = "当前几个候选已经构成可信区间。你可以再说一件记得住时间的经历,也可以先按这个区间看盘。";
|
||||
|
||||
function completedMessageResponse(text: string, requestId: string, caseId: string) {
|
||||
const body = [
|
||||
JSON.stringify({ type: "answer.delta", text }),
|
||||
@@ -414,7 +416,7 @@ export async function POST(request: Request) {
|
||||
followup: plan.next_followup,
|
||||
});
|
||||
const open = openQuestionFromPersistedFocus(persisted);
|
||||
if (open && persisted.focus) {
|
||||
if (open && persisted.focus && open.unrenderable !== true) {
|
||||
let classified = null;
|
||||
try {
|
||||
classified = await classifyRectificationTurnIntent(selectedModel, {
|
||||
@@ -456,13 +458,14 @@ export async function POST(request: Request) {
|
||||
});
|
||||
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);
|
||||
if (!plan.next_followup) {
|
||||
await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
requestId,
|
||||
userMessage: parsed.data.message ?? null,
|
||||
assistantMessage: DISCRIMINATOR_EXHAUSTED_NARRATION,
|
||||
});
|
||||
return completedMessageResponse(DISCRIMINATOR_EXHAUSTED_NARRATION, requestId, caseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
*/
|
||||
|
||||
import type { AnswerClass, ConflictProbe } from "./types.ts";
|
||||
import { d9StyleLabel, d10StyleLabel } from "../v9/varga-type-tables.ts";
|
||||
import {
|
||||
D9_TYPE_TABLE,
|
||||
D10_TYPE_TABLE,
|
||||
d9StyleLabel,
|
||||
d10StyleLabel,
|
||||
signKey,
|
||||
} from "../v9/varga-type-tables.ts";
|
||||
import {
|
||||
completeStyleOptions,
|
||||
isRenderableProbe,
|
||||
@@ -469,7 +475,7 @@ export function selectDiscriminatorProbe(
|
||||
return inspectDiscriminatorProbes(packet, options).selected;
|
||||
}
|
||||
|
||||
function withCompletedContrastOptions(
|
||||
export function withCompletedContrastOptions(
|
||||
probe: CandidateDiscriminatorProbe,
|
||||
): { ok: true; probe: CandidateDiscriminatorProbe } | { ok: false; reason: DroppedProbe["reason"] } {
|
||||
const mapped = probe.styleOptions?.map((item) => ({
|
||||
@@ -522,22 +528,37 @@ function inferredVargaStyleIncoming(
|
||||
});
|
||||
}
|
||||
|
||||
function knownVargaSigns(layer: "d9" | "d10", tokens: readonly string[]): string[] {
|
||||
const table = layer === "d9" ? D9_TYPE_TABLE : D10_TYPE_TABLE;
|
||||
return tokens.flatMap((token) => {
|
||||
const key = signKey(token);
|
||||
return table[key] ? [key] : [];
|
||||
});
|
||||
}
|
||||
|
||||
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] : [];
|
||||
});
|
||||
if (!layer) return null;
|
||||
const fromKey = knownVargaSigns(
|
||||
layer,
|
||||
match?.[2]
|
||||
?.split(/[|/]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item && !/^\d{1,2}:\d{2}$/.test(item))
|
||||
?? [],
|
||||
);
|
||||
const fromOutcomes = knownVargaSigns(
|
||||
layer,
|
||||
probe.expectedOutcomes.flatMap((row) => {
|
||||
const token = row.outcomeId.replace(/^supports_/, "").trim();
|
||||
return token && !ANSWER_CLASSES.has(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;
|
||||
if (signs.length < 2) return null;
|
||||
return { layer, signs };
|
||||
}
|
||||
|
||||
@@ -568,12 +589,29 @@ function withUnsureOutcome(
|
||||
return rows;
|
||||
}
|
||||
|
||||
function conflictStyleOptions(
|
||||
probe: CandidateDiscriminatorProbe,
|
||||
): ConflictProbe["style_options"] {
|
||||
const rows = probe.styleOptions?.flatMap((item) => {
|
||||
const label = item.label.trim();
|
||||
if (!label) return [];
|
||||
return [{
|
||||
label,
|
||||
answer_class: item.answerClass,
|
||||
...(item.sign ? { sign: item.sign } : {}),
|
||||
}];
|
||||
}) ?? [];
|
||||
return rows.length > 0 ? rows : undefined;
|
||||
}
|
||||
|
||||
export function conflictProbesFromContrast(
|
||||
packet: CandidateContrastPacket | null | undefined,
|
||||
): ConflictProbe[] {
|
||||
return (packet?.probes ?? []).flatMap((probe) => {
|
||||
if (!probe.semanticKey.startsWith("varga.")) return [];
|
||||
const outcomes = probe.expectedOutcomes.flatMap((row, index) => {
|
||||
const completed = withCompletedContrastOptions(probe);
|
||||
const working = completed.ok ? completed.probe : probe;
|
||||
const outcomes = working.expectedOutcomes.flatMap((row, index) => {
|
||||
const answer = ANSWER_CLASSES.has(row.outcomeId)
|
||||
? row.outcomeId as AnswerClass
|
||||
: (["yes", "weak_yes", "no"][index] as AnswerClass | undefined);
|
||||
@@ -585,29 +623,33 @@ export function conflictProbesFromContrast(
|
||||
}];
|
||||
});
|
||||
if (outcomes.length < 2) return [];
|
||||
if (probe.informationGain <= 0) return [];
|
||||
const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [
|
||||
if (working.informationGain <= 0) return [];
|
||||
const candidateIds = [...new Set(working.expectedOutcomes.flatMap((row) => [
|
||||
...row.supportsCandidateIds,
|
||||
...row.conflictsCandidateIds,
|
||||
]))];
|
||||
if (candidateIds.length < 2) return [];
|
||||
const choiceKind = effectiveContrastChoiceKind(probe);
|
||||
const choiceKind = completed.ok
|
||||
? (working.choiceKind ?? effectiveContrastChoiceKind(working))
|
||||
: effectiveContrastChoiceKind(probe);
|
||||
const styleOptions = conflictStyleOptions(working);
|
||||
return [{
|
||||
id: probe.probeId,
|
||||
semantic_key: probe.semanticKey,
|
||||
candidate_split_hash: probe.candidateSplitHash,
|
||||
domain: probe.domain ?? "career",
|
||||
year: probe.year ?? 0,
|
||||
question: probe.question,
|
||||
id: working.probeId,
|
||||
semantic_key: working.semanticKey,
|
||||
candidate_split_hash: working.candidateSplitHash,
|
||||
domain: working.domain ?? "career",
|
||||
year: working.year ?? 0,
|
||||
question: working.question,
|
||||
candidate_ids: candidateIds,
|
||||
expected_outcomes: outcomes,
|
||||
information_gain: probe.informationGain,
|
||||
information_gain: working.informationGain,
|
||||
source: "varga_contrast",
|
||||
...(choiceKind === "varga_style"
|
||||
|| choiceKind === "event_quality"
|
||||
|| choiceKind === "existence"
|
||||
? { choice_kind: choiceKind }
|
||||
: {}),
|
||||
...(styleOptions ? { style_options: styleOptions } : {}),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function probeFromEngine(probe: EngineProbeFields): ConflictProbe | null
|
||||
if (outcomes.length < 2 || candidateIds.length < 2 || (probe.information_gain ?? 0) <= 0) {
|
||||
return null;
|
||||
}
|
||||
const styleOptions = styleOptionsFromEngine(probe.style_options);
|
||||
return {
|
||||
id: `probe:${semanticKey}:${splitHash}`,
|
||||
semantic_key: semanticKey,
|
||||
@@ -36,9 +37,23 @@ export function probeFromEngine(probe: EngineProbeFields): ConflictProbe | null
|
||||
|| probe.choice_kind === "existence"
|
||||
? { choice_kind: probe.choice_kind }
|
||||
: {}),
|
||||
...(styleOptions.length > 0 ? { style_options: styleOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function styleOptionsFromEngine(
|
||||
rows: DiscriminatingEventProbe["style_options"],
|
||||
): NonNullable<ConflictProbe["style_options"]> {
|
||||
return (rows ?? []).flatMap((row) => {
|
||||
if (!ANSWER_CLASSES.has(row.answer_class)) return [];
|
||||
return [{
|
||||
label: row.label,
|
||||
answer_class: row.answer_class as AnswerClass,
|
||||
...(row.sign ? { sign: row.sign } : {}),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function outcomesFromEngine(
|
||||
rows: DiscriminatingEventProbe["expected_outcomes"],
|
||||
): ProbeOutcome[] {
|
||||
|
||||
@@ -85,6 +85,11 @@ export type ConflictProbe = Readonly<{
|
||||
information_gain: number;
|
||||
source: string;
|
||||
choice_kind?: ProbeChoiceKind;
|
||||
style_options?: readonly Readonly<{
|
||||
label: string;
|
||||
answer_class: AnswerClass;
|
||||
sign?: string;
|
||||
}>[];
|
||||
}>;
|
||||
|
||||
export type ProbeAnswer = Readonly<{
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
import type { ChoiceKey } from "./choice-card";
|
||||
import { persistServerOwnedFocus, openQuestionFromPersistedFocus } from "./server-focus";
|
||||
import { buildMethodFollowupPlan, spokenFollowupForUser } from "./method-followup";
|
||||
import { meetsAcceptanceEventQuality } from "./evidence-model";
|
||||
import type { SessionOutcomeKind } from "./confirmation-gate";
|
||||
|
||||
export type ApplyChoiceCommand = Readonly<{
|
||||
@@ -283,28 +282,83 @@ async function persistNextInterviewAfterChoice(input: {
|
||||
&& input.nextAction.type !== "ask_holdout_validation",
|
||||
});
|
||||
const followup = plan.next_followup;
|
||||
const persistedFocus = await persistServerOwnedFocus({
|
||||
const persistedFocus = await persistFocusAfterChoice({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
activeFocus: null,
|
||||
decisionReceipt: latest.decisionReceipt,
|
||||
followup,
|
||||
});
|
||||
const open = openQuestionFromPersistedFocus(persistedFocus);
|
||||
if (open) {
|
||||
if (open && open.unrenderable !== true) {
|
||||
return { hostNarration: "接下来请点选下面这一问。", choiceReady: true };
|
||||
}
|
||||
if (followup?.choice_frame) {
|
||||
const spoken = "请再说一件记得大概时间的经历。";
|
||||
const fallback = await persistFocusAfterChoice({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
decisionReceipt: latest.decisionReceipt,
|
||||
followup: {
|
||||
...followup,
|
||||
intent: "collect_method_evidence",
|
||||
choice_frame: null,
|
||||
source: "method_coverage",
|
||||
user_prompt_hint: spoken,
|
||||
},
|
||||
});
|
||||
if (fallback.status === "created" || fallback.status === "already_open") {
|
||||
return { hostNarration: spoken, choiceReady: false };
|
||||
}
|
||||
return { hostNarration: spoken, choiceReady: false };
|
||||
}
|
||||
if (followup?.intent === "collect_method_evidence") {
|
||||
const spoken = spokenFollowupForUser(followup);
|
||||
if (
|
||||
spoken
|
||||
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
|
||||
) {
|
||||
return { hostNarration: spoken, choiceReady: false };
|
||||
}
|
||||
return { hostNarration: null, choiceReady: false };
|
||||
}
|
||||
if (
|
||||
followup?.intent === "collect_method_evidence"
|
||||
&& meetsAcceptanceEventQuality(input.dossier.evidence)
|
||||
) {
|
||||
return { hostNarration: spokenFollowupForUser(followup), choiceReady: false };
|
||||
if (!followup) {
|
||||
return {
|
||||
hostNarration: "当前几个候选已经构成可信区间。你可以再说一件记得住时间的经历,也可以先按这个区间看盘。",
|
||||
choiceReady: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hostNarration: spokenFollowupForUser(followup) ?? "请再说一件记得大概时间的经历。",
|
||||
choiceReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistFocusAfterChoice(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
|
||||
followup: ReturnType<typeof buildMethodFollowupPlan>["next_followup"];
|
||||
}) {
|
||||
try {
|
||||
return await persistServerOwnedFocus({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
activeFocus: null,
|
||||
decisionReceipt: input.decisionReceipt,
|
||||
followup: input.followup,
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
status: "skipped" as const,
|
||||
focus: null,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
return { hostNarration: null, choiceReady: false };
|
||||
}
|
||||
|
||||
async function persistApplied(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
mentionedVargaKeysFromLedgerEvidence,
|
||||
volunteeredDomainsFromEvidence,
|
||||
type CandidateContrastPacket,
|
||||
type CandidateDiscriminatorProbe,
|
||||
type EngineContrastProbe,
|
||||
} from "../core/candidate-contrast-packet.ts";
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
} from "./evidence-model";
|
||||
import { refinementFromDecisionReceipt } from "./refinement-packet";
|
||||
import { windowScanFromDecisionReceipt } from "./varga-observations";
|
||||
import type { DroppedProbe } from "./probe-question-contract.ts";
|
||||
import { evidenceLedgerFingerprint } from "./tool-service";
|
||||
import {
|
||||
candidateSnapshotSource,
|
||||
@@ -183,6 +185,7 @@ export function contrastPacketFromLatestResult(
|
||||
|| probe.choice_kind === "existence"
|
||||
? { choice_kind: probe.choice_kind }
|
||||
: {}),
|
||||
...(probe.style_options?.length ? { style_options: probe.style_options } : {}),
|
||||
}];
|
||||
});
|
||||
const merged = mergeEngineProbes(
|
||||
@@ -285,6 +288,7 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket
|
||||
|| item.choice_kind === "existence"
|
||||
? { choice_kind: item.choice_kind }
|
||||
: {}),
|
||||
...(item.style_options?.length ? { style_options: item.style_options } : {}),
|
||||
})),
|
||||
candidateTimes: state.candidates
|
||||
.filter((item) => item.status !== "eliminated")
|
||||
@@ -316,6 +320,50 @@ function scoreableSnapshotCurrentFromDossier(
|
||||
return storedSnapshotIsCurrent(stored, current);
|
||||
}
|
||||
|
||||
function mergeDroppedProbes(
|
||||
...groups: readonly (readonly DroppedProbe[] | undefined)[]
|
||||
): DroppedProbe[] {
|
||||
const byKey = new Map<string, DroppedProbe>();
|
||||
for (const group of groups) {
|
||||
for (const item of group ?? []) {
|
||||
if (!byKey.has(item.semantic_key)) byKey.set(item.semantic_key, item);
|
||||
}
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
export function followupAsksRenderableDiscriminator(
|
||||
followup: { intent?: string; choice_frame?: unknown } | null | undefined,
|
||||
): boolean {
|
||||
return followup?.intent === "distinguish_candidates" && Boolean(followup.choice_frame);
|
||||
}
|
||||
|
||||
function discriminatorProbeIfFollowupCanAsk(input: {
|
||||
dossier: DecisionDossier;
|
||||
inspected: ReturnType<typeof inspectDiscriminatorProbes>;
|
||||
contrastPacket?: CandidateContrastPacket;
|
||||
askedKeys?: readonly string[];
|
||||
}): {
|
||||
probe: CandidateDiscriminatorProbe | null;
|
||||
dropped: DroppedProbe[];
|
||||
} {
|
||||
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: input.dossier.evidence,
|
||||
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
...catalog,
|
||||
...(input.contrastPacket ? { contrastPacket: input.contrastPacket } : {}),
|
||||
...(input.askedKeys ? { askedProbeKeys: input.askedKeys } : {}),
|
||||
candidatesSeparated: false,
|
||||
});
|
||||
const dropped = mergeDroppedProbes(input.inspected.dropped, plan.dropped_probes);
|
||||
if (!followupAsksRenderableDiscriminator(plan.next_followup)) {
|
||||
return { probe: null, dropped };
|
||||
}
|
||||
return { probe: input.inspected.selected, dropped };
|
||||
}
|
||||
|
||||
export function decideFromDossier(
|
||||
dossier: DecisionDossier,
|
||||
options?: { currentEvidenceFingerprint?: string | null },
|
||||
@@ -347,6 +395,7 @@ export function decideFromDossier(
|
||||
askedKeys,
|
||||
mentionedKeys,
|
||||
});
|
||||
const gated = discriminatorProbeIfFollowupCanAsk({ dossier, inspected, askedKeys });
|
||||
return {
|
||||
...decideRectification({
|
||||
methodCoverageAll: blockingMethodsCovered(collecting.methods),
|
||||
@@ -355,12 +404,12 @@ export function decideFromDossier(
|
||||
userStopped: dossier.case.status === "paused",
|
||||
snapshotCurrent,
|
||||
candidateScores: candidateScoresFromDossier(dossier.latestResult),
|
||||
discriminatorProbe: inspected.selected,
|
||||
discriminatorProbe: gated.probe,
|
||||
holdoutValidation: holdoutStatusFromInference(inference, oosBlindPrompts),
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
inferenceCredibleRange: inference?.credible_range ?? null,
|
||||
}),
|
||||
droppedProbes: inspected.dropped,
|
||||
droppedProbes: gated.dropped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -384,8 +433,16 @@ export function decideAfterInferenceChange(input: {
|
||||
}
|
||||
const training = input.state.events.filter((item) => item.usage === "training");
|
||||
const trainingDomains = new Set(training.map((item) => item.domain));
|
||||
const inspected = inspectDiscriminatorProbes(contrastPacketFromState(input.state), {
|
||||
const contrastPacket = contrastPacketFromState(input.state);
|
||||
const inspected = inspectDiscriminatorProbes(contrastPacket, {
|
||||
mentionedKeys: mentionedVargaKeysFromLedgerEvidence(input.dossier.evidence),
|
||||
askedKeys: input.state.answered_probes.map((item) => item.semantic_key),
|
||||
});
|
||||
const gated = discriminatorProbeIfFollowupCanAsk({
|
||||
dossier: input.dossier,
|
||||
inspected,
|
||||
contrastPacket,
|
||||
askedKeys: input.state.answered_probes.map((item) => item.semantic_key),
|
||||
});
|
||||
return {
|
||||
...decideRectification({
|
||||
@@ -395,7 +452,7 @@ export function decideAfterInferenceChange(input: {
|
||||
candidateScores: input.state.candidates
|
||||
.filter((item) => item.status !== "eliminated")
|
||||
.map((item) => ({ time: item.time, score: item.posterior_score })),
|
||||
discriminatorProbe: inspected.selected,
|
||||
discriminatorProbe: gated.probe,
|
||||
holdoutValidation: holdoutStatusFromState(
|
||||
input.state,
|
||||
refinementFromDecisionReceipt(input.dossier.latestResult?.decisionReceipt ?? null).oos_blind_prompts,
|
||||
@@ -404,7 +461,7 @@ export function decideAfterInferenceChange(input: {
|
||||
userStopped: input.userStopped,
|
||||
accepted: Boolean(input.dossier.case.acceptedTime),
|
||||
}),
|
||||
droppedProbes: inspected.dropped,
|
||||
droppedProbes: gated.dropped,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
mentionedVargaKeysFromLedgerEvidence,
|
||||
vargaLayerCovered,
|
||||
vargaLayerFromSemanticKey,
|
||||
withCompletedContrastOptions,
|
||||
type CandidateContrastPacket,
|
||||
type CandidateDiscriminatorProbe,
|
||||
} from "../core/candidate-contrast-packet.ts";
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
isRenderableProbe,
|
||||
rankDiscriminatorScore,
|
||||
EXISTENCE_STYLE_OPTIONS,
|
||||
type DroppedProbe,
|
||||
type ProbeStyleOption,
|
||||
} from "./probe-question-contract.ts";
|
||||
import type { SessionOutcomeKind } from "./confirmation-gate.ts";
|
||||
@@ -147,6 +149,7 @@ export type MethodFollowupPlan = Readonly<{
|
||||
stop_domain_rotation: true;
|
||||
do_not_poll: readonly [];
|
||||
not_in_rotation: readonly ["relocation"];
|
||||
dropped_probes: readonly DroppedProbe[];
|
||||
}>;
|
||||
|
||||
export type MethodFollowupEvidence = Readonly<{
|
||||
@@ -374,43 +377,42 @@ function contrastFollowupDomain(
|
||||
function eventProbeFromContrast(probe: CandidateDiscriminatorProbe): DiscriminatingEventProbe | null {
|
||||
const domain = contrastFollowupDomain(probe.domain);
|
||||
if (!EVENT_PROBE_DOMAINS.includes(domain as EventProbeDomain)) return null;
|
||||
const choiceKind = probe.choiceKind ?? "existence";
|
||||
const styleOptions = completeStyleOptions({
|
||||
choiceKind,
|
||||
styleOptions: probe.styleOptions?.map((item) => ({
|
||||
label: item.label,
|
||||
answer_class: item.answerClass,
|
||||
...(item.sign ? { sign: item.sign } : {}),
|
||||
})),
|
||||
});
|
||||
if (!styleOptions.ok) return null;
|
||||
const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [
|
||||
const completed = withCompletedContrastOptions(probe);
|
||||
if (!completed.ok) return null;
|
||||
const working = completed.probe;
|
||||
const choiceKind = working.choiceKind ?? "existence";
|
||||
const styleOptions = (working.styleOptions ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
answer_class: item.answerClass,
|
||||
...(item.sign ? { sign: item.sign } : {}),
|
||||
}));
|
||||
const candidateIds = [...new Set(working.expectedOutcomes.flatMap((row) => [
|
||||
...row.supportsCandidateIds,
|
||||
...row.conflictsCandidateIds,
|
||||
]))];
|
||||
return {
|
||||
year: probe.year ?? 0,
|
||||
year_label: probe.year ? `${probe.year} 年前后` : "当前这几个候选",
|
||||
year: working.year ?? 0,
|
||||
year_label: working.year ? `${working.year} 年前后` : "当前这几个候选",
|
||||
domain: domain as EventProbeDomain,
|
||||
event_family: followupEventFamily(domain, choiceKind),
|
||||
source: "dasha_activation",
|
||||
tracks: ["vimshottari", "narayana"],
|
||||
tracks_agree: true,
|
||||
unique_minute_claim: false,
|
||||
user_meaning: probe.question,
|
||||
user_meaning: working.question,
|
||||
role: "distinguish",
|
||||
phase: "candidate_discriminator",
|
||||
information_gain: probe.informationGain,
|
||||
semantic_key: probe.semanticKey,
|
||||
candidate_split_hash: probe.candidateSplitHash,
|
||||
information_gain: working.informationGain,
|
||||
semantic_key: working.semanticKey,
|
||||
candidate_split_hash: working.candidateSplitHash,
|
||||
candidate_ids: candidateIds,
|
||||
expected_outcomes: probe.expectedOutcomes.map((row) => ({
|
||||
expected_outcomes: working.expectedOutcomes.map((row) => ({
|
||||
answer_class: row.outcomeId,
|
||||
supports: row.supportsCandidateIds,
|
||||
conflicts: row.conflictsCandidateIds,
|
||||
})),
|
||||
choice_kind: choiceKind,
|
||||
style_options: styleOptions.options,
|
||||
style_options: styleOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -521,40 +523,63 @@ type RankedDiscriminator = Readonly<{
|
||||
styleOptions: ProbeStyleOption[];
|
||||
}>;
|
||||
|
||||
function droppedFromProbe(
|
||||
semanticKey: string,
|
||||
informationGain: number,
|
||||
reason: DroppedProbe["reason"],
|
||||
): DroppedProbe {
|
||||
return {
|
||||
semantic_key: semanticKey,
|
||||
information_gain: informationGain,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
function renderableEventProbe(
|
||||
probe: DiscriminatingEventProbe,
|
||||
askedKeys: ReadonlySet<string>,
|
||||
topCandidateTimes: readonly string[],
|
||||
mentionedKeys: ReadonlySet<string> = new Set(),
|
||||
): RankedDiscriminator | null {
|
||||
): { row: RankedDiscriminator | null; dropped: DroppedProbe | null } {
|
||||
const key = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
|
||||
const candidateIds = probe.candidate_ids ?? candidateIdsFromProbe(probe);
|
||||
const styleOptions = completeStyleOptions({
|
||||
choiceKind: probe.choice_kind,
|
||||
styleOptions: probe.style_options,
|
||||
});
|
||||
if (!styleOptions.ok || !isValidDistinguishProbe({ ...probe, role: "distinguish" })) return null;
|
||||
if (!isRenderableProbe({
|
||||
if (!styleOptions.ok) {
|
||||
return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, styleOptions.reason) };
|
||||
}
|
||||
if (!isValidDistinguishProbe({ ...probe, role: "distinguish" })) {
|
||||
return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "not_renderable") };
|
||||
}
|
||||
const renderable = isRenderableProbe({
|
||||
informationGain: probe.information_gain,
|
||||
candidateIds,
|
||||
expectedOutcomeCount: probe.expected_outcomes?.length,
|
||||
choiceKind: probe.choice_kind,
|
||||
styleOptions: styleOptions.options,
|
||||
}).ok) return null;
|
||||
const key = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
|
||||
});
|
||||
if (!renderable.ok) {
|
||||
return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, renderable.reason) };
|
||||
}
|
||||
const layer = vargaLayerFromSemanticKey(key);
|
||||
const asked = askedKeys.has(key)
|
||||
|| Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash))
|
||||
|| (layer ? vargaLayerCovered(mentionedKeys, layer) : false);
|
||||
return {
|
||||
kind: "event",
|
||||
eventProbe: probe,
|
||||
styleOptions: styleOptions.options,
|
||||
score: rankDiscriminatorScore({
|
||||
informationGain: probe.information_gain ?? 0,
|
||||
asked,
|
||||
candidateIds,
|
||||
topCandidateTimes,
|
||||
}),
|
||||
row: {
|
||||
kind: "event",
|
||||
eventProbe: probe,
|
||||
styleOptions: styleOptions.options,
|
||||
score: rankDiscriminatorScore({
|
||||
informationGain: probe.information_gain ?? 0,
|
||||
asked,
|
||||
candidateIds,
|
||||
topCandidateTimes,
|
||||
}),
|
||||
},
|
||||
dropped: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -563,41 +588,55 @@ function renderableContrastProbe(
|
||||
askedKeys: ReadonlySet<string>,
|
||||
topCandidateTimes: readonly string[],
|
||||
mentionedKeys: ReadonlySet<string> = new Set(),
|
||||
): RankedDiscriminator | null {
|
||||
const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [
|
||||
): { row: RankedDiscriminator | null; dropped: DroppedProbe | null } {
|
||||
const completed = withCompletedContrastOptions(probe);
|
||||
if (!completed.ok) {
|
||||
return {
|
||||
row: null,
|
||||
dropped: droppedFromProbe(probe.semanticKey, probe.informationGain, completed.reason),
|
||||
};
|
||||
}
|
||||
const working = completed.probe;
|
||||
const candidateIds = [...new Set(working.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.ok || !isRenderableProbe({
|
||||
informationGain: probe.informationGain,
|
||||
const styleOptions = (working.styleOptions ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
answer_class: item.answerClass,
|
||||
...(item.sign ? { sign: item.sign } : {}),
|
||||
}));
|
||||
const renderable = isRenderableProbe({
|
||||
informationGain: working.informationGain,
|
||||
candidateIds,
|
||||
expectedOutcomeCount: probe.expectedOutcomes.length,
|
||||
choiceKind: probe.choiceKind,
|
||||
styleOptions: styleOptions.options,
|
||||
}).ok) return null;
|
||||
const layer = vargaLayerFromSemanticKey(probe.semanticKey);
|
||||
const asked = askedKeys.has(probe.semanticKey)
|
||||
|| askedKeys.has(probe.candidateSplitHash)
|
||||
|| askedKeys.has(probe.probeId)
|
||||
expectedOutcomeCount: working.expectedOutcomes.length,
|
||||
choiceKind: working.choiceKind,
|
||||
styleOptions,
|
||||
});
|
||||
if (!renderable.ok) {
|
||||
return {
|
||||
row: null,
|
||||
dropped: droppedFromProbe(working.semanticKey, working.informationGain, renderable.reason),
|
||||
};
|
||||
}
|
||||
const layer = vargaLayerFromSemanticKey(working.semanticKey);
|
||||
const asked = askedKeys.has(working.semanticKey)
|
||||
|| askedKeys.has(working.candidateSplitHash)
|
||||
|| askedKeys.has(working.probeId)
|
||||
|| (layer ? vargaLayerCovered(mentionedKeys, layer) : false);
|
||||
return {
|
||||
kind: "contrast",
|
||||
contrastProbe: probe,
|
||||
styleOptions: styleOptions.options,
|
||||
score: rankDiscriminatorScore({
|
||||
informationGain: probe.informationGain,
|
||||
asked,
|
||||
candidateIds,
|
||||
topCandidateTimes,
|
||||
}),
|
||||
row: {
|
||||
kind: "contrast",
|
||||
contrastProbe: working,
|
||||
styleOptions,
|
||||
score: rankDiscriminatorScore({
|
||||
informationGain: working.informationGain,
|
||||
asked,
|
||||
candidateIds,
|
||||
topCandidateTimes,
|
||||
}),
|
||||
},
|
||||
dropped: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -714,13 +753,16 @@ function rankRenderableDiscriminators(input: {
|
||||
topCandidateTimes?: readonly string[];
|
||||
providedDomains?: readonly string[];
|
||||
evidence?: readonly MethodFollowupEvidence[];
|
||||
}): { locked: RankedDiscriminator[]; yearless: RankedDiscriminator[] } {
|
||||
}): { locked: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } {
|
||||
const top = input.topCandidateTimes ?? [];
|
||||
const provided = new Set(input.providedDomains ?? []);
|
||||
const mentioned = input.mentionedKeys ?? new Set();
|
||||
const rows: RankedDiscriminator[] = [];
|
||||
const dropped: DroppedProbe[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (row: RankedDiscriminator | null) => {
|
||||
const push = (result: { row: RankedDiscriminator | null; dropped: DroppedProbe | null }) => {
|
||||
if (result.dropped) dropped.push(result.dropped);
|
||||
const row = result.row;
|
||||
if (!row) return;
|
||||
const key = row.eventProbe?.semantic_key
|
||||
?? row.contrastProbe?.semanticKey
|
||||
@@ -740,6 +782,7 @@ function rankRenderableDiscriminators(input: {
|
||||
return {
|
||||
locked: sorted.filter((row) => discriminatorLocksScoringPeriod(row)),
|
||||
yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && discriminatorChoiceKind(row) !== "varga_style"),
|
||||
dropped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1174,7 +1217,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
providedDomains: datedDomainsFromEvidence(input.evidence),
|
||||
evidence: input.evidence,
|
||||
})
|
||||
: { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[] };
|
||||
: { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[], dropped: [] as DroppedProbe[] };
|
||||
const rankedDiscriminators = rankedCatalog.locked;
|
||||
const yearlessDiscriminators = rankedCatalog.yearless;
|
||||
const bestDiscriminator = rankedDiscriminators[0] ?? null;
|
||||
@@ -1259,6 +1302,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
stop_domain_rotation: true,
|
||||
do_not_poll: DO_NOT_POLL,
|
||||
not_in_rotation: NOT_IN_ROTATION,
|
||||
dropped_probes: rankedCatalog.dropped,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1276,6 +1320,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
stop_domain_rotation: true,
|
||||
do_not_poll: DO_NOT_POLL,
|
||||
not_in_rotation: NOT_IN_ROTATION,
|
||||
dropped_probes: rankedCatalog.dropped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1304,6 +1349,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
stop_domain_rotation: true,
|
||||
do_not_poll: DO_NOT_POLL,
|
||||
not_in_rotation: NOT_IN_ROTATION,
|
||||
dropped_probes: rankedCatalog.dropped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1715,6 +1761,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
stop_domain_rotation: true,
|
||||
do_not_poll: DO_NOT_POLL,
|
||||
not_in_rotation: NOT_IN_ROTATION,
|
||||
dropped_probes: rankedCatalog.dropped,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
stampChoiceSchemaWithProbe,
|
||||
previousInferenceFromReceipt,
|
||||
} from "./inference-adapter";
|
||||
import type { MethodFollowup } from "./method-followup";
|
||||
import { spokenFollowupForUser, type MethodFollowup } from "./method-followup";
|
||||
import {
|
||||
setV10ConversationFocus,
|
||||
RectificationToolServiceError,
|
||||
@@ -112,6 +112,7 @@ export function openQuestionFromPersistedFocus(result: PersistServerFocusResult)
|
||||
|| result.focus.questionId !== result.questionId
|
||||
) return null;
|
||||
if (!result.prompt || !parseAgentChoiceCopy(result.focus.expectedAnswerSchema)) {
|
||||
if (result.focus.expectedAnswerSchema?.collect === true) return null;
|
||||
return {
|
||||
question_id: result.questionId,
|
||||
prompt: null,
|
||||
@@ -127,6 +128,80 @@ export function openQuestionFromPersistedFocus(result: PersistServerFocusResult)
|
||||
};
|
||||
}
|
||||
|
||||
export const COLLECT_FOCUS_SCHEMA_KEY = "collect";
|
||||
|
||||
function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> | null {
|
||||
const prompt = spokenFollowupForUser({ ...followup, choice_frame: null });
|
||||
if (!prompt) return null;
|
||||
return {
|
||||
prompt,
|
||||
[COLLECT_FOCUS_SCHEMA_KEY]: true,
|
||||
semantic_key: followup.semantic_key ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCollectFocusSchema(schema: Readonly<Record<string, unknown>> | null | undefined): boolean {
|
||||
return schema?.[COLLECT_FOCUS_SCHEMA_KEY] === true && typeof schema.prompt === "string";
|
||||
}
|
||||
|
||||
async function persistCollectFocus(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
activeFocus: ConversationFocus | null;
|
||||
followup: MethodFollowup;
|
||||
}): Promise<PersistServerFocusResult> {
|
||||
const schema = collectFocusSchema(input.followup);
|
||||
if (!schema) {
|
||||
return {
|
||||
status: "skipped",
|
||||
focus: input.activeFocus,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
const questionId = stableFollowupQuestionId(input.followup);
|
||||
const prompt = typeof schema.prompt === "string" ? schema.prompt : null;
|
||||
const active = input.activeFocus;
|
||||
if (active && active.questionId === questionId && isCollectFocusSchema(active.expectedAnswerSchema)) {
|
||||
return { status: "already_open", focus: active, questionId: active.questionId, prompt };
|
||||
}
|
||||
try {
|
||||
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
||||
questionId,
|
||||
intent: input.followup.intent,
|
||||
targetEvidenceId: null,
|
||||
targetDomain: input.followup.domain,
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: schema,
|
||||
});
|
||||
return {
|
||||
status: result.idempotent ? "already_open" : "created",
|
||||
focus: result.focus,
|
||||
questionId: result.focus.questionId,
|
||||
prompt,
|
||||
};
|
||||
} catch (error) {
|
||||
const code = error instanceof RectificationToolServiceError
|
||||
? error.code
|
||||
: safeToolErrorCode(error);
|
||||
if (code === "focus_idempotency_conflict" || code.includes("focus_idempotency_conflict")) {
|
||||
return {
|
||||
status: "duplicate_focus",
|
||||
focus: input.activeFocus,
|
||||
questionId,
|
||||
prompt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
focus: input.activeFocus,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistServerOwnedFocus(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
@@ -137,9 +212,34 @@ export async function persistServerOwnedFocus(input: {
|
||||
}): Promise<PersistServerFocusResult> {
|
||||
const followup = input.followup;
|
||||
const frame = followup?.choice_frame ?? null;
|
||||
if (!followup || !frame) {
|
||||
if (!followup) {
|
||||
return {
|
||||
status: followup?.intent === "distinguish_candidates" ? "invalid_choice_schema" : "skipped",
|
||||
status: "skipped",
|
||||
focus: input.activeFocus,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
if (!frame) {
|
||||
if (followup.intent === "distinguish_candidates") {
|
||||
return {
|
||||
status: "invalid_choice_schema",
|
||||
focus: input.activeFocus,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
if (followup.intent === "collect_method_evidence") {
|
||||
return persistCollectFocus({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
activeFocus: input.activeFocus,
|
||||
followup,
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
focus: input.activeFocus,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
|
||||
@@ -87,6 +87,17 @@ export function projectCurrentQuestion(
|
||||
domain: focus.targetDomain ?? null,
|
||||
};
|
||||
}
|
||||
const collectPrompt = typeof schema?.prompt === "string" ? schema.prompt.trim() : "";
|
||||
if (focus.intent === "collect_method_evidence" && collectPrompt && !looksLikeChoiceSchema(schema)) {
|
||||
return {
|
||||
question_id: focus.questionId ?? null,
|
||||
focus_id: focus.id ?? null,
|
||||
probe_id: probeId,
|
||||
prompt: collectPrompt,
|
||||
intent: focus.intent,
|
||||
domain: focus.targetDomain ?? null,
|
||||
};
|
||||
}
|
||||
if (!looksLikeChoiceSchema(schema)) return null;
|
||||
return {
|
||||
question_id: focus.questionId ?? null,
|
||||
|
||||
@@ -644,11 +644,24 @@ test("answering a discriminator persists the next dated card so GET still has a
|
||||
assert.doesNotMatch(card.prompt, /2015/);
|
||||
});
|
||||
|
||||
test("answering the last discriminator persists a year-locked family collect, not a yearless D24 card", async () => {
|
||||
test("answering the last discriminator persists a year-locked family collect focus, not a yearless D24 card", async () => {
|
||||
const accounting = persistChoiceAccounting(familyCollectDossier(), {
|
||||
set_agentic_rectification_conversation_focus: () => {
|
||||
throw new Error("yearless D24 must not persist a scoring focus");
|
||||
},
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: {
|
||||
id: NEXT_FOCUS_ID,
|
||||
case_id: CASE_ID,
|
||||
question_id: args.p_question_id,
|
||||
intent: args.p_intent,
|
||||
target_evidence_id: args.p_target_evidence_id,
|
||||
target_domain: args.p_target_domain,
|
||||
target_kind: args.p_target_kind,
|
||||
expected_answer_schema: args.p_expected_answer_schema,
|
||||
status: "active",
|
||||
asked_at: "2026-08-28T07:37:50.000Z",
|
||||
resolved_at: null,
|
||||
},
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const applied = await applyRectificationChoice(accounting.client, {
|
||||
userId: USER_ID,
|
||||
@@ -662,11 +675,14 @@ test("answering the last discriminator persists a year-locked family collect, no
|
||||
optionId: "C",
|
||||
expectedRevision: familyCollectInference().revision,
|
||||
});
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
JSON.stringify(accounting.calls.map((call) => call.fn)),
|
||||
);
|
||||
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
|
||||
assert.ok(setFocus, JSON.stringify(accounting.calls.map((call) => call.fn)));
|
||||
assert.equal(setFocus.args.p_intent, "collect_method_evidence");
|
||||
const schema = setFocus.args.p_expected_answer_schema as { choice?: unknown; prompt?: string; collect?: boolean };
|
||||
assert.equal(schema.choice, undefined);
|
||||
assert.equal(schema.collect, true);
|
||||
assert.match(schema.prompt ?? "", /2021/);
|
||||
assert.match(schema.prompt ?? "", /家人|结婚|添丁|住院/);
|
||||
assert.equal(applied.nextInterviewPersisted, true);
|
||||
assert.equal(applied.nextChoiceReady, false);
|
||||
assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false);
|
||||
@@ -801,6 +817,39 @@ test("collection focus without choice copy is not an unrenderable current_questi
|
||||
assert.equal(projection.current_question, null);
|
||||
});
|
||||
|
||||
test("failed card persist still leaves a spoken collect next step", () => {
|
||||
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /if \(followup\?\.choice_frame\)/);
|
||||
assert.match(source, /intent: "collect_method_evidence"/);
|
||||
assert.match(source, /choice_frame: null/);
|
||||
assert.match(source, /请再说一件记得大概时间的经历/);
|
||||
assert.match(source, /当前几个候选已经构成可信区间/);
|
||||
});
|
||||
|
||||
test("spoken collect focus keeps current_question after refresh", () => {
|
||||
const snapshot = candidateSnapshotFixture();
|
||||
Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() });
|
||||
const dossier = parseV9CaseDossier(dossierFixture({
|
||||
latestResult: snapshot,
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
activeFocus: activeFocusFixture({
|
||||
intent: "collect_method_evidence",
|
||||
targetDomain: "family",
|
||||
expectedAnswerSchema: {
|
||||
prompt: "2021 年前后,家里有没有结婚、添丁或住院这类记得住时间的事?不记得具体日子也可以先说有没有。",
|
||||
collect: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
assert.ok(dossier);
|
||||
const projection = projectTurnDecision(dossier);
|
||||
const currentQuestion = projection.current_question as { prompt?: string; unrenderable?: boolean } | null;
|
||||
assert.equal(currentQuestion?.unrenderable, undefined);
|
||||
assert.match(currentQuestion?.prompt ?? "", /2021/);
|
||||
assert.equal(projection.current_question && typeof projection.current_question === "object", true);
|
||||
});
|
||||
|
||||
test("turn_decision hides current_probe unless a valid current_question exists", () => {
|
||||
const withFocus = projectTurnDecision(parseV9CaseDossier(choiceDossier())!);
|
||||
assert.ok(withFocus.current_question);
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import {
|
||||
inspectDiscriminatorProbes,
|
||||
withCompletedContrastOptions,
|
||||
type CandidateDiscriminatorProbe,
|
||||
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
|
||||
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import {
|
||||
contrastPacketFromDossier,
|
||||
decideFromDossier,
|
||||
followupAsksRenderableDiscriminator,
|
||||
rectificationFollowupCatalog,
|
||||
type DecisionDossier,
|
||||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
|
||||
const CAREER_2020_04: ConflictProbe = {
|
||||
id: "probe:career.2020.04.dasha_boundary",
|
||||
semantic_key: "career.2020.04.dasha_boundary",
|
||||
candidate_split_hash: "career.2020.04",
|
||||
domain: "career",
|
||||
year: 2020,
|
||||
question: "2020 年 4 月前后有没有入职或换工作?",
|
||||
candidate_ids: ["05:00", "05:10"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 0.9,
|
||||
source: "dasha_boundary",
|
||||
choice_kind: "existence",
|
||||
};
|
||||
|
||||
const REL_2024_05: ConflictProbe = {
|
||||
id: "probe:relationship.2024.05.dasha_boundary",
|
||||
semantic_key: "relationship.2024.05.dasha_boundary",
|
||||
candidate_split_hash: "relationship.2024.05",
|
||||
domain: "relationship",
|
||||
year: 2024,
|
||||
question: "2024 年 5 月前后有没有开始一段认真关系?",
|
||||
candidate_ids: ["05:00", "05:10"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 0.85,
|
||||
source: "dasha_boundary",
|
||||
choice_kind: "existence",
|
||||
};
|
||||
|
||||
const D24: ConflictProbe = {
|
||||
id: "contrast:varga.d24.05:00/05:10",
|
||||
semantic_key: "varga.d24.05:00/05:10",
|
||||
candidate_split_hash: "varga.d24.05:00/05:10",
|
||||
domain: "education",
|
||||
year: 0,
|
||||
question: "有没有学业或考试发挥明显失常、压力特别大的时候?",
|
||||
candidate_ids: ["05:00", "05:10"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 2.5,
|
||||
source: "varga_contrast",
|
||||
choice_kind: "event_quality",
|
||||
};
|
||||
|
||||
const D12: ConflictProbe = {
|
||||
id: "contrast:varga.d12.05:00/05:10",
|
||||
semantic_key: "varga.d12.05:00/05:10",
|
||||
candidate_split_hash: "varga.d12.05:00/05:10",
|
||||
domain: "family",
|
||||
year: 0,
|
||||
question: "家里有没有结婚、添丁或住院这类事?",
|
||||
candidate_ids: ["05:00", "05:10"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 0.8,
|
||||
source: "varga_contrast",
|
||||
choice_kind: "existence",
|
||||
};
|
||||
|
||||
const D9_STYLE_NO_OPTIONS: ConflictProbe = {
|
||||
id: "contrast:varga.d9.巨蟹座/狮子座",
|
||||
semantic_key: "varga.d9.巨蟹座/狮子座",
|
||||
candidate_split_hash: "varga.d9.巨蟹座/狮子座",
|
||||
domain: "relationship",
|
||||
year: 0,
|
||||
question: "亲密关系里更接近下面哪一种相处方式?",
|
||||
candidate_ids: ["05:00", "05:10"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "weak_yes", supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 1.4,
|
||||
source: "varga_contrast",
|
||||
choice_kind: "varga_style",
|
||||
};
|
||||
|
||||
const D10_STYLE_NO_OPTIONS: ConflictProbe = {
|
||||
id: "contrast:varga.d10.天秤座/天蝎座",
|
||||
semantic_key: "varga.d10.天秤座/天蝎座",
|
||||
candidate_split_hash: "varga.d10.天秤座/天蝎座",
|
||||
domain: "career",
|
||||
year: 0,
|
||||
question: "平时做事更接近下面哪一种职责风格?",
|
||||
candidate_ids: ["05:00", "05:10"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "weak_yes", supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 1.2,
|
||||
source: "varga_contrast",
|
||||
choice_kind: "varga_style",
|
||||
};
|
||||
|
||||
const FAMILY_2021_COLLECT = {
|
||||
year: 2021,
|
||||
year_label: "2021 年前后",
|
||||
domain: "family" as const,
|
||||
event_family: "家人结婚、添丁或住院",
|
||||
source: "age_band" as const,
|
||||
tracks: ["vimshottari", "narayana"] as const,
|
||||
tracks_agree: false,
|
||||
unique_minute_claim: false as const,
|
||||
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
|
||||
role: "collect" as const,
|
||||
phase: "evidence_collection" as const,
|
||||
information_gain: 0,
|
||||
semantic_key: "family.2021",
|
||||
candidate_split_hash: "family:2021",
|
||||
candidate_ids: [] as const,
|
||||
expected_outcomes: [] as const,
|
||||
choice_kind: "existence" as const,
|
||||
};
|
||||
|
||||
const EVIDENCE = [
|
||||
{
|
||||
id: "e-career-entry",
|
||||
status: "confirmed",
|
||||
domain: "career",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2020-04-01",
|
||||
occurredTo: null,
|
||||
eventKind: "career_entry",
|
||||
},
|
||||
{
|
||||
id: "e-career-exit",
|
||||
status: "confirmed",
|
||||
domain: "career",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2020-10-01",
|
||||
occurredTo: null,
|
||||
eventKind: "career_exit",
|
||||
},
|
||||
{
|
||||
id: "e-rel-start",
|
||||
status: "confirmed",
|
||||
domain: "relationship",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2024-05-01",
|
||||
occurredTo: null,
|
||||
eventKind: "relationship_start",
|
||||
},
|
||||
{
|
||||
id: "e-rel-end",
|
||||
status: "confirmed",
|
||||
domain: "relationship",
|
||||
datePrecision: "day",
|
||||
occurredFrom: "2024-08-08",
|
||||
occurredTo: null,
|
||||
eventKind: "relationship_end",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function contrastFromConflict(probe: ConflictProbe): CandidateDiscriminatorProbe {
|
||||
return {
|
||||
probeId: probe.id,
|
||||
candidateSetVersion: "05:00-05:10",
|
||||
question: probe.question,
|
||||
expectedOutcomes: probe.expected_outcomes.map((row) => ({
|
||||
outcomeId: row.answer_class,
|
||||
supportsCandidateIds: row.supports,
|
||||
conflictsCandidateIds: row.conflicts,
|
||||
})),
|
||||
candidateSplitHash: probe.candidate_split_hash,
|
||||
informationGain: probe.information_gain,
|
||||
sourceFeatures: [{ technique: probe.domain, calculationResultId: null }],
|
||||
domain: probe.domain,
|
||||
year: probe.year > 0 ? probe.year : null,
|
||||
semanticKey: probe.semantic_key,
|
||||
choiceKind: probe.choice_kind,
|
||||
};
|
||||
}
|
||||
|
||||
function deadlockDossier(): { dossier: DecisionDossier; state: ReturnType<typeof buildInferenceState> } {
|
||||
const state = buildInferenceState({
|
||||
range_start: "05:00",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 18 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 16 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" },
|
||||
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" },
|
||||
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" },
|
||||
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" },
|
||||
],
|
||||
probes: [CAREER_2020_04, REL_2024_05, D24, D12, D9_STYLE_NO_OPTIONS, D10_STYLE_NO_OPTIONS],
|
||||
answered_probes: [
|
||||
{
|
||||
probe_id: CAREER_2020_04.id,
|
||||
semantic_key: CAREER_2020_04.semantic_key,
|
||||
candidate_split_hash: CAREER_2020_04.candidate_split_hash,
|
||||
answer_class: "yes",
|
||||
classified_from: "choice",
|
||||
},
|
||||
{
|
||||
probe_id: REL_2024_05.id,
|
||||
semantic_key: REL_2024_05.semantic_key,
|
||||
candidate_split_hash: REL_2024_05.candidate_split_hash,
|
||||
answer_class: "yes",
|
||||
classified_from: "choice",
|
||||
},
|
||||
],
|
||||
});
|
||||
const dossier: DecisionDossier = {
|
||||
evidence: EVIDENCE,
|
||||
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
|
||||
latestResult: {
|
||||
resultId: "55555555-5555-4555-8555-555555555555",
|
||||
candidates: state.candidates.map((item) => ({
|
||||
candidateId: item.id,
|
||||
time: item.time,
|
||||
rank: item.rank,
|
||||
relativeSupport: Math.round(item.posterior_score),
|
||||
})),
|
||||
representativeTime: state.representative_time,
|
||||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never),
|
||||
decisionReceipt: {
|
||||
inference_state: state,
|
||||
evidence_collection_probes: [FAMILY_2021_COLLECT],
|
||||
},
|
||||
},
|
||||
case: { acceptedTime: null },
|
||||
};
|
||||
return { dossier, state };
|
||||
}
|
||||
|
||||
test("deadlock case: decision and followup agree and still render a D9/D10 style card", () => {
|
||||
const { dossier } = deadlockDossier();
|
||||
const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(dossier));
|
||||
assert.equal(inspected.selected?.semanticKey, D24.semantic_key);
|
||||
|
||||
const d9 = withCompletedContrastOptions(contrastFromConflict(D9_STYLE_NO_OPTIONS));
|
||||
const d10 = withCompletedContrastOptions(contrastFromConflict(D10_STYLE_NO_OPTIONS));
|
||||
assert.equal(d9.ok, true);
|
||||
assert.equal(d10.ok, true);
|
||||
if (d9.ok) assert.equal(d9.probe.choiceKind, "varga_style");
|
||||
if (d10.ok) assert.equal(d10.probe.choiceKind, "varga_style");
|
||||
|
||||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
...catalog,
|
||||
candidatesSeparated: false,
|
||||
});
|
||||
assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), true);
|
||||
assert.equal(plan.next_followup?.intent, "distinguish_candidates");
|
||||
assert.ok(plan.next_followup?.choice_frame);
|
||||
assert.equal(plan.next_followup?.choice_kind, "varga_style");
|
||||
assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d(9|10)\./);
|
||||
assert.equal(
|
||||
plan.dropped_probes.some((item) => item.semantic_key.startsWith("varga.d9.") || item.semantic_key.startsWith("varga.d10.")),
|
||||
false,
|
||||
JSON.stringify(plan.dropped_probes),
|
||||
);
|
||||
|
||||
const decision = decideFromDossier(dossier);
|
||||
assert.equal(decision.nextAction, "ask_candidate_discriminator");
|
||||
assert.equal(decision.sessionOutcome, "discriminate_candidates");
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
});
|
||||
|
||||
test("varga_style probes missing style_options complete the same way in inspect and followup ranking", () => {
|
||||
const probe = contrastFromConflict(D9_STYLE_NO_OPTIONS);
|
||||
const inspected = inspectDiscriminatorProbes({
|
||||
candidateSetVersion: "05:00-05:10",
|
||||
vargaDifferences: [],
|
||||
probes: [probe],
|
||||
});
|
||||
assert.ok(inspected.selected);
|
||||
assert.equal(inspected.selected?.choiceKind, "varga_style");
|
||||
assert.equal(inspected.dropped.length, 0);
|
||||
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: EVIDENCE,
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
candidatesSeparated: false,
|
||||
contrastPacket: {
|
||||
candidateSetVersion: "05:00-05:10",
|
||||
vargaDifferences: [],
|
||||
probes: [probe],
|
||||
},
|
||||
});
|
||||
assert.equal(plan.next_followup?.semantic_key, probe.semanticKey);
|
||||
assert.equal(plan.next_followup?.choice_kind, "varga_style");
|
||||
assert.ok(plan.next_followup?.choice_frame);
|
||||
assert.equal(plan.dropped_probes.some((item) => item.semantic_key === probe.semanticKey), false);
|
||||
});
|
||||
|
||||
test("method coverage incomplete still asks a renderable D9 style card", () => {
|
||||
const { dossier } = deadlockDossier();
|
||||
const decision = decideFromDossier(dossier);
|
||||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: [],
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
...catalog,
|
||||
candidatesSeparated: false,
|
||||
});
|
||||
assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered");
|
||||
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
|
||||
assert.equal(decision.nextAction, "ask_candidate_discriminator");
|
||||
assert.equal(plan.next_followup?.intent, "distinguish_candidates");
|
||||
assert.ok(plan.next_followup?.choice_frame);
|
||||
assert.notEqual(plan.next_followup?.source, "method_coverage");
|
||||
});
|
||||
|
||||
test("clock-key varga_style without signs fail-closes to collect instead of a dead-end discriminator", () => {
|
||||
const d9Clock: ConflictProbe = {
|
||||
...D9_STYLE_NO_OPTIONS,
|
||||
id: "contrast:varga.d9.05:00/05:10",
|
||||
semantic_key: "varga.d9.05:00/05:10",
|
||||
candidate_split_hash: "varga.d9.05:00/05:10",
|
||||
};
|
||||
const d10Clock: ConflictProbe = {
|
||||
...D10_STYLE_NO_OPTIONS,
|
||||
id: "contrast:varga.d10.05:00/05:10",
|
||||
semantic_key: "varga.d10.05:00/05:10",
|
||||
candidate_split_hash: "varga.d10.05:00/05:10",
|
||||
};
|
||||
const { dossier } = deadlockDossier();
|
||||
const state = buildInferenceState({
|
||||
range_start: "05:00",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 18 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 16 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" },
|
||||
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" },
|
||||
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" },
|
||||
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" },
|
||||
],
|
||||
probes: [CAREER_2020_04, REL_2024_05, D24, D12, d9Clock, d10Clock],
|
||||
answered_probes: [
|
||||
{
|
||||
probe_id: CAREER_2020_04.id,
|
||||
semantic_key: CAREER_2020_04.semantic_key,
|
||||
candidate_split_hash: CAREER_2020_04.candidate_split_hash,
|
||||
answer_class: "yes",
|
||||
classified_from: "choice",
|
||||
},
|
||||
{
|
||||
probe_id: REL_2024_05.id,
|
||||
semantic_key: REL_2024_05.semantic_key,
|
||||
candidate_split_hash: REL_2024_05.candidate_split_hash,
|
||||
answer_class: "yes",
|
||||
classified_from: "choice",
|
||||
},
|
||||
],
|
||||
});
|
||||
const clockDossier: DecisionDossier = {
|
||||
...dossier,
|
||||
latestResult: {
|
||||
...dossier.latestResult!,
|
||||
decisionReceipt: {
|
||||
inference_state: state,
|
||||
evidence_collection_probes: [FAMILY_2021_COLLECT],
|
||||
},
|
||||
},
|
||||
};
|
||||
const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(clockDossier));
|
||||
assert.equal(inspected.selected?.semanticKey, D24.semantic_key);
|
||||
assert.equal(inspected.selected?.choiceKind, "event_quality");
|
||||
const d9 = withCompletedContrastOptions(contrastFromConflict(d9Clock));
|
||||
assert.equal(d9.ok, true);
|
||||
if (d9.ok) assert.equal(d9.probe.choiceKind, "existence");
|
||||
|
||||
const catalog = rectificationFollowupCatalog(clockDossier.latestResult, clockDossier.evidence);
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: clockDossier.evidence,
|
||||
declinedTopics: [],
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
...catalog,
|
||||
candidatesSeparated: false,
|
||||
});
|
||||
assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false);
|
||||
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
||||
assert.equal(plan.next_followup?.choice_frame, null);
|
||||
assert.equal(plan.next_followup?.source, "method_coverage");
|
||||
|
||||
const decision = decideFromDossier(clockDossier);
|
||||
assert.notEqual(decision.nextAction, "ask_candidate_discriminator");
|
||||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
});
|
||||
|
||||
test("unrenderable contrast probes are recorded in dropped_probes instead of being dropped silently", () => {
|
||||
const unrenderable = contrastFromConflict({
|
||||
...D9_STYLE_NO_OPTIONS,
|
||||
information_gain: 0,
|
||||
semantic_key: "varga.d9.dropped",
|
||||
candidate_split_hash: "varga.d9.dropped",
|
||||
id: "contrast:varga.d9.dropped",
|
||||
});
|
||||
const inspected = inspectDiscriminatorProbes({
|
||||
candidateSetVersion: "05:00-05:10",
|
||||
vargaDifferences: [],
|
||||
probes: [unrenderable],
|
||||
});
|
||||
assert.equal(inspected.selected, null);
|
||||
assert.equal(inspected.dropped.some((item) => item.semantic_key === unrenderable.semanticKey), true);
|
||||
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: EVIDENCE,
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
candidatesSeparated: false,
|
||||
contrastPacket: {
|
||||
candidateSetVersion: "05:00-05:10",
|
||||
vargaDifferences: [],
|
||||
probes: [unrenderable],
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
plan.dropped_probes.some((item) => item.semantic_key === unrenderable.semanticKey),
|
||||
true,
|
||||
JSON.stringify(plan.dropped_probes),
|
||||
);
|
||||
});
|
||||
|
||||
test("agent message path no longer returns the discriminator dead-end copy", () => {
|
||||
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||||
const fastPath = route.slice(
|
||||
route.indexOf('if (action === "message")'),
|
||||
route.indexOf("const requestTime"),
|
||||
);
|
||||
assert.doesNotMatch(fastPath, /目前没有可继续区分/);
|
||||
assert.match(route, /当前几个候选已经构成可信区间/);
|
||||
assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION/);
|
||||
assert.match(fastPath, /!plan\.next_followup/);
|
||||
assert.ok(fastPath.includes("ask_candidate_discriminator"));
|
||||
assert.ok(fastPath.includes("open.unrenderable !== true"));
|
||||
assert.ok(route.indexOf("classifyRectificationTurnIntent") < route.indexOf("runV9AgentTurn({"));
|
||||
});
|
||||
@@ -432,3 +432,50 @@ test("contrast probe is not replaced by an already-answered education quality pr
|
||||
assert.notEqual(schema.probe_id, "probe:education.2016");
|
||||
assert.match(String(schema.probe_id), /varga\.d24/);
|
||||
});
|
||||
|
||||
test("spoken collect followup persists a collect focus without a choice card", async () => {
|
||||
const followup: MethodFollowup = {
|
||||
method_id: "relatives",
|
||||
intent: "collect_method_evidence",
|
||||
ask_theme: "family_event",
|
||||
domain: "family",
|
||||
kind_hint: "family_event",
|
||||
user_prompt_hint: "collect",
|
||||
must_not_label: false,
|
||||
choice_frame: null,
|
||||
source: "method_coverage",
|
||||
probe_year: 2021,
|
||||
year_label: "2021 年前后",
|
||||
semantic_key: "family.2021",
|
||||
};
|
||||
const accounting = fakeAccounting({
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
id: FOCUS_ID,
|
||||
case_id: CASE_ID,
|
||||
question_id: args.p_question_id,
|
||||
intent: args.p_intent,
|
||||
target_evidence_id: null,
|
||||
target_domain: args.p_target_domain,
|
||||
target_kind: args.p_target_kind,
|
||||
expected_answer_schema: args.p_expected_answer_schema,
|
||||
status: "active",
|
||||
asked_at: "2026-08-27T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const result = await persistServerOwnedFocus({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
activeFocus: null,
|
||||
decisionReceipt: null,
|
||||
followup,
|
||||
});
|
||||
assert.equal(result.status, "created");
|
||||
assert.equal(result.focus?.intent, "collect_method_evidence");
|
||||
assert.equal(result.focus?.expectedAnswerSchema.collect, true);
|
||||
assert.equal(result.focus?.expectedAnswerSchema.choice, undefined);
|
||||
assert.match(String(result.focus?.expectedAnswerSchema.prompt ?? ""), /2021/);
|
||||
assert.equal(openQuestionFromPersistedFocus(result), null);
|
||||
});
|
||||
|
||||
@@ -105,6 +105,9 @@ test("production intent handling contains no semantic regex or positional text p
|
||||
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(fastPath, /目前没有可继续区分/);
|
||||
assert.match(route, /当前几个候选已经构成可信区间/);
|
||||
assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION/);
|
||||
assert.match(fastPath, /!plan\.next_followup/);
|
||||
assert.doesNotMatch(route, /classified\.answer_class!/);
|
||||
});
|
||||
|
||||
@@ -161,6 +161,13 @@ test("varga_style B-option weak_yes counts as strong conflict; existence weak_ye
|
||||
assert.equal(existenceCounts[existenceConflicted], 0);
|
||||
});
|
||||
|
||||
test("varga_style contrast writeback keeps style_options for the next round", () => {
|
||||
const probe = styleProbeFrom(twoGroupStylePacket());
|
||||
assert.equal(probe.choice_kind, "varga_style");
|
||||
assert.ok((probe.style_options?.length ?? 0) >= 2);
|
||||
assert.ok(probe.style_options?.every((item) => item.label.trim().length > 0));
|
||||
});
|
||||
|
||||
test("engine varga.d9/d10 without style_options scores with the render effective kind", () => {
|
||||
for (const semanticKey of ["varga.d9", "varga.d10"] as const) {
|
||||
const packet = {
|
||||
|
||||
Reference in New Issue
Block a user