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