fix(web): exit rectification when the probe pool is exhausted (BUG-558)

Stop falling through to "say another event" after dated and occupation collect are done. Deliver an adopt path or a gate sentence, and add a spoken-collect stop control.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 23:03:11 +08:00
co-authored by Cursor
parent 3c30a579a9
commit 150d7ef189
21 changed files with 1179 additions and 121 deletions
@@ -33,7 +33,7 @@ import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
import { parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card";
import { CHOICE_STOP_LABEL, parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card";
import {
classifyRectificationTurnIntent,
optionIdForAnswerClass,
@@ -283,6 +283,58 @@ export async function POST(request: Request) {
const actionId = parsed.data.actionId;
const focusId = parsed.data.focusId;
const expectedRevision = parsed.data.expectedRevision;
if (action === "stop_and_review") {
if (!actionId) {
return NextResponse.json(
{ error: "选择题请求缺少 actionId" },
{ status: 400 },
);
}
if (!focusId) {
try {
await transitionV9CaseStatus(accounting, userId, caseId, "paused");
const idle = await persistNextInterviewIfIdle({
accounting,
userId,
caseId,
narrateAdopt,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration({ variant: "delivery" });
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: CHOICE_STOP_LABEL,
assistantMessage,
});
return NextResponse.json({
type: "choice.applied",
action: "applied",
replayed: false,
status: "narrated",
narrationPersisted: true,
focusId: null,
questionId: parsed.data.questionId ?? null,
optionId: "stop",
probeId: null,
caseRevision: expectedRevision ?? 0,
narration: assistantMessage,
userMessage: CHOICE_STOP_LABEL,
turnId: turn.turnId,
});
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const mapped = mapRectificationRpcError(error);
return NextResponse.json(
{ error: mapped.message, message: mapped.message, code: mapped.code },
{ status: mapped.status },
);
}
return NextResponse.json(
{ error: "选择题处理失败", message: "请稍后重试。" },
{ status: 500 },
);
}
}
}
if (!actionId || !focusId || expectedRevision === undefined) {
return NextResponse.json(
{ error: "选择题请求缺少 actionId、focusId 或 expectedRevision" },
@@ -417,7 +469,7 @@ export async function POST(request: Request) {
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
}
if (classified.intent === "stop_rectification") {
if (classified.intent === "stop_rectification" || classified.intent === "ask_about_result") {
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const applied = await applyRectificationChoice(accounting, {
userId,
@@ -449,6 +501,26 @@ export async function POST(request: Request) {
} catch {
classified = null;
}
if (classified?.intent === "stop_rectification" || classified?.intent === "ask_about_result") {
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const applied = await applyRectificationChoice(accounting, {
userId,
caseId,
sessionId,
actionId: requestId,
action: STOP_ACTION,
focusId: focus.id,
questionId: focus.questionId,
probeId: typeof focus.expectedAnswerSchema.probe_id === "string"
? focus.expectedAnswerSchema.probe_id
: null,
optionId: "stop",
expectedRevision: previous?.revision ?? 0,
userDisplay: parsed.data.message ?? null,
});
await transitionV9CaseStatus(accounting, userId, caseId, "paused");
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
if (shouldDeclineCollectFocus(classified)) {
const continueToAgent = shouldContinueAgentForDatedEvent(classified);
const applied = await applyCollectFocusDenial(accounting, {
+26
View File
@@ -3155,12 +3155,38 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-refinement p,
.rectification-refinement__stage { margin: 0; color: var(--color-ink-secondary); }
.rectification-readonly-range {
display: block;
width: calc(100% - var(--assistant-content-inset));
margin: var(--space-3) 0 var(--space-4);
margin-inline-start: var(--assistant-content-inset);
padding: 0;
border: 0;
background: transparent;
color: var(--color-ink-secondary);
font: inherit;
font-size: var(--type-caption);
line-height: 1.5;
text-align: left;
cursor: pointer;
}
button.rectification-readonly-range:hover {
text-decoration: underline;
}
.rectification-collect-stop {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
width: 100%;
margin: 0 0 var(--space-3);
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
color: var(--color-ink-secondary);
font: inherit;
font-size: var(--type-body-sm);
cursor: pointer;
}
.rectification-question-skipped {
margin: var(--space-2) 0 0;
@@ -167,11 +167,26 @@ function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | n
function RectificationReadonlyRange({
range,
}: Readonly<{ range: readonly [string, string] }>) {
onStop,
}: Readonly<{
range: readonly [string, string];
onStop?: () => void;
}>) {
if (!onStop) {
return (
<p className="rectification-readonly-range" role="status">
{range[0]}{range[1]}
</p>
);
}
return (
<p className="rectification-readonly-range" role="status">
<button
type="button"
className="rectification-readonly-range"
onClick={onStop}
>
{range[0]}{range[1]}
</p>
</button>
);
}
@@ -1080,23 +1095,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION,
optionId: ChoiceKey | "stop" | "skip_probe",
override?: Readonly<{
focusId: string;
questionId: string | null;
focusId?: string;
questionId?: string | null;
probeId?: string | null;
caseRevision?: number | null;
}>,
) => {
const focusId = override?.focusId ?? choiceCard?.focus_id;
if (!focusId || busy || readonly) return;
if (!override && !choiceCard) return;
if (!isPersistedFocusId(focusId)) {
const focusId = override?.focusId ?? choiceCard?.focus_id ?? (
action === STOP_ACTION && currentQuestion?.kind === "collect_spoken"
? currentQuestion.focus_id
: null
);
if (busy || readonly) return;
if (action !== STOP_ACTION && (!focusId || (!override && !choiceCard))) return;
if (focusId && !isPersistedFocusId(focusId)) {
setError("当前选择题已失效,请等待下一问。");
return;
}
const questionId = override?.questionId ?? choiceCard?.question_id;
const questionId = override?.questionId ?? choiceCard?.question_id ?? currentQuestion?.question_id ?? null;
const probeId = override?.probeId ?? choiceCard?.probe_id;
const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0;
const actionId = actionIdForChoice(focusId, optionId);
const actionId = actionIdForChoice(focusId ?? caseId, optionId);
setError("");
keyCounter.current += 1;
const turnKey = keyCounter.current;
@@ -1104,7 +1123,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer;
beginLiveRun(recordingLabel);
setMessages((current) => [
...markQuestionAnswered(current, focusId, optionId),
...markQuestionAnswered(current, focusId ?? "", optionId),
{
role: "assistant",
text: "",
@@ -1132,8 +1151,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
requestId: actionId,
action,
actionId,
focusId,
questionId,
...(focusId ? { focusId } : {}),
...(questionId ? { questionId } : {}),
probeId: probeId ?? null,
optionId: optionId === "stop" ? undefined : optionId,
expectedRevision,
@@ -1219,6 +1238,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
beginLiveRun,
caseId,
choiceCard,
currentQuestion,
loadCaseSnapshot,
onCompleted,
onMessagesChange,
@@ -1523,9 +1543,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
function submitStop() {
if (!choiceCard) return;
if (choiceCard.skip_this_probe) {
void submitStructuredChoice(SKIP_PROBE_ACTION, "skip_probe");
if (choiceCard) {
if (choiceCard.skip_this_probe) {
void submitStructuredChoice(SKIP_PROBE_ACTION, "skip_probe");
return;
}
void submitStructuredChoice(STOP_ACTION, "stop");
return;
}
if (currentQuestion?.kind === "collect_spoken" && currentQuestion.focus_id) {
void submitStructuredChoice(STOP_ACTION, "stop", {
focusId: currentQuestion.focus_id,
questionId: currentQuestion.question_id,
});
return;
}
void submitStructuredChoice(STOP_ACTION, "stop");
@@ -1686,7 +1716,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
/>
)}
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
<RectificationReadonlyRange range={candidateResult.credibleRange} />
<RectificationReadonlyRange
range={candidateResult.credibleRange}
onStop={readonly || busy ? undefined : submitStop}
/>
)}
</div>
);
@@ -1746,6 +1779,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<span className="rectification-adopt-status__hint"></span>
</div>
)}
{currentQuestion?.kind === "collect_spoken" && !readonly && !busy && (
<button
type="button"
className="rectification-collect-stop"
onClick={submitStop}
>
{CHOICE_STOP_LABEL}
</button>
)}
<ChatComposer
inputRef={composer}
value={draft}
@@ -42,7 +42,8 @@ export type EvidenceStopReason =
| "insufficient_dated_events"
| "insufficient_domains"
| "tied_first"
| "user_uncertainty_too_high";
| "user_uncertainty_too_high"
| "probe_pool_exhausted";
export type StopClass =
| Readonly<{ kind: "keep_collecting"; reason: "insufficient_dated_events" | "insufficient_domains" }>
@@ -318,9 +319,21 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
stopReason,
);
}
return stopClass?.kind === "exhausted"
? completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason)
: completeWithRange(separation, holdout, range, "offer", capability);
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
}
if (capability.canAdopt && input.methodCoverageAll) {
return finish("adopt_representative", {
input,
separation,
holdout,
range,
probe: null,
capability,
stopReason: "probe_pool_exhausted",
});
}
return completeWithRange(separation, holdout, range, "offer", capability);
}
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
@@ -559,6 +572,7 @@ function finish(
range: readonly [string, string] | null;
probe: CandidateDiscriminatorProbe | null;
capability: DeliveryCapability;
stopReason?: EvidenceStopReason | null;
},
): RectificationDecision {
const completionStatus: CompletionStatus | null = input.capability.canConfirmExactMinute && input.input.accepted
@@ -592,6 +606,7 @@ function finish(
probe: input.probe,
holdoutValidation: input.holdout,
droppedProbes: [],
...(input.stopReason ? { stopReason: input.stopReason } : {}),
};
}
@@ -77,9 +77,43 @@ export const RECTIFICATION_USER_COPY = {
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
probePoolExhaustedStop: "能分开候选的问题已经问完,先按现有材料给你结果。",
postAdoptVerifyDone: "前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。",
insufficientEventsGate: "还差带月份的经历,领域不限。",
insufficientDomainsGate: "还差另一个领域的带月份经历。",
lowDateQualityGate: "两件事的日期还没对清。",
noCandidatesGate: "当前还排不出可比较的候选时间。",
} as const;
export const ACCEPTANCE_GATE_COPY: Readonly<Record<string, string>> = {
insufficient_events: RECTIFICATION_USER_COPY.insufficientEventsGate,
insufficient_dated_events: RECTIFICATION_USER_COPY.insufficientEventsGate,
insufficient_domain_diversity: RECTIFICATION_USER_COPY.insufficientDomainsGate,
insufficient_domains: RECTIFICATION_USER_COPY.insufficientDomainsGate,
low_date_quality: RECTIFICATION_USER_COPY.lowDateQualityGate,
no_candidates: RECTIFICATION_USER_COPY.noCandidatesGate,
};
export function acceptanceGateNarration(
reasons: readonly string[] | null | undefined,
missingEventCount?: number | null,
): string | null {
const tokens = (reasons ?? []).map((item) => item.trim()).filter(Boolean);
if (
(tokens.includes("insufficient_events") || tokens.includes("insufficient_dated_events"))
&& typeof missingEventCount === "number"
&& Number.isFinite(missingEventCount)
&& missingEventCount > 0
) {
return `还差 ${missingEventCount} 件带月份的经历,领域不限。`;
}
for (const reason of tokens) {
const copy = ACCEPTANCE_GATE_COPY[reason];
if (copy) return copy;
}
return null;
}
export type RangeNarrationVariant = "delivery" | "intermediate";
export type RangeNarrationInput = {
@@ -126,6 +160,7 @@ export function containsBoundarySemantics(text: string): boolean {
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;
if (reason === "probe_pool_exhausted") return RECTIFICATION_USER_COPY.probePoolExhaustedStop;
return null;
}
@@ -241,6 +276,11 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
RECTIFICATION_USER_COPY.probePoolExhaustedStop,
RECTIFICATION_USER_COPY.insufficientEventsGate,
RECTIFICATION_USER_COPY.insufficientDomainsGate,
RECTIFICATION_USER_COPY.lowDateQualityGate,
RECTIFICATION_USER_COPY.noCandidatesGate,
RECTIFICATION_USER_COPY.postAdoptVerifyDone,
...Object.values(USER_COLLECT_QUESTION),
...Object.values(USER_COLLECT_QUESTION_RETRY),
@@ -8,8 +8,17 @@
import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint";
import { nextProbe } from "../core/build-state";
import { RECTIFICATION_TERMINATION_COPY, isNonConvergingRangeOffer, nonConvergingRangeNarration, publicCanAdopt, publicNextAction } from "../core/rectification-decision.ts";
import {
RECTIFICATION_TERMINATION_COPY,
engineCapabilityCeilingFromReceipt,
isNonConvergingRangeOffer,
nonConvergingRangeNarration,
publicCanAdopt,
publicNextAction,
type RectificationDecision,
} from "../core/rectification-decision.ts";
import {
acceptanceGateNarration,
deliveryAdoptNarration,
openingRangeFromCandidateRange,
RECTIFICATION_USER_COPY,
@@ -19,7 +28,12 @@ import {
previousInferenceFromReceipt,
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCatalog } from "./decision-from-dossier";
import {
decideAfterInferenceChange,
decideFromDossier,
rectificationFollowupCatalog,
type DecisionDossier,
} from "./decision-from-dossier";
import type { AnswerClass, InferenceState } from "../core/types.ts";
import {
CHOICE_ACTION,
@@ -55,6 +69,7 @@ import {
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus";
import { collectionProgressFromReceipt, trainingScoreableGate } from "./evidence-model";
import { composeCollectSpokenAssistantText } from "./collect-prompt";
import {
blockingMethodsCovered,
@@ -657,11 +672,8 @@ export async function persistNextInterviewAfterChoice(input: {
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: input.dossier,
decision: {
credibleRange: input.nextAction.credible_range,
representativeTime: input.nextAction.representative_time,
},
dossier: liveDossier,
decision,
decisionReceipt: latest.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
@@ -674,35 +686,15 @@ export async function persistNextInterviewAfterChoice(input: {
followup: null,
};
}
if (isNonConvergingRangeOffer({
canOfferRange: input.nextAction.can_offer_range,
canAdopt: input.nextAction.can_adopt,
canConfirmExactMinute: input.nextAction.can_confirm_exact_minute,
nextAction: input.nextAction.type,
})) {
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: input.dossier,
decision: {
credibleRange: input.nextAction.credible_range,
representativeTime: input.nextAction.representative_time,
},
decisionReceipt: latest.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
return {
hostNarration: withProspectiveWindows(nonConvergingRangeNarration({
credibleRange: input.nextAction.credible_range,
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,
};
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: latest.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
return {
hostNarration: spokenFollowupForUser(followup) ?? RECTIFICATION_USER_COPY.hostNarrationFallback,
@@ -984,7 +976,31 @@ export async function persistNextInterviewIfIdle(input: {
: fallback,
};
}
if (isNonConvergingRangeOffer(decision)) {
const remainingCollect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
if (
!remainingCollect
&& !dossier.case.acceptedTime
&& !decision.canAdopt
) {
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier,
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
if (isNonConvergingRangeOffer(decision)
|| decision.nextAction === "complete_with_range"
|| decision.sessionOutcome === "completed_with_range"
) {
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
@@ -1003,7 +1019,15 @@ export async function persistNextInterviewIfIdle(input: {
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
};
}
return { persisted: false, choiceReady: false, hostNarration: null };
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier,
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
const nextAction = publicNextAction(decision);
const nextInterview = await persistNextInterviewAfterChoice({
@@ -1029,17 +1053,8 @@ async function persistExhaustionCollect(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
dossier: {
evidence: Parameters<typeof exhaustionSpokenCollectFollowup>[0]["evidence"];
conversationSummary: { declinedSkippedTopics?: readonly Readonly<Record<string, unknown>>[] };
latestResult?: { decisionReceipt?: Readonly<Record<string, unknown>> | null } | null;
case?: {
acceptedTime?: string | null;
status?: string;
candidateRange?: { start_time?: string; end_time?: string } | null;
};
};
decision: { credibleRange?: readonly [string, string] | null; representativeTime?: string | null };
dossier: DecisionDossier | Parameters<typeof decideAfterInferenceChange>[0]["dossier"];
decision: RectificationDecision;
decisionReceipt?: Readonly<Record<string, unknown>> | null;
askedTurnId?: string | null;
}): Promise<{
@@ -1047,41 +1062,135 @@ async function persistExhaustionCollect(input: {
choiceReady: boolean;
hostNarration: string;
focus?: ConversationFocus | null;
terminalNote?: boolean;
}> {
const dossier = input.dossier as DecisionDossier;
const receipt = input.decisionReceipt ?? dossier.latestResult?.decisionReceipt ?? null;
const decision = input.decision;
const catalog = rectificationFollowupCatalog(
input.dossier.latestResult ?? null,
input.dossier.evidence,
dossier.latestResult ?? null,
dossier.evidence,
);
const followup = exhaustionSpokenCollectFollowup({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
const range = nonConvergingRangeNarration({
...input.decision,
openingRange: openingRangeFromDossier(input.dossier),
variant: "intermediate",
});
const spoken = spokenFollowupForUser(followup);
const persistedFocus = followup
? await persistFocusAfterChoice({
const trainingGate = trainingScoreableGate(dossier.evidence);
const ceiling = engineCapabilityCeilingFromReceipt(receipt);
const inference = previousInferenceFromReceipt(receipt);
const rounds = inference?.rounds ?? [];
let plateauRounds = 0;
for (let index = rounds.length - 1; index >= 0; index -= 1) {
if (rounds[index]?.kind !== "low_information") break;
plateauRounds += 1;
}
const budget = {
rounds: rounds.filter((item) => item.kind === "informative").length,
answers: inference?.answered_probes.length ?? 0,
plateau: plateauRounds,
};
console.warn(JSON.stringify({
event: "rectification_exhaustion_collect",
case_id: input.caseId,
can_adopt: decision.canAdopt,
ceiling: {
acceptance: ceiling.acceptanceAllowed,
selection: ceiling.selectionAllowed,
propose: ceiling.proposeAllowed,
},
training_gate: {
count: trainingGate.trainingCount,
domains: trainingGate.trainingDomainCount,
open: trainingGate.open,
},
stop_class: decision.stopReason ?? decision.sessionOutcome,
ranked_count: decision.separation.ranked.length,
probe_key: decision.probe?.semanticKey ?? null,
budget,
next_domain: followup?.domain ?? null,
next_source: followup?.source ?? null,
}));
if (followup) {
const range = nonConvergingRangeNarration({
credibleRange: decision.credibleRange ?? input.decision.credibleRange,
representativeTime: decision.representativeTime ?? input.decision.representativeTime,
openingRange: openingRangeFromDossier(dossier),
variant: "intermediate",
});
const spoken = spokenFollowupForUser(followup);
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
decisionReceipt: input.decisionReceipt ?? input.dossier.latestResult?.decisionReceipt,
decisionReceipt: receipt,
followup,
askedTurnId: input.askedTurnId ?? null,
})
: { status: "skipped" as const, focus: null, questionId: null, prompt: null };
const persisted = Boolean(spoken) && (
persistedFocus.status === "created" || persistedFocus.status === "already_open"
});
const persisted = Boolean(spoken) && (
persistedFocus.status === "created" || persistedFocus.status === "already_open"
);
return {
persisted,
choiceReady: false,
hostNarration: spoken ?? withProspectiveWindows(range, receipt),
focus: persistedFocus.focus,
};
}
if (ceiling.acceptanceAllowed && trainingGate.open && decision.canAdopt) {
const adopted = {
...decision,
stopReason: decision.stopReason ?? "probe_pool_exhausted",
};
return {
persisted: false,
choiceReady: false,
hostNarration: adoptHostNarration({
dossier,
decision: adopted,
receipt,
}),
focus: null,
terminalNote: true,
};
}
const receiptReasons = receipt && Array.isArray(receipt.acceptance_reasons)
? receipt.acceptance_reasons.filter((item): item is string => typeof item === "string")
: [];
const reasons = [
...receiptReasons,
...(decision.stopReason ? [decision.stopReason] : []),
...(decision.separation.ranked.length === 0 ? ["no_candidates"] : []),
];
const progress = collectionProgressFromReceipt(receipt);
const gate = acceptanceGateNarration(reasons, progress?.missing ?? null)
?? (decision.separation.ranked.length === 0 ? RECTIFICATION_USER_COPY.noCandidatesGate : null);
const range = nonConvergingRangeNarration({
credibleRange: decision.credibleRange ?? input.decision.credibleRange,
representativeTime: decision.representativeTime ?? input.decision.representativeTime,
openingRange: openingRangeFromDossier(dossier),
variant: "intermediate",
});
const hostNarration = withProspectiveWindows(
[range, gate].filter(Boolean).join(""),
receipt,
);
try {
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: globalThis.crypto.randomUUID(),
userMessage: null,
assistantMessage: hostNarration,
});
} catch {
// Fake RPCs and already-narrated turns must not block the gate carrier.
}
return {
persisted,
persisted: false,
choiceReady: false,
hostNarration: withProspectiveWindows(range, input.decisionReceipt ?? input.dossier.latestResult?.decisionReceipt),
focus: persistedFocus.focus,
hostNarration,
focus: null,
terminalNote: true,
};
}
@@ -1332,7 +1441,7 @@ export async function ensureNonTerminalTurnExit(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> {
const before = await inspectNonTerminalTurnExit(input);
if (before.satisfied) {
return { persisted: false, choiceReady: false, hostNarration: null };
@@ -1351,7 +1460,7 @@ export async function ensureNonTerminalTurnExit(input: {
decisionReceipt: before.dossier.latestResult?.decisionReceipt,
});
const after = await inspectNonTerminalTurnExit(input);
if (!after.satisfied) {
if (!after.satisfied && !repaired.terminalNote) {
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
}
return repaired;
@@ -33,6 +33,7 @@ import {
blockingMethodsCovered,
buildMethodFollowupPlan,
datedMethodCollectOpen,
exhaustionSpokenCollectFollowup,
isRemainingEvidenceCollect,
} from "./method-followup";
import { buildConfirmationGate } from "./confirmation-gate";
@@ -568,6 +569,7 @@ export function decideFromDossier(
options?: DecideFromDossierOptions,
): RectificationDecision {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const oosBlindPrompts = refinementFromDecisionReceipt(
dossier.latestResult?.decisionReceipt ?? null,
).oos_blind_prompts;
@@ -577,6 +579,14 @@ export function decideFromDossier(
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "collect_evidence",
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
const remainingCollect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
const latest = dossier.latestResult;
const decisionBudget = decisionBudgetFromInference(inference);
@@ -631,6 +641,7 @@ export function decideFromDossier(
inferenceCredibleRange: inference?.credible_range ?? null,
engineCeiling: engineCapabilityCeilingFromReceipt(latest?.decisionReceipt ?? null),
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods)
|| remainingCollect !== null
|| isRemainingEvidenceCollect(collecting.next_followup),
...decisionBudget,
...evidenceStops,
@@ -646,11 +657,20 @@ export function decideAfterInferenceChange(input: {
userStopped: boolean;
birthDate?: string | null;
}): RectificationDecision {
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
const collecting = buildMethodFollowupPlan({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "collect_evidence",
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
const remainingCollect = exhaustionSpokenCollectFollowup({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
if (!input.state) {
const evidenceStops = evidenceStopInputs(input.dossier.evidence);
@@ -718,6 +738,7 @@ export function decideAfterInferenceChange(input: {
accepted: Boolean(input.dossier.case.acceptedTime),
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods)
|| remainingCollect !== null
|| isRemainingEvidenceCollect(collecting.next_followup),
...decisionBudgetFromInference(input.state),
...evidenceStops,
@@ -311,6 +311,88 @@ function existenceNearbyYears(domain: string): number {
return EXISTENCE_NEARBY_YEARS[domain] ?? 0;
}
const SEMANTIC_YEAR = /^([a-z_]+)\.((?:19|20)\d{2})(?:\.|$)/;
export function existenceProbeAsked(
askedKeys: ReadonlySet<string> | readonly string[],
domain: string,
year: number,
): boolean {
if (!domain || !year) return false;
const asked = askedKeys instanceof Set ? askedKeys : new Set(askedKeys);
const nearby = existenceNearbyYears(domain);
for (const key of asked) {
const match = key.match(SEMANTIC_YEAR);
if (!match || match[1] !== domain) continue;
const askedYear = Number(match[2]);
if (Number.isInteger(askedYear) && Math.abs(askedYear - year) <= nearby) return true;
}
return false;
}
export function datedLedgerAnchor(
evidence: readonly MethodFollowupEvidence[] | undefined,
domain: string | null | undefined,
): { year: number; month: number | null; label: string } | null {
if (!domain) return null;
let best: { year: number; month: number | null; label: string } | null = null;
for (const item of evidence ?? []) {
if (!isConfirmedDated(item) || item.domain !== domain) continue;
const year = evidenceYear(item);
if (year === null) continue;
const raw = item.occurredFrom || item.occurredTo || "";
const month = raw.length >= 7 && raw[4] === "-"
? Number(raw.slice(5, 7))
: null;
const label = month && Number.isInteger(month) && month >= 1 && month <= 12
? `${year}${month}`
: `${year}`;
if (!best || (month && !best.month)) {
best = { year, month: month && Number.isInteger(month) && month >= 1 && month <= 12 ? month : null, label };
}
}
return best;
}
function nearbyLedgerHint(
evidence: readonly MethodFollowupEvidence[] | undefined,
domain: string | null | undefined,
year: number | null | undefined,
month?: number | null,
): string {
if (!domain || !year || year <= 0) return "";
const probeIndex = month && month >= 1 && month <= 12 ? year * 12 + month : null;
let best: { label: string; family: string; delta: number } | null = null;
for (const item of evidence ?? []) {
if (!isConfirmedDated(item) || item.domain === domain) continue;
const otherYear = evidenceYear(item);
if (otherYear === null) continue;
const raw = item.occurredFrom || item.occurredTo || "";
const otherMonth = raw.length >= 7 && raw[4] === "-"
? Number(raw.slice(5, 7))
: null;
const otherIndex = otherMonth && otherMonth >= 1 && otherMonth <= 12
? otherYear * 12 + otherMonth
: null;
let delta = 99;
if (probeIndex != null && otherIndex != null) {
delta = Math.abs(probeIndex - otherIndex);
if (delta > 2) continue;
} else if (otherYear !== year) {
continue;
} else {
delta = 2;
}
const family = EXISTENCE_EVENT_FAMILY[item.domain];
if (!family) continue;
const label = otherIndex
? `${otherYear}${otherMonth}`
: `${otherYear}`;
if (!best || delta < best.delta) best = { label, family, delta };
}
return best ? `账本里 ${best.label}${best.family};题干先提那件事再问。` : "";
}
function birthYearFromDate(birthDate: string | null | undefined): number | null {
if (!birthDate || birthDate.length < 4 || !/^\d{4}/.test(birthDate)) return null;
const year = Number(birthDate.slice(0, 4));
@@ -596,7 +678,8 @@ function reverseVerifyProbeAsked(
const split = probe.candidate_split_hash ?? "";
return askedKeys.has(semantic)
|| (split !== "" && askedKeys.has(split))
|| askedKeys.has(`${probe.domain}.${probe.year}`);
|| askedKeys.has(`${probe.domain}.${probe.year}`)
|| existenceProbeAsked(askedKeys, probe.domain, probe.year);
}
export function remainingReverseVerifyProbes(
@@ -690,7 +773,11 @@ function remainingConflictProbes(
if (probeBelowAdultFloor(probe, birthDate)) continue;
const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
const split = probe.candidate_split_hash ?? "";
if (askedKeys.has(semantic) || (split && askedKeys.has(split))) continue;
if (
askedKeys.has(semantic)
|| (split && askedKeys.has(split))
|| existenceProbeAsked(askedKeys, probe.domain, probe.year)
) continue;
rows.push(probe);
}
return rows
@@ -775,6 +862,7 @@ function renderableEventProbe(
const layer = vargaLayerFromSemanticKey(key);
const asked = askedKeys.has(key)
|| Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash))
|| existenceProbeAsked(askedKeys, probe.domain, probe.year)
|| (layer ? vargaLayerCovered(mentionedKeys, layer) : false);
return {
row: {
@@ -1007,12 +1095,24 @@ function rankRenderableDiscriminators(input: {
) {
continue;
}
if (probe.choiceKind === "varga_style") {
const domain = contrastFollowupDomain(probe.domain);
const anchor = datedLedgerAnchor(input.evidence, domain);
if (!anchor) {
dropped.push(droppedFromProbe(probe.semanticKey, probe.informationGain, "unanchored_varga_style"));
continue;
}
}
if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) {
const year = probe.year ?? 0;
if (year <= 0) continue;
if (input.evidence && probeYearAlreadyCovered(input.evidence, probe.domain, year)) continue;
}
push(renderableContrastProbe(probe, input.askedKeys, top, mentioned));
const rendered = renderableContrastProbe(probe, input.askedKeys, top, mentioned);
if (rendered.row && probe.choiceKind === "varga_style") {
rendered.row = { ...rendered.row, score: rendered.row.score * 0.8 };
}
push(rendered);
}
const sorted = rows.sort((left, right) => right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0));
return {
@@ -1199,7 +1299,7 @@ export function exhaustionSpokenCollectFollowup(input: {
source: "method_coverage",
};
}
return otherCollectFollowup(input.evidence);
return null;
}
export const OPENING_COLLECT_DOMAIN = "other";
@@ -1883,6 +1983,12 @@ export function buildMethodFollowupPlan(input: {
const followupFromRanked = (ranked: RankedDiscriminator): MethodFollowup => {
if (ranked.kind === "event" && ranked.eventProbe) {
const conflictProbe = ranked.eventProbe;
const nearby = nearbyLedgerHint(
input.evidence,
conflictProbe.domain,
conflictProbe.year,
conflictProbe.month,
);
return makeFollowup({
method_id: PROBE_METHOD_ID[conflictProbe.domain],
intent: "distinguish_candidates",
@@ -1892,6 +1998,7 @@ export function buildMethodFollowupPlan(input: {
user_prompt_hint: ask(
`当前候选时间还分不开。按冲突分钟反推:${conflictProbe.year_label} 是否有${conflictProbe.event_family}。对得上写入账本并重算以筛窗;对不上关闭该问。不要问两套盘哪个更像。不确认唯一分钟。`,
REVERSE_VERIFY_VARGA[conflictProbe.domain],
nearby,
),
source: "event_probe",
information_gain: conflictProbe.information_gain ?? 0,
@@ -1915,6 +2022,13 @@ export function buildMethodFollowupPlan(input: {
supports: row.supportsCandidateIds,
conflicts: row.conflictsCandidateIds,
}));
const vargaAnchor = contrast.choiceKind === "varga_style"
? datedLedgerAnchor(input.evidence, domain)
: null;
const nearby = nearbyLedgerHint(input.evidence, domain, contrast.year, null);
const extra = vargaAnchor
? `题干先提到 ${vargaAnchor.label} 这段经历,再问风格。`
: nearby || (contrast.authoringHint ?? "按候选盘面差异核对前事,不要问两套盘哪个更像。");
return makeFollowup({
method_id: PROBE_METHOD_ID[domain],
intent: "distinguish_candidates",
@@ -1924,7 +2038,7 @@ export function buildMethodFollowupPlan(input: {
user_prompt_hint: ask(
contrast.question,
REVERSE_VERIFY_VARGA[domain],
contrast.authoringHint ?? "按候选盘面差异核对前事,不要问两套盘哪个更像。",
extra,
),
source: "event_probe",
information_gain: contrast.informationGain,
@@ -2224,7 +2338,7 @@ export function buildMethodFollowupPlan(input: {
&& !hasConfirmedDomain(input.evidence, "relocation")
&& !declined.has("relocation"))
) {
next = makeFollowup(otherCollectFollowup(input.evidence));
next = null;
} else if (precisionCard) {
next = precisionCard;
} else {