fix(rectification): require three answers before uncertainty stop
Independent Staging Quality Gate / validate (push) Successful in 9m19s
Independent Staging Quality Gate / publish (push) Successful in 1m52s

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:
Jesse_Chen
2026-09-03 12:08:37 +08:00
parent e8c98c37cf
commit 0c0df42679
11 changed files with 335 additions and 51 deletions
@@ -628,6 +628,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{
precision_stage: DerivedPrecisionStage;
representative_time: string | null;
credible_range: readonly [string, string] | null;
stop_reason: EvidenceStopReason | null;
}> {
return {
type: decision.nextAction,
@@ -642,6 +643,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{
precision_stage: decision.precisionStage,
representative_time: decision.representativeTime,
credible_range: decision.credibleRange,
stop_reason: decision.stopReason ?? null,
};
}
@@ -62,6 +62,8 @@ export const RECTIFICATION_USER_COPY = {
adoptCue: "可以从下面选一个先用着。",
hostNarrationFallback: "我按现有材料继续往下收。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
} as const;
export type RangeNarrationVariant = "delivery" | "intermediate";
@@ -71,6 +73,7 @@ export type RangeNarrationInput = {
representativeTime?: string | null;
openingRange?: readonly [string, string] | null;
variant?: RangeNarrationVariant;
stopReason?: string | null;
};
function clockMinutes(value: string): number | null {
@@ -104,6 +107,12 @@ export function containsBoundarySemantics(text: string): boolean {
return BOUNDARY_SEMANTIC_PHRASES.some((phrase) => text.includes(phrase));
}
export function stopReasonPrefix(reason: string | null | undefined): string | null {
if (reason === "user_uncertainty_too_high") return RECTIFICATION_USER_COPY.uncertaintyStop;
if (reason === "tied_first") return RECTIFICATION_USER_COPY.tiedFirstStop;
return null;
}
function progressClause(
openingRange: readonly [string, string] | null | undefined,
rangeText: string,
@@ -127,6 +136,7 @@ export function nonConvergingRangeNarration(
const progress = range && rangeText
? progressClause(input.openingRange, rangeText, range)
: null;
const reason = variant === "delivery" ? stopReasonPrefix(input.stopReason) : null;
if (variant === "intermediate") {
if (rangeText && representative) {
@@ -144,16 +154,20 @@ export function nonConvergingRangeNarration(
if (rangeText && representative) {
const head = progress ?? `更站得住的范围是 ${rangeText},代表分钟 ${representative}`;
const representativeClause = progress ? `。代表分钟是 ${representative}` : ``;
return `${head}${representativeClause}${boundaryCopy}`;
const body = `${head}${representativeClause}${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
}
if (rangeText) {
const head = progress ?? `更站得住的范围是 ${rangeText}`;
return `${head}${boundaryCopy}`;
const body = `${head}${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
}
if (representative) {
return `当前代表分钟 ${representative}${boundaryCopy}`;
const body = `当前代表分钟 ${representative}${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
}
return `当前几个候选还分不开。${boundaryCopy}`;
const body = `当前几个候选还分不开。${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
}
export function deliveryAdoptNarration(input: RangeNarrationInput): string {
@@ -198,6 +212,8 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.continueCollectFallback,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
...Object.values(USER_COLLECT_QUESTION),
...Object.values(USER_COLLECT_QUESTION_RETRY),
];
@@ -20,7 +20,7 @@ import {
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCatalog } from "./decision-from-dossier";
import type { InferenceState } from "../core/types.ts";
import type { AnswerClass, InferenceState } from "../core/types.ts";
import {
CHOICE_ACTION,
STOP_ACTION,
@@ -125,6 +125,7 @@ function adoptHostNarration(input: {
credibleRange: input.credibleRange,
representativeTime: input.representativeTime,
openingRange: input.openingRange,
stopReason: typeof input.receipt?.stopReason === "string" ? input.receipt.stopReason : null,
}), input.receipt);
}
@@ -251,7 +252,12 @@ export async function applyRectificationChoice(
}
if (!previous) {
const narration = composeChoiceNarration({ optionId, scoring, appliedInference: false });
const narration = composeChoiceNarration({
optionId,
scoring,
appliedInference: false,
answerClass,
});
return persistApplied(accounting, command, {
focusId: focus.id,
questionId,
@@ -298,6 +304,7 @@ export async function applyRectificationChoice(
optionId,
scoring,
appliedInference: persistable && scoring,
answerClass,
});
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(dossier.evidence);
@@ -513,6 +520,7 @@ export async function persistNextInterviewAfterChoice(input: {
representativeTime: input.nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
variant: input.nextAction.can_adopt ? "delivery" : "intermediate",
stopReason: input.nextAction.stop_reason,
}), latest.decisionReceipt),
choiceReady: false,
};
@@ -822,7 +830,7 @@ async function persistApplied(
optionId: ChoiceOptionId;
scoring: boolean;
appliedInference: boolean;
answerClass: string | null;
answerClass: AnswerClass | null;
sourceQuote: string | null;
year: number | null;
expectedRevision: number;
@@ -902,6 +910,7 @@ ${nextInterview.hostNarration}`;
credibleRange: nextAction.credible_range,
representativeTime: nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
stopReason: nextAction.stop_reason,
}), input.dossier.latestResult?.decisionReceipt)
: null;
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
@@ -913,6 +922,7 @@ ${nonConvergingRangeNarration({
representativeTime: nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
variant: "delivery",
stopReason: nextAction.stop_reason,
}, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt)
: null;
const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview);
@@ -962,6 +972,7 @@ ${nonConvergingRangeNarration({
optionId: input.optionId,
scoring: input.scoring,
appliedInference: input.appliedInference,
answerClass: input.answerClass,
});
return {
@@ -94,10 +94,14 @@ export function composeChoiceNarration(input: {
optionId: ChoiceOptionId;
scoring: boolean;
appliedInference: boolean;
answerClass?: AnswerClass | null;
}): string {
if (input.optionId === "stop") {
return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`;
}
if (input.answerClass === "unsure") {
return "已记录。这题先不计分,换一件事问。";
}
if (!input.scoring) {
return "已记录你的选择。这是盘外核对,不会改候选分数。";
}
@@ -44,6 +44,7 @@ import {
import { refinementFromDecisionReceipt, type DiscriminatingEventProbe } from "./refinement-packet";
import { windowScanFromDecisionReceipt } from "./varga-observations";
import type { DroppedProbe } from "./probe-question-contract.ts";
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
import { evidenceLedgerFingerprint } from "./tool-service";
import {
candidateSnapshotSource,
@@ -359,12 +360,25 @@ export type DecideFromDossierOptions = Readonly<{
birthDate?: string | null;
}>;
function userInterviewAnswers(
answers: InferenceState["answered_probes"] | undefined,
) {
return answers?.filter((item) => (
item.classified_from === "choice" || item.classified_from === "declined"
)) ?? [];
}
function decisionBudgetFromInference(inference: InferenceState | null | undefined) {
const rounds = inference?.rounds ?? [];
const userAnswers = userInterviewAnswers(inference?.answered_probes);
let plateauRounds = 0;
for (let index = rounds.length - 1; index >= 0; index -= 1) {
if (rounds[index]?.kind !== "low_information") break;
plateauRounds += 1;
// Unsure/low-information streaks only count toward the plateau after the
// same sample floor as userUncertaintyHigh. maxPlateauRounds stays 2.
if (userAnswers.length >= RECTIFICATION_POLICY.minUncertaintyAnswers) {
for (let index = rounds.length - 1; index >= 0; index -= 1) {
if (rounds[index]?.kind !== "low_information") break;
plateauRounds += 1;
}
}
return {
// The existing convergence evaluator budgets informative rounds. Keep the
@@ -527,12 +541,10 @@ function evidenceStopInputs(evidence: DecisionDossier["evidence"]): {
}
function uncertaintyHighFromAnswers(
answers: readonly Readonly<{ answer_class?: string; classified_from?: string }>[] | undefined,
answers: InferenceState["answered_probes"] | undefined,
): boolean {
const userAnswers = answers?.filter((item) => (
item.classified_from === "choice" || item.classified_from === "declined"
)) ?? [];
if (userAnswers.length === 0) return false;
const userAnswers = userInterviewAnswers(answers);
if (userAnswers.length < RECTIFICATION_POLICY.minUncertaintyAnswers) return false;
const uncertain = userAnswers.filter((item) => (
item.answer_class === "unsure" || item.classified_from === "declined"
)).length;