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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user