fix(rectification): require three answers before uncertainty stop
One "一时说不好" no longer ends the interview. Plateau streaks use the same sample floor. Delivery copy states why the range stopped. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
engineMeaningToDisplayCopy,
|
||||
listUserVisibleCopy,
|
||||
nonConvergingRangeNarration,
|
||||
stopReasonPrefix,
|
||||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
|
||||
|
||||
@@ -79,6 +80,24 @@ test("default range narration stays semantic even without an opening window", ()
|
||||
assert.equal(containsBoundarySemantics(text), true);
|
||||
});
|
||||
|
||||
test("delivery range narration puts the stop reason before the progress clause", () => {
|
||||
assert.equal(
|
||||
stopReasonPrefix("user_uncertainty_too_high"),
|
||||
RECTIFICATION_USER_COPY.uncertaintyStop,
|
||||
);
|
||||
assert.equal(stopReasonPrefix("tied_first"), RECTIFICATION_USER_COPY.tiedFirstStop);
|
||||
const text = nonConvergingRangeNarration({
|
||||
credibleRange: ["14:02", "14:43"],
|
||||
representativeTime: "14:43",
|
||||
openingRange: ["14:00", "15:00"],
|
||||
stopReason: "user_uncertainty_too_high",
|
||||
});
|
||||
assert.match(text, /^前面几道题你多半选了"说不好"/);
|
||||
assert.match(text, /已经从最初的 60 分钟收到 14:02–14:43 这 41 分钟/);
|
||||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.uncertaintyStop));
|
||||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.tiedFirstStop));
|
||||
});
|
||||
|
||||
test("question stem ownership stays on set-focus spokenPrompt, not a slot or a second turn", () => {
|
||||
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
|
||||
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||||
|
||||
@@ -805,6 +805,48 @@ test("deferFollowup scores the choice without persisting the next interview or t
|
||||
assert.equal(applied.narrationPersisted, false);
|
||||
});
|
||||
|
||||
test("one unsure discriminator answer does not adopt and persists the next question", async () => {
|
||||
const accounting = persistChoiceAccounting(twoProbeDossier(), {
|
||||
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,
|
||||
caseId: CASE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
actionId: ACTION_ID,
|
||||
action: CHOICE_ACTION,
|
||||
focusId: FOCUS_ID,
|
||||
questionId: QUESTION_ID,
|
||||
probeId: RELOCATION_2015_PROBE.id,
|
||||
optionId: "D",
|
||||
expectedRevision: twoProbeInference().revision,
|
||||
});
|
||||
assert.equal(applied.answerClass, "unsure");
|
||||
assert.equal(applied.narration, "已记录。这题先不计分,换一件事问。");
|
||||
assert.notEqual(applied.nextAction.session_outcome, "adopt_representative");
|
||||
assert.equal(applied.nextAction.type, "ask_candidate_discriminator");
|
||||
assert.equal(applied.nextInterviewPersisted, true);
|
||||
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
|
||||
assert.ok(persist?.args.p_inference);
|
||||
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
|
||||
assert.ok(setFocus);
|
||||
});
|
||||
|
||||
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: (_fn, args) => ({
|
||||
@@ -1116,11 +1158,29 @@ test("structured choice narration never persists a fake loading state", () => {
|
||||
composeChoiceNarration({ optionId: "A", scoring: true, appliedInference: true }),
|
||||
composeChoiceNarration({ optionId: "A", scoring: true, appliedInference: false }),
|
||||
composeChoiceNarration({ optionId: "A", scoring: false, appliedInference: false }),
|
||||
composeChoiceNarration({
|
||||
optionId: "D",
|
||||
scoring: true,
|
||||
appliedInference: true,
|
||||
answerClass: "unsure",
|
||||
}),
|
||||
]) {
|
||||
assert.doesNotMatch(narration, /正在准备下一步/);
|
||||
}
|
||||
});
|
||||
|
||||
test("an unsure choice keeps applied inference and does not claim scores changed", () => {
|
||||
const narration = composeChoiceNarration({
|
||||
optionId: "D",
|
||||
scoring: true,
|
||||
appliedInference: true,
|
||||
answerClass: "unsure",
|
||||
});
|
||||
// 旧:appliedInference 仍说「更新了候选比较」→ 新:unsure 不计分旁白 → 保留 inference 行仍持久化
|
||||
assert.equal(narration, "已记录。这题先不计分,换一件事问。");
|
||||
assert.doesNotMatch(narration, /更新了候选比较/);
|
||||
});
|
||||
|
||||
test("the public agent route treats structured choice as a non-model command", () => {
|
||||
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||||
const start = route.indexOf("if (isStructuredChoice)");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
|
||||
@@ -7,7 +8,9 @@ import { EFFECTIVE_ANSWER_SAFETY_CAP } from "../src/lib/birth-time-dynamic-stop-
|
||||
import {
|
||||
DEFAULT_MAX_DISCRIMINATION_ROUNDS,
|
||||
INFERENCE_ALGORITHM_VERSION,
|
||||
type ConflictProbe,
|
||||
type InferenceState,
|
||||
type RoundTrace,
|
||||
} from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import {
|
||||
MIN_STANDALONE_DATED_DOMAINS,
|
||||
@@ -92,13 +95,22 @@ function dossier(rows: DecisionDossier["evidence"], state?: InferenceState): Dec
|
||||
candidates: DOSSIER_CANDIDATES,
|
||||
representativeTime: "05:00",
|
||||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(rows as never),
|
||||
decisionReceipt: state ? { inference_state: state } : null,
|
||||
decisionReceipt: {
|
||||
acceptance_allowed: true,
|
||||
selection_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: true,
|
||||
...(state ? { inference_state: state } : {}),
|
||||
},
|
||||
},
|
||||
case: { acceptedTime: null },
|
||||
};
|
||||
}
|
||||
|
||||
function inferenceState(answers: InferenceState["answered_probes"]): InferenceState {
|
||||
function inferenceState(
|
||||
answers: InferenceState["answered_probes"],
|
||||
extra: Partial<InferenceState> = {},
|
||||
): InferenceState {
|
||||
return {
|
||||
algorithm_version: INFERENCE_ALGORITHM_VERSION,
|
||||
candidate_set_id: candidateSetId("05:00", "05:07", DOSSIER_CANDIDATES.map((item) => item.time)),
|
||||
@@ -118,13 +130,67 @@ function inferenceState(answers: InferenceState["answered_probes"]): InferenceSt
|
||||
rank: item.rank,
|
||||
strong_conflict_count: 0,
|
||||
})),
|
||||
events: [],
|
||||
events: [
|
||||
{ id: "e1", domain: "career", year: 2011, precision: "year", usage: "training" },
|
||||
{ id: "e2", domain: "relationship", year: 2012, precision: "year", usage: "training" },
|
||||
{ id: "e3", domain: "family", year: 2013, precision: "year", usage: "training" },
|
||||
],
|
||||
probes: [],
|
||||
answered_probes: answers,
|
||||
rounds: [],
|
||||
entropy: 1,
|
||||
representative_time: "05:00",
|
||||
credible_range: ["05:00", "05:07"],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function remainingProbe(id: string, year: number): ConflictProbe {
|
||||
return {
|
||||
id,
|
||||
semantic_key: `finance.${year}.test`,
|
||||
candidate_split_hash: `split-${id}`,
|
||||
domain: "finance",
|
||||
year,
|
||||
question: `${year} 年前后收入有没有明显变化?`,
|
||||
candidate_ids: ["05:00", "05:06", "05:07"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] },
|
||||
{ answer_class: "no", supports: ["05:06", "05:07"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 0.5,
|
||||
source: "dasha_boundary",
|
||||
};
|
||||
}
|
||||
|
||||
function lowInformationRounds(count: number): RoundTrace[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
round: index + 1,
|
||||
phase: "discrimination" as const,
|
||||
probe_id: `probe-${index + 1}`,
|
||||
scores_before: {},
|
||||
scores_after: {},
|
||||
entropy_before: 1,
|
||||
entropy_after: 1,
|
||||
eliminated_ids: [],
|
||||
winner_id: null,
|
||||
kind: "low_information" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function choiceAnswer(
|
||||
probeId: string,
|
||||
year: number,
|
||||
answerClass: "yes" | "no" | "unsure",
|
||||
classifiedFrom: "choice" | "declined" = "choice",
|
||||
) {
|
||||
return {
|
||||
probe_id: probeId,
|
||||
semantic_key: `career.${year}.test`,
|
||||
candidate_split_hash: `split-${probeId}`,
|
||||
answer_class: answerClass,
|
||||
classified_from: classifiedFrom,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -340,39 +406,62 @@ test("dossier and post-inference decisions share the half-uncertain stop rule",
|
||||
evidence("career", 1),
|
||||
evidence("relationship", 2),
|
||||
evidence("family", 3),
|
||||
evidence("education", 4),
|
||||
];
|
||||
const nextProbe = remainingProbe("probe-next", 2023);
|
||||
const oneUnsure = inferenceState(
|
||||
[choiceAnswer("probe-1", 2020, "unsure")],
|
||||
{ probes: [remainingProbe("probe-1", 2020), nextProbe] },
|
||||
);
|
||||
const oneUnsureDecision = decideFromDossier(dossier(rows, oneUnsure));
|
||||
// 旧:1 答 1 unsure 即停 → 新:不足 3 答不停 → 保留 达到下限后一半不确定仍停
|
||||
assert.equal(oneUnsureDecision.stopReason ?? null, null);
|
||||
assert.equal(oneUnsureDecision.nextAction, "ask_candidate_discriminator");
|
||||
assert.equal(decideAfterInferenceChange({
|
||||
dossier: dossier(rows),
|
||||
state: oneUnsure,
|
||||
userStopped: false,
|
||||
}).nextAction, "ask_candidate_discriminator");
|
||||
|
||||
const halfUncertain = inferenceState([
|
||||
{
|
||||
probe_id: "probe-1",
|
||||
semantic_key: "career.2020.test",
|
||||
candidate_split_hash: "split-1",
|
||||
answer_class: "unsure",
|
||||
classified_from: "choice",
|
||||
},
|
||||
{
|
||||
probe_id: "probe-2",
|
||||
semantic_key: "relationship.2021.test",
|
||||
candidate_split_hash: "split-2",
|
||||
answer_class: "yes",
|
||||
classified_from: "choice",
|
||||
},
|
||||
choiceAnswer("probe-1", 2020, "unsure"),
|
||||
choiceAnswer("probe-2", 2021, "yes"),
|
||||
]);
|
||||
assert.equal(decideFromDossier(dossier(rows, halfUncertain)).stopReason, "user_uncertainty_too_high");
|
||||
assert.equal(decideFromDossier(dossier(rows, halfUncertain)).stopReason ?? null, null);
|
||||
assert.equal(decideAfterInferenceChange({
|
||||
dossier: dossier(rows),
|
||||
state: halfUncertain,
|
||||
userStopped: false,
|
||||
}).stopReason ?? null, null);
|
||||
|
||||
const threeAnswersTwoUnsure = inferenceState([
|
||||
choiceAnswer("probe-1", 2020, "unsure"),
|
||||
choiceAnswer("probe-2", 2021, "yes"),
|
||||
choiceAnswer("probe-3", 2022, "unsure"),
|
||||
]);
|
||||
assert.equal(decideFromDossier(dossier(rows, threeAnswersTwoUnsure)).stopReason, "user_uncertainty_too_high");
|
||||
assert.equal(decideAfterInferenceChange({
|
||||
dossier: dossier(rows),
|
||||
state: threeAnswersTwoUnsure,
|
||||
userStopped: false,
|
||||
}).stopReason, "user_uncertainty_too_high");
|
||||
|
||||
const fourAnswersTwoUnsure = inferenceState([
|
||||
choiceAnswer("probe-1", 2020, "unsure"),
|
||||
choiceAnswer("probe-2", 2021, "yes"),
|
||||
choiceAnswer("probe-3", 2022, "unsure"),
|
||||
choiceAnswer("probe-4", 2023, "yes"),
|
||||
]);
|
||||
assert.equal(decideAfterInferenceChange({
|
||||
dossier: dossier(rows),
|
||||
state: fourAnswersTwoUnsure,
|
||||
userStopped: false,
|
||||
}).stopReason, "user_uncertainty_too_high");
|
||||
|
||||
const belowHalf = inferenceState([
|
||||
...halfUncertain.answered_probes,
|
||||
{
|
||||
probe_id: "probe-3",
|
||||
semantic_key: "family.2022.test",
|
||||
candidate_split_hash: "split-3",
|
||||
answer_class: "no",
|
||||
classified_from: "choice",
|
||||
},
|
||||
choiceAnswer("probe-1", 2020, "unsure"),
|
||||
choiceAnswer("probe-2", 2021, "yes"),
|
||||
choiceAnswer("probe-3", 2022, "no"),
|
||||
]);
|
||||
assert.equal(decideAfterInferenceChange({
|
||||
dossier: dossier(rows),
|
||||
@@ -381,7 +470,9 @@ test("dossier and post-inference decisions share the half-uncertain stop rule",
|
||||
}).stopReason ?? null, null);
|
||||
|
||||
const evidenceDoesNotDilute = inferenceState([
|
||||
...halfUncertain.answered_probes,
|
||||
choiceAnswer("probe-1", 2020, "unsure"),
|
||||
choiceAnswer("probe-2", 2021, "yes"),
|
||||
choiceAnswer("probe-3", 2022, "unsure"),
|
||||
...[1, 2, 3].map((index) => ({
|
||||
probe_id: `evidence-${index}`,
|
||||
semantic_key: `evidence.${index}`,
|
||||
@@ -397,14 +488,9 @@ test("dossier and post-inference decisions share the half-uncertain stop rule",
|
||||
}).stopReason, "user_uncertainty_too_high");
|
||||
|
||||
const halfDeclined = inferenceState([
|
||||
{
|
||||
probe_id: "probe-1",
|
||||
semantic_key: "career.2020.test",
|
||||
candidate_split_hash: "split-1",
|
||||
answer_class: "no",
|
||||
classified_from: "declined",
|
||||
},
|
||||
halfUncertain.answered_probes[1],
|
||||
choiceAnswer("probe-1", 2020, "no", "declined"),
|
||||
choiceAnswer("probe-2", 2021, "yes"),
|
||||
choiceAnswer("probe-3", 2022, "unsure"),
|
||||
]);
|
||||
assert.equal(decideAfterInferenceChange({
|
||||
dossier: dossier(rows),
|
||||
@@ -413,6 +499,61 @@ test("dossier and post-inference decisions share the half-uncertain stop rule",
|
||||
}).stopReason, "user_uncertainty_too_high");
|
||||
});
|
||||
|
||||
test("unsure plateau rounds wait for the uncertainty sample floor", () => {
|
||||
const rows = [
|
||||
evidence("career", 1),
|
||||
evidence("relationship", 2),
|
||||
evidence("family", 3),
|
||||
evidence("education", 4),
|
||||
];
|
||||
const twoUnsure = inferenceState(
|
||||
[choiceAnswer("probe-1", 2020, "unsure"), choiceAnswer("probe-2", 2021, "unsure")],
|
||||
{
|
||||
probes: [remainingProbe("probe-next", 2023)],
|
||||
rounds: lowInformationRounds(2),
|
||||
},
|
||||
);
|
||||
const early = decideAfterInferenceChange({
|
||||
dossier: dossier(rows),
|
||||
state: twoUnsure,
|
||||
userStopped: false,
|
||||
});
|
||||
assert.equal(early.stopReason ?? null, null);
|
||||
assert.notEqual(early.nextAction, "complete_with_range");
|
||||
assert.notEqual(early.sessionOutcome, "adopt_representative");
|
||||
|
||||
const afterFloor = inferenceState(
|
||||
[
|
||||
choiceAnswer("probe-1", 2020, "yes"),
|
||||
choiceAnswer("probe-2", 2021, "yes"),
|
||||
choiceAnswer("probe-3", 2022, "yes"),
|
||||
choiceAnswer("probe-4", 2023, "unsure"),
|
||||
choiceAnswer("probe-5", 2024, "unsure"),
|
||||
],
|
||||
{
|
||||
probes: [remainingProbe("probe-next", 2025)],
|
||||
rounds: lowInformationRounds(2),
|
||||
},
|
||||
);
|
||||
const plateaued = decideAfterInferenceChange({
|
||||
dossier: dossier(rows),
|
||||
state: afterFloor,
|
||||
userStopped: false,
|
||||
});
|
||||
assert.equal(plateaued.nextAction, "complete_with_range");
|
||||
assert.equal(plateaued.sessionOutcome, "adopt_representative");
|
||||
});
|
||||
|
||||
test("minUncertaintyAnswers is defined once in policy json", () => {
|
||||
assert.equal(RECTIFICATION_POLICY.minUncertaintyAnswers, 3);
|
||||
assert.equal(RECTIFICATION_POLICY.maxPlateauRounds, 2);
|
||||
const policy = readFileSync(
|
||||
new URL("../../references/rectification_policy.v1.json", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.equal([...policy.matchAll(/minUncertaintyAnswers/g)].length, 1);
|
||||
});
|
||||
|
||||
test("rectification label ladder follows evidence state and explicit adapter support", () => {
|
||||
const blocked = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
|
||||
@@ -673,6 +673,8 @@ test("public decision fields are derived from decideRectification", () => {
|
||||
precision_stage: decision.precisionStage,
|
||||
representative_time: decision.representativeTime,
|
||||
credible_range: decision.credibleRange,
|
||||
// 旧 公开字段无 stop_reason → 新 只读暴露停止原因 → 保留 采用/确认门字段语义不变
|
||||
stop_reason: decision.stopReason ?? null,
|
||||
});
|
||||
const publicOverlay = overlayPublicDecision({ selectionAllowed: true }, decision);
|
||||
assert.equal(publicOverlay.selectionAllowed, false);
|
||||
|
||||
Reference in New Issue
Block a user