fix(rectification): guarantee nonterminal turn exits
Independent Staging Quality Gate / validate (push) Successful in 13m25s
Independent Staging Quality Gate / publish (push) Successful in 9m26s

This commit is contained in:
Jesse_Chen
2026-09-01 13:35:51 +08:00
parent 15877069fc
commit e404b6f42b
13 changed files with 966 additions and 125 deletions
@@ -13,7 +13,6 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica
import {
applyRectificationChoice,
applyCollectFocusDenial,
ensureNonTerminalTurnExit,
persistNextInterviewIfIdle,
} from "@/lib/rectification-agentic/v9/answer-choice";
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
@@ -41,6 +40,12 @@ import {
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema, isRenderableChoiceOpenQuestion } from "@/lib/rectification-agentic/v9/server-focus";
import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup";
import { isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision";
import {
awaitTurnExitBeforeResponse,
finalizeSuccessfulTurnExit,
RECTIFICATION_ACTION_EXECUTION,
type RectificationRouteAction,
} from "@/lib/rectification-agentic/v9/turn-exit";
export const runtime = "nodejs";
export const maxDuration = 240;
@@ -86,6 +91,9 @@ const agentRequestSchema = z.object({
clientActionId: z.string().uuid().optional(),
}).strict();
type ParsedRectificationAction = z.infer<typeof agentRequestSchema>["action"];
const rectificationActionExecution: Record<ParsedRectificationAction, (typeof RECTIFICATION_ACTION_EXECUTION)[RectificationRouteAction]> = RECTIFICATION_ACTION_EXECUTION;
function actionToBudget(action: "opening" | "message" | "read_only"): RectificationAgentAction {
if (action === "opening") return "opening";
if (action === "read_only") return "read_only";
@@ -170,7 +178,8 @@ export async function POST(request: Request) {
const userId = user.id;
const { caseId, sessionId, requestId, action } = parsed.data;
const isStructuredChoice = action === "answer_choice" || action === "stop_and_review";
const execution = rectificationActionExecution[action];
const isStructuredChoice = execution === "immediate";
// Feature selector: the V9 runtime is DB-driven. When the flag is not
// published/enabled, no new runs are served (legacy stays read-only).
@@ -246,8 +255,15 @@ export async function POST(request: Request) {
);
}
if (isStructuredChoice) {
const actionId = parsed.data.actionId;
const selectedModel = isStructuredChoice
? null
: await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
const immediateResponse = await (async (): Promise<Response | null> => {
if (isStructuredChoice) {
const actionId = parsed.data.actionId;
const focusId = parsed.data.focusId;
const expectedRevision = parsed.data.expectedRevision;
if (!actionId || !focusId || expectedRevision === undefined) {
@@ -309,11 +325,8 @@ export async function POST(request: Request) {
}
}
const selectedModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!selectedModel) {
const resolvedModel = selectedModel;
if (!resolvedModel) {
return NextResponse.json(
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
{ status: 409 },
@@ -328,7 +341,7 @@ export async function POST(request: Request) {
if (focus && choice) {
let classified = null;
try {
classified = await classifyRectificationTurnIntent(selectedModel, {
classified = await classifyRectificationTurnIntent(resolvedModel, {
focus,
userMessage: parsed.data.message ?? "",
caseStatus,
@@ -537,6 +550,34 @@ export async function POST(request: Request) {
}
}
return null;
})();
if (immediateResponse) {
if (immediateResponse.status !== 200) return immediateResponse;
const response = await awaitTurnExitBeforeResponse(
immediateResponse,
() => finalizeSuccessfulTurnExit({
accounting: accounting as never,
userId,
caseId,
action,
}),
);
return response;
}
if (action === "answer_choice" || action === "stop_and_review") {
return NextResponse.json(
{ error: "选择题处理失败", message: "请稍后重试。" },
{ status: 500 },
);
}
if (!selectedModel) {
return NextResponse.json(
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
{ status: 409 },
);
}
const requestTime = new Date();
const chinaTime = new Date(requestTime.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
@@ -673,30 +714,14 @@ export async function POST(request: Request) {
if (!result.ok) {
send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" });
} else {
if (action === "message" || action === "opening") {
try {
await persistNextInterviewIfIdle({
accounting: accounting as never,
userId,
caseId,
});
} catch (error) {
console.warn(
`[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
try {
await ensureNonTerminalTurnExit({
accounting: accounting as never,
userId,
caseId,
});
} catch (error) {
console.warn(
`[rectification-v9] nonterminal turn exit repair failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
}
// Shared gate owns persistNextInterviewIfIdle then ensureNonTerminalTurnExit;
// The shared gate replaces the old message/opening-only cleanup.
await finalizeSuccessfulTurnExit({
accounting: accounting as never,
userId,
caseId,
action,
});
send({ type: "done", emitted: true });
}
} catch (error) {
@@ -150,6 +150,7 @@ export type DecideRectificationInput = Readonly<{
trainingGateOpen?: boolean;
candidateScores: readonly CandidateScoreRow[];
discriminatorProbe?: CandidateDiscriminatorProbe | null;
nakshatraBoundaryProbe?: CandidateDiscriminatorProbe | null;
holdoutValidation?: HoldoutValidationStatus;
accepted?: boolean;
inferenceCredibleRange?: readonly [string, string] | null;
@@ -277,6 +278,23 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
if (probe) {
return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason);
}
if (holdout === "not_started") {
return holdoutValidation(separation, range, capability);
}
if (input.datedMethodCollectOpen === true) {
return collect(separation, holdout, range, null, capability, stopReason);
}
if (input.nakshatraBoundaryProbe) {
return discriminateOrExhaust(
input,
separation,
holdout,
range,
input.nakshatraBoundaryProbe,
capability,
stopReason,
);
}
return stopClass?.kind === "exhausted"
? completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason)
: completeWithRange(separation, holdout, range, "offer", capability);
@@ -12,6 +12,7 @@ import { RECTIFICATION_TERMINATION_COPY, isNonConvergingRangeOffer, nonConvergin
import {
applyChoiceWithoutEvidence,
previousInferenceFromReceipt,
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCatalog } from "./decision-from-dossier";
import type { InferenceState } from "../core/types.ts";
@@ -49,6 +50,7 @@ import {
spokenFollowupForUser,
} from "./method-followup";
import type { SessionOutcomeKind } from "./confirmation-gate";
import { refinementFromDecisionReceipt } from "./refinement-packet";
import { projectCurrentQuestion } from "./turn-decision";
export type ApplyChoiceCommand = Readonly<{
@@ -136,7 +138,11 @@ export async function applyRectificationChoice(
const optionId = command.optionId;
const questionId = focus.questionId;
const scoring = schema.scoring !== false && !questionId.endsWith(":holdout");
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const receipt = dossier.latestResult?.decisionReceipt ?? null;
const previous = withNakshatraBoundaryProbe(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
);
const answerClass = optionId === "stop" ? null : outcomeIdForOption(optionId, schema);
if (optionId !== "stop" && !answerClass) {
throw new RectificationToolServiceError("agentic_rectification_invalid_choice_schema");
@@ -322,8 +328,8 @@ export async function persistNextInterviewAfterChoice(input: {
followup,
});
const open = openQuestionFromPersistedFocus(persistedFocus);
if (isRenderableChoiceOpenQuestion(open)) {
return { hostNarration: "接下来请点选下面这一问。", choiceReady: true };
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
return { hostNarration: open.prompt, choiceReady: true };
}
if (followup?.choice_frame) {
const spokenFollowup = spokenCollectFallbackFollowup(followup);
@@ -749,15 +755,12 @@ ${nonConvergingRangeNarration({
};
}
export async function ensureNonTerminalTurnExit(input: {
async function inspectNonTerminalTurnExit(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
}) {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
if (projectCurrentQuestion(dossier.conversationSummary.activeFocus)) {
return { persisted: false, choiceReady: false, hostNarration: null };
}
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
@@ -766,12 +769,23 @@ export async function ensureNonTerminalTurnExit(input: {
birthDate = null;
}
const decision = decideFromDossier(dossier, { birthDate });
if (
dossier.case.acceptedTime
const satisfied = Boolean(
projectCurrentQuestion(dossier.conversationSummary.activeFocus)
|| dossier.case.acceptedTime
|| dossier.case.confirmedTime
|| decision.completionStatus === "provisional_range_user_stopped"
|| decision.canAdopt
) {
);
return { dossier, decision, satisfied };
}
export async function ensureNonTerminalTurnExit(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
const before = await inspectNonTerminalTurnExit(input);
if (before.satisfied) {
return { persisted: false, choiceReady: false, hostNarration: null };
}
console.warn(JSON.stringify({
@@ -779,14 +793,19 @@ export async function ensureNonTerminalTurnExit(input: {
case_id: input.caseId,
reason: "missing_question_and_adopt_carrier",
}));
return persistExhaustionCollect({
const repaired = await persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier,
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
dossier: before.dossier,
decision: before.decision,
decisionReceipt: before.dossier.latestResult?.decisionReceipt,
});
const after = await inspectNonTerminalTurnExit(input);
if (!after.satisfied) {
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
}
return repaired;
}
function optionQuoteFromSchema(schema: Readonly<Record<string, unknown>>, optionId: ChoiceKey): string | null {
@@ -23,7 +23,7 @@ import {
type HoldoutValidationStatus,
type RectificationDecision,
} from "../core/rectification-decision.ts";
import type { InferenceState } from "../core/types.ts";
import type { ConflictProbe, InferenceState } from "../core/types.ts";
import {
askedDiscriminatorKeys,
authoritativeCandidateProjection,
@@ -185,6 +185,7 @@ export function contrastPacketFromLatestResult(
const fromInference: EngineContrastProbe[] = (inference?.probes ?? []).flatMap((probe) => {
if (answered.has(probe.id) || probe.information_gain <= 0) return [];
if (probe.source === "known_event_quality") return [];
if (probe.source === "nakshatra_boundary") return [];
return [{
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
@@ -193,7 +194,6 @@ export function contrastPacketFromLatestResult(
user_meaning: probe.question,
information_gain: probe.information_gain,
expected_outcomes: probe.expected_outcomes,
candidate_ids: probe.candidate_ids,
...(probe.choice_kind === "varga_style"
|| probe.choice_kind === "event_quality"
|| probe.choice_kind === "existence"
@@ -212,7 +212,6 @@ export function contrastPacketFromLatestResult(
user_meaning: probe.user_meaning,
information_gain: probe.information_gain,
expected_outcomes: probe.expected_outcomes,
candidate_ids: probe.candidate_ids,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
})),
@@ -273,7 +272,7 @@ export function rectificationFollowupCatalog(
eventClarificationProbes: refinement.event_clarification_probes,
evidenceCollectionProbes: refinement.evidence_collection_probes,
precisionStage: refinement.precision_stage?.current ?? null,
nakshatraBoundary: refinement.nakshatra_boundary,
nakshatraProbe: inference?.probes.find((probe) => probe.source === "nakshatra_boundary") ?? null,
oosBlindPrompts: refinement.oos_blind_prompts,
holdoutEvents: (inference?.events ?? [])
.filter((item) => item.usage === "holdout")
@@ -287,7 +286,7 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket
candidateSetVersion: state.candidate_set_id,
calculationResultId: null,
engineProbes: state.probes
.filter((item) => !answered.has(item.id))
.filter((item) => !answered.has(item.id) && item.source !== "nakshatra_boundary")
.map((item) => ({
semantic_key: item.semantic_key,
candidate_split_hash: item.candidate_split_hash,
@@ -442,6 +441,74 @@ function discriminatorProbeIfFollowupCanAsk(input: {
return { probe: matched ?? input.inspected.selected, dropped };
}
function nakshatraContrastPacket(
probe: ConflictProbe,
candidateSetVersion: string,
): CandidateContrastPacket {
return buildCandidateContrastPacket({
candidateSetVersion,
engineProbes: [{
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
domain: probe.domain,
year: probe.year > 0 ? probe.year : undefined,
user_meaning: probe.question,
information_gain: probe.information_gain,
expected_outcomes: probe.expected_outcomes,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
}],
});
}
function nakshatraProbeIfFollowupCanAsk(input: {
dossier: DecisionDossier;
probe: ConflictProbe | null;
candidateSetVersion: string;
askedKeys: readonly string[];
topCandidateTimes: readonly string[];
holdoutValidation: HoldoutValidationStatus;
birthDate?: string | null;
}): { probe: CandidateDiscriminatorProbe | null; dropped: DroppedProbe[] } {
if (!input.probe) return { probe: null, dropped: [] };
const packet = nakshatraContrastPacket(input.probe, input.candidateSetVersion);
const inspected = inspectDiscriminatorProbes(packet, {
askedKeys: input.askedKeys,
mentionedKeys: mentionedVargaKeysFromLedgerEvidence(input.dossier.evidence),
topCandidateTimes: input.topCandidateTimes,
});
if (!inspected.selected) return { probe: null, dropped: inspected.dropped };
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
contrastPacket: { ...packet, probes: [] },
topCandidateTimes: input.topCandidateTimes,
askedProbeKeys: input.askedKeys,
eventProbes: [],
eventClarificationProbes: [],
evidenceCollectionProbes: [],
precisionStage: null,
nakshatraProbe: input.probe,
holdoutValidation: input.holdoutValidation,
oosBlindPrompts: catalog.oosBlindPrompts,
holdoutEvents: catalog.holdoutEvents,
...(input.birthDate ? { birthDate: input.birthDate } : {}),
candidatesSeparated: false,
});
if (!followupAsksRenderableDiscriminator(plan.next_followup)) {
return { probe: null, dropped: mergeDroppedProbes(inspected.dropped, plan.dropped_probes) };
}
const key = plan.next_followup?.semantic_key;
const matched = key ? completedProbeForSemanticKey(key, packet, []) : null;
return {
probe: matched ?? inspected.selected,
dropped: mergeDroppedProbes(inspected.dropped, plan.dropped_probes),
};
}
function evidenceStopInputs(evidence: DecisionDossier["evidence"]): {
datedEventCount: number;
datedDomainCount: number;
@@ -511,6 +578,16 @@ export function decideFromDossier(
askedKeys,
birthDate: options?.birthDate,
});
const holdoutValidation = holdoutStatusFromInference(inference, oosBlindPrompts);
const nakshatra = nakshatraProbeIfFollowupCanAsk({
dossier,
probe: inference?.probes.find((probe) => probe.source === "nakshatra_boundary") ?? null,
candidateSetVersion: inference?.candidate_set_id ?? "",
askedKeys,
topCandidateTimes,
holdoutValidation,
birthDate: options?.birthDate,
});
return {
...decideRectification({
methodCoverageAll: blockingMethodsCovered(collecting.methods),
@@ -520,7 +597,8 @@ export function decideFromDossier(
snapshotCurrent,
candidateScores: candidateScoresFromDossier(dossier.latestResult),
discriminatorProbe: gated.probe,
holdoutValidation: holdoutStatusFromInference(inference, oosBlindPrompts),
nakshatraBoundaryProbe: nakshatra.probe,
holdoutValidation,
accepted: Boolean(dossier.case.acceptedTime),
inferenceCredibleRange: inference?.credible_range ?? null,
engineCeiling: engineCapabilityCeilingFromReceipt(latest?.decisionReceipt ?? null),
@@ -529,7 +607,7 @@ export function decideFromDossier(
...evidenceStops,
userUncertaintyHigh,
}),
droppedProbes: gated.dropped,
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
};
}
@@ -570,11 +648,29 @@ export function decideAfterInferenceChange(input: {
askedKeys: input.state.answered_probes.map((item) => item.semantic_key),
topCandidateTimes,
});
const askedKeys = input.state.answered_probes.flatMap((item) => [
item.probe_id,
item.semantic_key,
item.candidate_split_hash,
]);
const gated = discriminatorProbeIfFollowupCanAsk({
dossier: input.dossier,
inspected,
contrastPacket,
askedKeys: input.state.answered_probes.map((item) => item.semantic_key),
askedKeys,
birthDate: input.birthDate,
});
const oosBlindPrompts = refinementFromDecisionReceipt(
input.dossier.latestResult?.decisionReceipt ?? null,
).oos_blind_prompts;
const holdoutValidation = holdoutStatusFromState(input.state, oosBlindPrompts);
const nakshatra = nakshatraProbeIfFollowupCanAsk({
dossier: input.dossier,
probe: input.state.probes.find((probe) => probe.source === "nakshatra_boundary") ?? null,
candidateSetVersion: input.state.candidate_set_id,
askedKeys,
topCandidateTimes,
holdoutValidation,
birthDate: input.birthDate,
});
return {
@@ -586,10 +682,8 @@ export function decideAfterInferenceChange(input: {
.filter((item) => item.status !== "eliminated")
.map((item) => ({ time: item.time, score: item.posterior_score })),
discriminatorProbe: gated.probe,
holdoutValidation: holdoutStatusFromState(
input.state,
refinementFromDecisionReceipt(input.dossier.latestResult?.decisionReceipt ?? null).oos_blind_prompts,
),
nakshatraBoundaryProbe: nakshatra.probe,
holdoutValidation,
inferenceCredibleRange: input.state.credible_range,
userStopped: input.userStopped,
accepted: Boolean(input.dossier.case.acceptedTime),
@@ -599,7 +693,7 @@ export function decideAfterInferenceChange(input: {
...evidenceStops,
userUncertaintyHigh,
}),
droppedProbes: gated.dropped,
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
};
}
@@ -13,14 +13,20 @@ import { selectHighestGainProbe } from "../core/select-probe.ts";
import { askedEventProbeKeysFromLedgerEvidence } from "../core/candidate-contrast-packet.ts";
import { rankActive } from "../core/convergence-evaluator.ts";
import { rangeFromTimes, unionStillValidRange } from "../core/credible-range.ts";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import {
previousInferenceFromReceipt as parsePreviousInferenceFromReceipt,
} from "../core/compose-receipt.ts";
import type { AnswerClass, ConflictProbe, InferenceState, ProbeAnswer } from "../core/types.ts";
import { datedPrecision } from "./evidence-model.ts";
import {
isHoldoutVerificationQuote,
type ChoiceKey,
} from "./choice-card.ts";
import type { DiscriminatingEventProbe } from "./refinement-packet.ts";
import {
parseNakshatraBoundary,
type DiscriminatingEventProbe,
type NakshatraBoundary,
} from "./refinement-packet.ts";
function yearFrom(value: string | null | undefined): number | null {
if (!value || value.length < 4 || !/^\d{4}/.test(value)) return null;
@@ -68,7 +74,122 @@ export function askedDiscriminatorKeys(
];
}
export { previousInferenceFromReceipt };
const NAKSHATRA_BOUNDARY_SOURCE = "nakshatra_boundary";
export function nakshatraBoundaryProbe(
state: InferenceState | null | undefined,
boundary: NakshatraBoundary | null | undefined,
): ConflictProbe | null {
if (!state || !boundary?.near_boundary) return null;
const optionA = boundary.options.find((item) => item.key === "A") ?? null;
const optionB = boundary.options.find((item) => item.key === "B") ?? null;
if (
!optionA?.traits.length
|| !optionB?.traits.length
|| optionA.time_bias === optionB.time_bias
) return null;
const active = rankActive(state.candidates)
.slice()
.sort((left, right) => left.time.localeCompare(right.time));
if (active.length < 2) return null;
const pivot = Math.ceil(active.length / 2);
const earlier = active.slice(0, pivot).map((item) => item.id);
const later = active.slice(pivot).map((item) => item.id);
if (earlier.length === 0 || later.length === 0) return null;
const semanticKey = `nakshatra-boundary:${state.candidate_set_id}`;
const candidateSplitHash = `${semanticKey}:${earlier.join(",")}|${later.join(",")}`;
const candidatesFor = (bias: "earlier" | "later") => bias === "earlier" ? earlier : later;
const conflictsFor = (bias: "earlier" | "later") => bias === "earlier" ? later : earlier;
const optionLabel = (key: "A" | "B", traits: readonly string[]) => `${key} 组:${traits.join("、")}`;
return {
id: `probe:${semanticKey}`,
semantic_key: semanticKey,
candidate_split_hash: candidateSplitHash,
domain: "appearance",
year: 0,
question: boundary.user_meaning
?? "升点靠近两段日常节奏的交界。平时做事时,哪一组更像你?这只用来偏置时间窗,不能确认唯一分钟。",
candidate_ids: active.map((item) => item.id),
expected_outcomes: [
{
answer_class: "yes",
supports: candidatesFor(optionA.time_bias),
conflicts: conflictsFor(optionA.time_bias),
},
{
answer_class: "weak_yes",
supports: candidatesFor(optionB.time_bias),
conflicts: conflictsFor(optionB.time_bias),
},
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.01,
source: NAKSHATRA_BOUNDARY_SOURCE,
choice_kind: "varga_style",
style_options: [
{
label: optionLabel("A", optionA.traits),
answer_class: "yes",
sign: optionA.time_bias === "earlier" ? "较早时间窗" : "较晚时间窗",
},
{
label: optionLabel("B", optionB.traits),
answer_class: "weak_yes",
sign: optionB.time_bias === "earlier" ? "较早时间窗" : "较晚时间窗",
},
{ label: "两组都不太像我", answer_class: "no" },
{ label: "一时说不好", answer_class: "unsure" },
],
};
}
export function withNakshatraBoundaryProbe(
state: InferenceState | null,
boundary: NakshatraBoundary | null | undefined,
): InferenceState | null {
if (!state) return null;
const probe = nakshatraBoundaryProbe(state, boundary);
const existingIndexes = state.probes.flatMap((item, index) => (
item.source === NAKSHATRA_BOUNDARY_SOURCE ? [index] : []
));
if (!probe || isDuplicateProbe(probe, state.answered_probes)) {
if (existingIndexes.length === 0) return state;
return {
...state,
probes: state.probes.filter((item) => item.source !== NAKSHATRA_BOUNDARY_SOURCE),
};
}
const existing = existingIndexes.length > 0 ? state.probes[existingIndexes[0]!] : null;
if (
existingIndexes.length === 1
&& existing?.id === probe.id
&& existing.semantic_key === probe.semantic_key
&& existing.candidate_split_hash === probe.candidate_split_hash
) return state;
const probes = state.probes.filter((item) => item.source !== NAKSHATRA_BOUNDARY_SOURCE);
const insertAt = existingIndexes[0] ?? probes.length;
return {
...state,
probes: [
...probes.slice(0, insertAt),
probe,
...probes.slice(insertAt),
],
};
}
export function previousInferenceFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): InferenceState | null {
return withNakshatraBoundaryProbe(
parsePreviousInferenceFromReceipt(receipt),
parseNakshatraBoundary(receipt?.nakshatra_boundary),
);
}
type CandidateSnapshotRow = Readonly<{
candidateId?: string;
@@ -68,6 +68,7 @@ import {
parseAgentChoiceCopy,
preferConcreteChoicePrompt,
mergeChoiceCard,
serverOwnedChoiceCopy,
type RectificationChoiceCard,
type RectificationChoiceFrame,
} from "./choice-card.ts";
@@ -77,6 +78,7 @@ import {
decideRectification,
type HoldoutValidationStatus,
} from "../core/rectification-decision.ts";
import type { ConflictProbe } from "../core/types.ts";
import {
datedDomainsFromEvidence,
isStructuredDiscriminator,
@@ -104,7 +106,6 @@ import type {
DiscriminatingEventProbe,
EventProbeDomain,
EventProbeStyleOption,
NakshatraBoundary,
OosBlindPrompt,
PrecisionStageId,
} from "./refinement-packet";
@@ -947,7 +948,7 @@ export const GENERIC_COLLECT_QUESTION = "请先说一件你记得大概时间的
export function spokenFollowupForUser(followup: MethodFollowup | null): string | null {
if (!followup) return null;
if (followup.choice_frame) return "接下来请点选下面这一问。";
if (followup.choice_frame) return serverOwnedChoiceCopy(followup.choice_frame)?.prompt ?? null;
if (followup.intent !== "collect_method_evidence") return null;
const base = USER_COLLECT_QUESTION[followup.domain ?? ""]
?? GENERIC_COLLECT_QUESTION;
@@ -1307,7 +1308,7 @@ export function buildMethodFollowupPlan(input: {
observations?: readonly InternalVargaObservation[];
sessionOutcome?: SessionOutcomeKind;
precisionStage?: PrecisionStageId | null;
nakshatraBoundary?: NakshatraBoundary | null;
nakshatraProbe?: ConflictProbe | null;
oosBlindPrompts?: readonly OosBlindPrompt[];
eventProbes?: readonly DiscriminatingEventProbe[];
eventClarificationProbes?: readonly DiscriminatingEventProbe[];
@@ -1614,7 +1615,25 @@ export function buildMethodFollowupPlan(input: {
probe_id: contrast.probeId,
}, true, true);
};
if (!dashaCovered) {
if (dashaCovered) {
const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated;
for (const ranked of rankedDiscriminators) {
if (!allowLowGainDiscriminator && ranked.score < 0.08) continue;
const candidate = followupFromRanked(ranked);
if (candidate.choice_frame) {
next = candidate;
break;
}
}
}
if (!next && input.holdoutValidation === "not_started") {
const fields = holdoutAskFields(
input.oosBlindPrompts?.[0],
(input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null,
);
if (fields) next = makeFollowup(fields, false);
}
if (!next && !dashaCovered) {
next = makeFollowup({
method_id: "dasha_events",
intent: "collect_method_evidence",
@@ -1628,37 +1647,54 @@ export function buildMethodFollowupPlan(input: {
),
source: "method_coverage",
});
} else {
}
if (!next && dashaCovered && coverageComplete) {
const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated;
for (const ranked of rankedDiscriminators) {
if (!allowLowGainDiscriminator && ranked.score < 0.08) continue;
const candidate = followupFromRanked(ranked);
if (candidate.choice_frame) {
next = candidate;
break;
}
}
if (!next && coverageComplete) {
const yearless = yearlessDiscriminators[0];
if (yearless && (allowLowGainDiscriminator || yearless.score >= 0.08)) {
const domain = contrastFollowupDomain(
yearless.eventProbe?.domain ?? yearless.contrastProbe?.domain ?? null,
);
const lead = YEARLESS_COLLECT_LEAD[domain];
if (lead && !declined.has(domain)) {
next = makeFollowup({
method_id: PROBE_METHOD_ID[domain],
intent: "collect_method_evidence",
ask_theme: REVERSE_VERIFY_THEME[domain],
domain,
kind_hint: REVERSE_VERIFY_KIND[domain],
user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]),
source: "method_coverage",
});
}
const yearless = yearlessDiscriminators[0];
if (yearless && (allowLowGainDiscriminator || yearless.score >= 0.08)) {
const domain = contrastFollowupDomain(
yearless.eventProbe?.domain ?? yearless.contrastProbe?.domain ?? null,
);
const lead = YEARLESS_COLLECT_LEAD[domain];
if (lead && !declined.has(domain)) {
next = makeFollowup({
method_id: PROBE_METHOD_ID[domain],
intent: "collect_method_evidence",
ask_theme: REVERSE_VERIFY_THEME[domain],
domain,
kind_hint: REVERSE_VERIFY_KIND[domain],
user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]),
source: "method_coverage",
});
}
}
}
if (
!next
&& input.holdoutValidation !== "not_started"
&& !datedMethodCollectOpen(methods)
&& input.nakshatraProbe
) {
const probe = input.nakshatraProbe;
next = makeFollowup({
method_id: "nakshatra_boundary",
intent: "distinguish_candidates",
ask_theme: "nakshatra_trait",
domain: probe.domain,
kind_hint: null,
user_prompt_hint: probe.question,
source: "nakshatra_boundary",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
probe_year: probe.year,
choice_kind: probe.choice_kind ?? "varga_style",
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
style_options: probe.style_options,
probe_id: probe.id,
}, true, true);
}
const sameDomainYearlessCard = (domain: string): MethodFollowup | null => {
const ranked = yearlessDiscriminators.find((row) => (
(row.eventProbe?.domain ?? row.contrastProbe?.domain ?? null) === domain
@@ -1934,23 +1970,6 @@ export function buildMethodFollowupPlan(input: {
),
source: "varga_observation",
});
} else if (input.nakshatraBoundary?.near_boundary) {
next = makeFollowup({
method_id: "nakshatra_boundary",
intent: "distinguish_candidates",
ask_theme: "nakshatra_trait",
domain: null,
kind_hint: null,
user_prompt_hint: input.nakshatraBoundary.user_meaning
?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。",
source: "nakshatra_boundary",
});
} else if (input.holdoutValidation === "not_started") {
const fields = holdoutAskFields(
input.oosBlindPrompts?.[0],
(input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null,
);
if (fields) next = makeFollowup(fields, false);
} else if (horaryStatus === "uncovered") {
next = sameDomainYearlessCard("horary") ?? makeFollowup({
method_id: "horary",
@@ -8,8 +8,10 @@ import {
askedProbeKeysFromReceipt,
stampChoiceSchemaWithProbe,
previousInferenceFromReceipt,
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { spokenFollowupForUser, type MethodFollowup } from "./method-followup";
import { refinementFromDecisionReceipt } from "./refinement-packet";
import {
setV10ConversationFocus,
RectificationToolServiceError,
@@ -90,7 +92,11 @@ function expectedAnswerSchemaFor(
candidate_split_hash: followup.candidate_split_hash ?? null,
choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence",
};
const state = previousInferenceFromReceipt(decisionReceipt ?? null);
const receipt = decisionReceipt ?? null;
const state = withNakshatraBoundaryProbe(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
);
if (decisionReceipt?.inference_state !== undefined && !state) return null;
const stamped = stampChoiceSchemaWithProbe(
schema,
@@ -0,0 +1,79 @@
import {
ensureNonTerminalTurnExit,
persistNextInterviewIfIdle,
} from "./answer-choice.ts";
import { projectCurrentQuestion } from "./turn-decision.ts";
import {
loadV9CaseDossier,
persistV9DeterministicTurn,
type RectificationRpcClient,
} from "./tool-service.ts";
export type RectificationRouteAction =
| "opening"
| "message"
| "read_only"
| "answer_choice"
| "stop_and_review";
export const RECTIFICATION_ACTION_EXECUTION = {
opening: "stream",
message: "hybrid",
read_only: "read_only",
answer_choice: "immediate",
stop_and_review: "immediate",
} as const satisfies Record<RectificationRouteAction, "stream" | "hybrid" | "read_only" | "immediate">;
export async function finalizeSuccessfulTurnExit(input: {
accounting: RectificationRpcClient;
userId: string;
caseId: string;
action: RectificationRouteAction;
}): Promise<void> {
if (input.action === "read_only") {
// Read-only requests must never mutate the interview or create a focus.
return;
}
let focusCreated = false;
try {
const next = await persistNextInterviewIfIdle(input);
focusCreated = next.persisted;
} catch (error) {
console.warn(
`[rectification-v9] persist next interview after turn failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
try {
const repaired = await ensureNonTerminalTurnExit(input);
focusCreated ||= repaired.persisted;
} catch (error) {
console.warn(
`[rectification-v9] nonterminal turn exit repair failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
throw error;
}
if (!focusCreated) return;
try {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const question = projectCurrentQuestion(dossier.conversationSummary.activeFocus);
if (question?.focus_id && question.prompt) {
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: question.focus_id,
userMessage: null,
assistantMessage: question.prompt,
});
}
} catch (error) {
console.warn(
`[rectification-v9] persist server question turn failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
}
export async function awaitTurnExitBeforeResponse<T>(
response: T,
finalize: () => Promise<void>,
): Promise<T> {
await finalize();
return response;
}