fix(rectification): guarantee nonterminal turn exits
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user