fix(rectification): persist refresh attempts and map collect focus kinds (BUG-656/657/658)
Independent Staging Quality Gate / validate (push) Failing after 13m28s
Independent Staging Quality Gate / publish (push) Skipped

Empty engine refresh now records an already_answered attempt so GET can leave wait-to-narrow; collect kinds map onto the table CHECK; remaining candidates drive probe refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-12 06:09:20 +08:00
co-authored by Cursor
parent 2b6d28e7a7
commit eae049d0ea
17 changed files with 747 additions and 62 deletions
@@ -125,6 +125,15 @@ export type RoundTrace = Readonly<{
kind: "informative" | "low_information" | "tie_break";
}>;
export type RefreshAttemptResult = "no_new_probes" | "new_probes";
export type RefreshAttempt = Readonly<{
candidate_set_id: string;
answer_count: number;
result: RefreshAttemptResult;
at: string;
}>;
export type InferenceState = Readonly<{
algorithm_version: typeof INFERENCE_ALGORITHM_VERSION;
candidate_set_id: string;
@@ -145,6 +154,8 @@ export type InferenceState = Readonly<{
holdout_passed?: boolean | null;
refresh_count?: number;
refresh_answer_count?: number;
refresh_attempts?: readonly RefreshAttempt[];
targeted_collect_status?: "asked" | "skipped";
transitions?: readonly Readonly<{
layer: string;
at: string;
@@ -25,7 +25,12 @@ import {
RECTIFICATION_USER_COPY,
withLastSuccessfulCompareNotice,
} from "../user-copy.ts";
import { moreCollectHint, preciseGapNarration, rangeNarrowHint } from "./collection-question-pool.ts";
import {
isTargetedCollectFollowup,
moreCollectHint,
preciseGapNarration,
rangeNarrowHint,
} from "./collection-question-pool.ts";
import {
applyChoiceWithoutEvidence,
previousInferenceFromReceipt,
@@ -73,7 +78,15 @@ import {
hadPostAdoptVerifyQuestions,
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus";
import {
persistServerOwnedFocus,
persistSkippedCollectFocus,
persistableFocusDomain,
openQuestionFromPersistedFocus,
isRenderableChoiceOpenQuestion,
linkFocusAskedTurn,
followupHasPersistableDomain,
} from "./server-focus";
import { collectionProgressFromReceipt, trainingScoreableGate } from "./evidence-model";
import { composeCollectSpokenAssistantText } from "./collect-prompt";
import {
@@ -87,6 +100,7 @@ import {
spokenCollectFallbackFollowup,
spokenFollowupForUser,
targetedCollectFollowup,
collectQuestionDomain,
ledgerHasConfirmedDatedEvent,
type MethodCoverage,
type MethodFollowup,
@@ -454,6 +468,38 @@ function dossierWithClosedFocus<T extends {
};
}
function dossierWithSkippedTargetedCollect<T extends {
conversationSummary: {
declinedSkippedTopics: readonly Readonly<Record<string, unknown>>[];
activeFocus: unknown;
};
}>(dossier: T, followup: MethodFollowup): T {
const domain = persistableFocusDomain(followup.domain)
?? persistableFocusDomain(collectQuestionDomain(followup.domain))
?? "other";
const questionId = followup.collection_key
?? `collect:targeted:${domain}`;
const collectKind = followup.kind_hint ?? `targeted:${domain}`;
return {
...dossier,
conversationSummary: {
...dossier.conversationSummary,
activeFocus: null,
declinedSkippedTopics: [
...dossier.conversationSummary.declinedSkippedTopics,
{
target_domain: domain,
status: "skipped",
intent: "collect_method_evidence",
questionId,
target_kind: collectKind,
expected_answer_schema: { collect_kind: collectKind },
},
],
},
};
}
export async function applyRectificationChoice(
accounting: AccountingClient,
command: ApplyChoiceCommand,
@@ -826,7 +872,7 @@ export async function persistNextInterviewAfterChoice(input: {
});
liveDossier = refreshed.dossier;
decisionState = refreshed.state;
if (refreshed.refreshed) {
if (refreshed.refreshed || refreshed.attemptRecorded) {
decision = decideAfterInferenceChange({
dossier: liveDossier,
state: decisionState,
@@ -1328,7 +1374,7 @@ export async function persistNextInterviewIfIdle(input: {
userStopped: input.userStopped,
hasDatedProbe: Boolean(decision.probe),
});
if (refreshed.refreshed) {
if (refreshed.refreshed || refreshed.attemptRecorded) {
const latest = dossier.latestResult;
const overlay = refreshed.dossier.latestResult;
dossier = {
@@ -1485,14 +1531,14 @@ async function persistExhaustionCollect(input: {
focus?: ConversationFocus | null;
terminalNote?: boolean;
}> {
const dossier = input.dossier as DecisionDossier;
let dossier = input.dossier as DecisionDossier;
const receipt = input.decisionReceipt ?? dossier.latestResult?.decisionReceipt ?? null;
const decision = input.decision;
const catalog = rectificationFollowupCatalog(
let decision = input.decision;
let catalog = rectificationFollowupCatalog(
dossier.latestResult ?? null,
dossier.evidence,
);
const followup = exhaustionSpokenCollectFollowup({
let followup = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
answeredProbes: catalog.answeredProbes,
@@ -1571,16 +1617,43 @@ async function persistExhaustionCollect(input: {
focus: persistedFocus.focus,
};
}
// duplicate_focus is not "already asked". Keep the spoken collect or the
// intermediate range sentence instead of falling through to adopt.
const hostNarration = (spoken ?? "").trim() || (range ?? "").trim();
if (hostNarration) {
return {
persisted: false,
choiceReady: false,
hostNarration,
focus: persistedFocus.focus,
};
if (isTargetedCollectFollowup(followup)) {
await persistSkippedCollectFocus({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
followup,
});
dossier = dossierWithSkippedTargetedCollect(dossier, followup);
decision = decideFromDossier(dossier);
catalog = rectificationFollowupCatalog(
dossier.latestResult ?? null,
dossier.evidence,
);
followup = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
}) ?? targetedCollectFollowup(
catalog.remainingLayers,
dossier.evidence,
dossier.conversationSummary.declinedSkippedTopics,
catalog.remainingSplitTimes,
);
if (isTargetedCollectFollowup(followup)) followup = null;
} else {
// duplicate_focus is not "already asked". Keep the spoken collect or the
// intermediate range sentence instead of falling through to adopt.
const hostNarration = (spoken ?? "").trim() || (range ?? "").trim();
if (hostNarration) {
return {
persisted: false,
choiceReady: false,
hostNarration,
focus: persistedFocus.focus,
};
}
}
} else {
console.warn(JSON.stringify({
@@ -115,9 +115,24 @@ function topicKind(topic: CollectionTopic): string {
: "";
}
function topicSchema(topic: CollectionTopic): Readonly<Record<string, unknown>> | null {
const schema = topic.expected_answer_schema ?? topic.expectedAnswerSchema;
return schema && typeof schema === "object" && !Array.isArray(schema)
? schema as Readonly<Record<string, unknown>>
: null;
}
export function topicCollectKind(topic: CollectionTopic): string {
const schema = topicSchema(topic);
const fromSchema = schema && typeof schema.collect_kind === "string"
? schema.collect_kind.trim()
: "";
return fromSchema || topicKind(topic);
}
export function isInviteCollectTopic(topic: CollectionTopic): boolean {
return topicQuestionId(topic).startsWith("collect:invite:")
|| topicKind(topic) === "invite_more";
|| topicCollectKind(topic) === "invite_more";
}
export function askedCollectKeys(topics: readonly CollectionTopic[]): ReadonlySet<string> {
@@ -128,8 +143,8 @@ export function askedCollectKeys(topics: readonly CollectionTopic[]): ReadonlySe
if (isInviteCollectTopic(topic)) continue;
const questionId = topicQuestionId(topic).replace(/:(?:next|next2|next3)$/, "");
if (questionId.startsWith("collect:")) keys.add(questionId);
const kind = topicKind(topic);
if (kind.startsWith("anchor:") || kind.startsWith("generic:")) {
const kind = topicCollectKind(topic);
if (kind.startsWith("anchor:") || kind.startsWith("generic:") || kind.startsWith("targeted:")) {
keys.add(`collect:${kind}`);
}
}
@@ -526,18 +541,42 @@ export function remainingSplitTimes(
return [clocks[0], clocks[clocks.length - 1]];
}
export function isTargetedCollectTopic(topic: CollectionTopic): boolean {
const questionId = topicQuestionId(topic);
const kind = topicCollectKind(topic);
return questionId.startsWith("collect:targeted:")
|| kind.startsWith("targeted:")
|| topicDomain(topic) === "targeted";
}
export function isTargetedCollectClosed(
topics: readonly CollectionTopic[] = [],
): boolean {
return topics.some((topic) => {
const status = topicStatus(topic);
if (status === "active" || !status) return false;
return isTargetedCollectTopic(topic);
});
}
export function isTargetedCollectFollowup(followup: {
collection_key?: string;
kind_hint?: string | null;
} | null | undefined): boolean {
if (!followup) return false;
const key = followup.collection_key ?? "";
const hint = followup.kind_hint ?? "";
return key.startsWith("collect:targeted:")
|| hint.startsWith("targeted:");
}
export function isTargetedCollectDeclined(
topics: readonly CollectionTopic[] = [],
): boolean {
return topics.some((topic) => {
const status = topicStatus(topic);
if (status !== "declined" && status !== "skipped") return false;
const questionId = topicQuestionId(topic);
const kind = topicKind(topic);
const domain = topicDomain(topic);
return questionId.startsWith("collect:targeted:")
|| kind.startsWith("targeted:")
|| domain === "targeted";
return isTargetedCollectTopic(topic);
});
}
@@ -56,6 +56,7 @@ import {
remainingSplitTimes,
targetedCollectPool,
isTargetedCollectDeclined,
isTargetedCollectClosed,
} from "./collection-question-pool.ts";
import { evidenceLedgerFingerprint } from "./tool-service";
import { followupCaseArgs, blockScanDeclinedForFingerprint } from "./block-scan.ts";
@@ -509,13 +510,27 @@ function narrowingExhaustion(
live.remainingSplitTimes,
);
const remainingLayers = live.remainingLayers;
const refreshed = (inference?.refresh_count ?? 0) >= 1;
// No remaining split layers: BUG-651 pool-empty delivery still holds.
// Remaining layers without a refresh: wait (BUG-653 accident).
const receipt = dossier.latestResult?.decisionReceipt ?? null;
const attempts = inference?.refresh_attempts
?? (Array.isArray(receipt?.refresh_attempts) ? receipt.refresh_attempts : []);
const attempted = Boolean(
inference
&& Array.isArray(attempts)
&& attempts.some((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
const row = item as { candidate_set_id?: unknown; answer_count?: unknown };
return row.candidate_set_id === inference.candidate_set_id
&& row.answer_count === inference.answered_probes.length;
}),
);
return {
refreshExhausted: options?.refreshExhausted ?? (refreshed || remainingLayers.length === 0),
refreshExhausted: options?.refreshExhausted ?? (
attempted
|| remainingLayers.length === 0
|| (inference?.refresh_count ?? 0) >= 1
),
targetedCollectExhausted: options?.targetedCollectExhausted
?? (isTargetedCollectDeclined(declined) || targeted.length === 0),
?? (isTargetedCollectClosed(declined) || isTargetedCollectDeclined(declined) || targeted.length === 0),
};
}
@@ -1468,6 +1468,18 @@ export function exhaustionSpokenCollectFollowup(input: {
return nextCollectionFollowup(input.evidence, input.declinedTopics ?? []);
}
function collectKindFromFocus(focus: {
targetKind?: string | null;
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
}): string | null {
const schema = focus.expectedAnswerSchema;
const fromSchema = typeof schema?.collect_kind === "string" ? schema.collect_kind.trim() : "";
if (fromSchema) return fromSchema;
return typeof focus.targetKind === "string" && focus.targetKind.trim()
? focus.targetKind
: null;
}
export const OPENING_COLLECT_DOMAIN = "other";
export function collectQuestionDomain(domain: string | null | undefined): string {
@@ -2163,7 +2175,7 @@ export function buildMethodFollowupPlan(input: {
intent: focus.intent || "reverse_verify",
ask_theme: keepAskTheme,
domain: focus.targetDomain,
kind_hint: focus.targetKind,
kind_hint: collectKindFromFocus(focus),
user_prompt_hint: `${acceptedKeepHint}先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。`,
source: focus.intent === "out_of_sample_check" ? "oos_blind" : "reverse_verify",
...(keepProbeYear ? {
@@ -2185,7 +2197,7 @@ export function buildMethodFollowupPlan(input: {
intent: focus.intent || "active_focus",
ask_theme: "active_focus",
domain: parseCollectFocusQuestionId(focus.questionId)?.domain ?? focus.targetDomain,
kind_hint: focus.targetKind,
kind_hint: collectKindFromFocus(focus),
user_prompt_hint: keepChoice
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
@@ -7,7 +7,7 @@
*/
import { probeFromEngine } from "../core/probes-from-engine.ts";
import type { ConflictProbe, InferenceState, ProbeAnswer } from "../core/types.ts";
import type { ConflictProbe, InferenceState, ProbeAnswer, RefreshAttempt, RefreshAttemptResult } from "../core/types.ts";
import { askedDiscriminatorKeys, previousInferenceFromReceipt } from "./inference-adapter.ts";
import {
runV9CandidateScore,
@@ -43,6 +43,59 @@ export type RefreshDiscriminatorProbesResult = Readonly<{
refreshCount: number;
}>;
export function refreshAttemptsOf(
state: InferenceState | null | undefined,
receipt?: Readonly<Record<string, unknown>> | null,
): readonly RefreshAttempt[] {
const fromState = state?.refresh_attempts;
if (Array.isArray(fromState) && fromState.length > 0) return fromState;
const fromReceipt = receipt?.refresh_attempts;
if (!Array.isArray(fromReceipt)) return fromState ?? [];
return fromReceipt.flatMap((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const row = item as Record<string, unknown>;
const candidateSetId = typeof row.candidate_set_id === "string" ? row.candidate_set_id : "";
const answerCount = typeof row.answer_count === "number" ? row.answer_count : null;
const result = row.result === "no_new_probes" || row.result === "new_probes"
? row.result
: null;
const at = typeof row.at === "string" ? row.at : "";
if (!candidateSetId || answerCount == null || result == null) return [];
return [{ candidate_set_id: candidateSetId, answer_count: answerCount, result, at }];
});
}
export function hasRefreshAttemptFor(
state: InferenceState | null | undefined,
candidateSetId: string,
answerCount: number,
receipt?: Readonly<Record<string, unknown>> | null,
): boolean {
return refreshAttemptsOf(state, receipt).some((item) => (
item.candidate_set_id === candidateSetId && item.answer_count === answerCount
));
}
function withRefreshAttempt(
state: InferenceState,
result: RefreshAttemptResult,
): InferenceState {
const attempt: RefreshAttempt = {
candidate_set_id: state.candidate_set_id,
answer_count: state.answered_probes.length,
result,
at: new Date().toISOString(),
};
const previous = refreshAttemptsOf(state);
if (previous.some((item) => (
item.candidate_set_id === attempt.candidate_set_id
&& item.answer_count === attempt.answer_count
))) {
return { ...state, refresh_attempts: previous };
}
return { ...state, refresh_attempts: [...previous, attempt] };
}
type RefreshImpl = (input: RefreshDiscriminatorProbesInput) => Promise<RefreshDiscriminatorProbesResult>;
function isRefreshableDatedProbe(probe: DiscriminatingEventProbe): boolean {
@@ -276,6 +329,7 @@ export function applyRefreshedProbesToDossier(
decisionReceipt: {
...receipt,
inference_state: state,
refresh_attempts: state.refresh_attempts ?? [],
discriminating_event_probes: mergeEventProbes(existing, extraEventProbes),
},
},
@@ -292,6 +346,14 @@ function shouldRefreshDatedPool(input: {
if (input.dossier.case.acceptedTime) return false;
if (!input.state) return false;
if (input.hasDatedProbe) return false;
if (hasRefreshAttemptFor(
input.state,
input.state.candidate_set_id,
input.state.answered_probes.length,
input.dossier.latestResult?.decisionReceipt,
)) {
return false;
}
const refreshCount = input.state.refresh_count ?? 0;
if (refreshCount >= MAX_DISCRIMINATOR_REFRESHES) return false;
if (
@@ -329,6 +391,55 @@ function refreshWriteBlockedReason(
return null;
}
async function persistRefreshAttempt(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
dossier: DecisionDossier;
previous: InferenceState;
next: InferenceState;
}): Promise<boolean> {
const last = input.next.answered_probes.at(-1);
if (!last) return false;
const evidenceFp = input.dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(input.dossier.evidence as never);
try {
await persistV9InferenceState(input.accounting, input.userId, input.caseId, {
expectedRevision: input.previous.revision,
probeId: last.probe_id,
openProbeId: last.probe_id,
semanticKey: last.semantic_key,
candidateSplitHash: last.candidate_split_hash,
answerClass: last.answer_class,
rawAnswer: "refresh_attempt",
inferenceState: input.next as unknown as Record<string, unknown>,
posteriorBefore: Object.fromEntries(
input.previous.candidates.map((item) => [item.time, item.posterior_score]),
),
posteriorAfter: Object.fromEntries(
input.next.candidates.map((item) => [item.time, item.posterior_score]),
),
scoreDeltas: {},
decisionStateFingerprint: inferenceFingerprintForState(
input.caseId,
evidenceFp,
input.next,
),
reason: "already_answered",
idempotencyKey: `refresh_attempt:${input.next.candidate_set_id}:${input.next.answered_probes.length}`,
candidateSetId: input.next.candidate_set_id,
});
return true;
} catch (error) {
console.warn(
`[rectification-v9] persist refresh attempt failed case=${input.caseId} reason=${
error instanceof Error ? error.message : String(error)
}`,
);
return false;
}
}
function refreshPersistBlockedReason(input: {
previous: InferenceState;
next: InferenceState;
@@ -415,16 +526,26 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
dossier: DecisionDossier;
state: InferenceState | null;
refreshed: boolean;
attemptRecorded: boolean;
}> {
const state = input.state
?? previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null);
const alreadyAttempted = Boolean(
state
&& hasRefreshAttemptFor(
state,
state.candidate_set_id,
state.answered_probes.length,
input.dossier.latestResult?.decisionReceipt,
),
);
if (!shouldRefreshDatedPool({
dossier: input.dossier,
state,
userStopped: input.userStopped,
hasDatedProbe: input.hasDatedProbe,
}) || !state) {
return { dossier: input.dossier, state, refreshed: false };
return { dossier: input.dossier, state, refreshed: false, attemptRecorded: alreadyAttempted };
}
const result = await refreshDiscriminatorProbes({
accounting: input.accounting,
@@ -444,14 +565,32 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
candidate_set_id: result.state.candidate_set_id,
refresh_count: state.refresh_count ?? 0,
}));
return { dossier: input.dossier, state, refreshed: false };
const attempted = withRefreshAttempt(
{ ...state, revision: state.revision + 1 },
"no_new_probes",
);
await persistRefreshAttempt({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: input.dossier,
previous: state,
next: attempted,
});
return {
dossier: applyRefreshedProbesToDossier(input.dossier, attempted),
state: attempted,
refreshed: false,
attemptRecorded: true,
};
}
const nextState: InferenceState = {
const nextState: InferenceState = withRefreshAttempt({
...state,
revision: state.revision + 1,
probes: mergeConflictProbes(state.probes, askable, state.answered_probes),
refresh_count: (state.refresh_count ?? 0) + 1,
refresh_answer_count: state.answered_probes.length,
};
}, "new_probes");
const written = await persistRefreshedInference({
accounting: input.accounting,
userId: input.userId,
@@ -462,7 +601,12 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
askableCount: askable.length,
});
if (!written) {
return { dossier: input.dossier, state, refreshed: false };
return {
dossier: applyRefreshedProbesToDossier(input.dossier, nextState),
state: nextState,
refreshed: false,
attemptRecorded: true,
};
}
const persistableEventProbes = result.eventProbes.filter((probe) => {
const key = probe.semantic_key?.trim() ?? `${probe.domain}.${probe.year}`;
@@ -472,5 +616,6 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
dossier: applyRefreshedProbesToDossier(input.dossier, nextState, persistableEventProbes),
state: nextState,
refreshed: true,
attemptRecorded: true,
};
}
@@ -15,6 +15,7 @@ import { USER_COLLECT_QUESTION } from "../user-copy";
import { refinementFromDecisionReceipt } from "./refinement-packet";
import {
setV10ConversationFocus,
resolveV10ConversationFocus,
RectificationToolServiceError,
safeToolErrorCode,
type AccountingClient,
@@ -207,6 +208,34 @@ const PERSISTABLE_FOCUS_DOMAINS = new Set([
"other",
]);
/** Matches `agentic_rectification_conversation_focuses.target_kind` CHECK. */
export const FOCUS_TARGET_KIND_CHECK = [
"education_start", "education_completion", "education_interruption",
"career_entry", "career_change", "promotion", "career_pressure", "career_exit",
"relationship_start", "relationship_commitment", "relationship_separation",
"relocation", "finance_gain", "finance_loss",
"self_health_event", "family_event", "other",
] as const;
const FOCUS_TARGET_KIND_CHECK_SET = new Set<string>(FOCUS_TARGET_KIND_CHECK);
const COLLECT_FOCUS_KIND_BY_DOMAIN: Readonly<Record<string, string>> = {
family: "family_event",
finance: "finance_gain",
relocation: "relocation",
relationship: "relationship_change",
career: "career_change",
education: "education_milestone",
health: "self_health_event",
};
const FOCUS_TARGET_KIND_ALIASES: Readonly<Record<string, string>> = {
education_milestone: "education_start",
relationship_change: "relationship_start",
finance_change: "finance_gain",
home_change: "relocation",
};
export function persistableFocusDomain(domain: string | null | undefined): string | null {
if (!domain || domain === "unknown" || domain === "active_focus") return null;
if (domain === "health_pressure") return "health";
@@ -215,6 +244,34 @@ export function persistableFocusDomain(domain: string | null | undefined): strin
return null;
}
function clampFocusTargetKind(kind: string | null | undefined): string | null {
const value = kind?.trim() || "";
if (!value) return null;
if (FOCUS_TARGET_KIND_CHECK_SET.has(value)) return value;
const aliased = FOCUS_TARGET_KIND_ALIASES[value];
return aliased && FOCUS_TARGET_KIND_CHECK_SET.has(aliased) ? aliased : null;
}
export function collectKindFromSchema(
schema: Readonly<Record<string, unknown>> | null | undefined,
): string | null {
const value = schema?.collect_kind;
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function collectFocusTargetKind(followup: Pick<MethodFollowup, "kind_hint" | "domain">): string | null {
const hint = followup.kind_hint?.trim() || "";
const fromHint = clampFocusTargetKind(hint);
if (fromHint) return fromHint;
if (hint.startsWith("anchor:")) {
const inner = clampFocusTargetKind(hint.split(":")[1] ?? "");
if (inner) return inner;
}
const domain = persistableFocusDomain(followup.domain)
?? persistableFocusDomain(collectQuestionDomain(followup.domain));
return clampFocusTargetKind(domain ? COLLECT_FOCUS_KIND_BY_DOMAIN[domain] ?? null : null);
}
export function followupHasPersistableDomain(followup: Pick<MethodFollowup, "domain">): boolean {
return Boolean(
persistableFocusDomain(followup.domain)
@@ -261,6 +318,7 @@ export function collectFocusSchema(followup: MethodFollowup): Record<string, unk
[COLLECT_FOCUS_SCHEMA_KEY]: true,
};
if (followup.semantic_key) schema.semantic_key = followup.semantic_key;
if (followup.kind_hint) schema.collect_kind = followup.kind_hint;
if (followup.date_reliability_evidence_id) {
schema.date_reliability = true;
schema.target_evidence_id = followup.date_reliability_evidence_id;
@@ -343,7 +401,7 @@ async function persistCollectFocus(input: {
?? (input.followup.intent === "collect_method_evidence"
? persistableFocusDomain(collectQuestionDomain(input.followup.domain))
: null),
targetKind: input.followup.kind_hint ?? null,
targetKind: collectFocusTargetKind(input.followup),
expectedAnswerSchema: schema,
askedTurnId: input.askedTurnId ?? null,
});
@@ -570,3 +628,33 @@ async function persistServerOwnedFocusCore(input: {
throw error;
}
}
export async function persistSkippedCollectFocus(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
followup: MethodFollowup;
}): Promise<ConversationFocus | null> {
const schema = collectFocusSchema(input.followup);
if (!schema) return null;
const questionId = stableFollowupQuestionId(input.followup);
try {
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
questionId,
intent: input.followup.intent,
targetEvidenceId: input.followup.date_reliability_evidence_id ?? null,
targetDomain: persistableFocusDomain(input.followup.domain)
?? persistableFocusDomain(collectQuestionDomain(input.followup.domain)),
targetKind: collectFocusTargetKind(input.followup),
expectedAnswerSchema: schema,
});
if (!result.focus.id) return result.focus;
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
focusId: result.focus.id,
status: "skipped",
});
return { ...result.focus, status: "skipped" };
} catch {
return null;
}
}
@@ -355,6 +355,7 @@ export function interviewCollectWaiting(input: Readonly<{
}>): boolean {
if (!input.questionMissing || input.offeringCards) return false;
if (input.sessionOutcome && input.sessionOutcome !== "collect_evidence") return false;
if (input.sessionOutcome === "collect_evidence" && !input.stopReason) return true;
return input.stopReason === "insufficient_dated_events"
|| input.stopReason === "insufficient_domains"
|| input.stopReason === "insufficient_events";
@@ -33,7 +33,7 @@ import {
setRefreshDiscriminatorProbesForTests,
} from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { persistServerOwnedFocus, FOCUS_TARGET_KIND_CHECK } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
import {
@@ -374,7 +374,18 @@ function accidentDossier(answeredCount: number, extra: {
} = {}): DecisionDossier {
const loaded = liveState(answeredCount);
const state = extra.refreshCount != null
? { ...loaded, refresh_count: extra.refreshCount }
? {
...loaded,
refresh_count: extra.refreshCount,
refresh_attempts: extra.refreshCount >= 1
? [{
candidate_set_id: loaded.candidate_set_id,
answer_count: loaded.answered_probes.length,
result: "no_new_probes" as const,
at: "2026-09-11T00:00:00.000Z",
}]
: loaded.refresh_attempts,
}
: loaded;
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
return {
@@ -477,7 +488,26 @@ function idleHandlers(decision: DecisionDossier, extra: {
} = {}) {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra),
get_agentic_rectification_case_dossier: () => {
const base = rpcDossier(decision, extra);
const last = extra.transitions?.at(-1);
const inference = last?.p_inference_state;
const latest = base.latest_result as {
decision_receipt?: Record<string, unknown>;
} | null | undefined;
if (!inference || typeof inference !== "object" || !latest) return base;
return {
...base,
latest_result: {
...latest,
decision_receipt: {
...(latest.decision_receipt ?? {}),
inference_state: inference,
refresh_attempts: (inference as { refresh_attempts?: unknown }).refresh_attempts,
},
},
};
},
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
@@ -598,6 +628,13 @@ test("T0: sixth dated answer must refresh or targeted-collect, not deliver a car
assert.match(host, /家里|收入|搬家|感情|还能再收窄|添丁|住院/);
assert.doesNotMatch(host, /这次给出|最终|做不了|才会变|没有拿到下一个问题/);
assert.equal(persisted.choiceReady, false);
const focusWrite = idleAccounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(focusWrite, "targeted collect must become the active focus");
assert.match(String(focusWrite.args.p_question_id ?? ""), /collect:targeted:/);
assert.ok(
(FOCUS_TARGET_KIND_CHECK as readonly string[]).includes(String(focusWrite.args.p_target_kind ?? "")),
String(focusWrite.args.p_target_kind),
);
for (const phrase of COLLECT_FLOW_BANNED_PHRASES) {
if (phrase === "领域") continue;
assert.equal(host.includes(phrase), false, phrase);
@@ -676,6 +713,8 @@ test("T3: skipped persist still leaves a non-empty carrier; 没有了 delivers t
}));
const next = result as Awaited<ReturnType<typeof persistNextInterviewAfterChoice>>;
assert.ok((next.hostNarration ?? "").trim(), "BUG-652: never silent empty carrier");
assert.match(next.hostNarration, /目前范围/);
assert.doesNotMatch(next.hostNarration, /没有拿到下一个问题/);
const skippedDirect = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
@@ -908,24 +947,76 @@ test("T0: last inference row and GET probe key vs persist status after a real re
resetRefreshDiscriminatorProbesForTests();
});
test("T3: refresh without new engine probes does not write inference", async () => {
test("T3: refresh without new engine probes persists an already_answered attempt", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: (state.refresh_count ?? 0) + 1,
}));
let engineCalls = 0;
setRefreshDiscriminatorProbesForTests(async ({ state }) => {
engineCalls += 1;
return {
state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: (state.refresh_count ?? 0) + 1,
};
});
const dossier = accidentDossier(6);
const transitions: Record<string, unknown>[] = [];
const accounting = idleHandlers(dossier, { transitions });
const idle = await persistNextInterviewIfIdle({
accounting: idleHandlers(dossier, { transitions }).client,
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(transitions.length, 0, JSON.stringify(transitions.at(-1) ?? {}));
const last = transitions.at(-1);
const inference = last?.p_inference_state as {
refresh_attempts?: ReadonlyArray<{
candidate_set_id?: string;
answer_count?: number;
result?: string;
}>;
probes?: unknown[];
candidate_set_id?: string;
} | undefined;
assert.equal(last?.p_reason, "already_answered", JSON.stringify(last ?? {}));
assert.equal(last?.p_raw_answer, "refresh_attempt");
assert.equal(inference?.refresh_attempts?.at(-1)?.result, "no_new_probes");
assert.equal(inference?.refresh_attempts?.at(-1)?.answer_count, liveState(6).answered_probes.length);
assert.equal(inference?.candidate_set_id, liveState(6).candidate_set_id);
assert.equal((inference as { refresh_count?: number } | undefined)?.refresh_count ?? 0, 0);
assert.equal(
(inference?.probes ?? []).some((item) => (
Boolean(item)
&& typeof item === "object"
&& (item as { semantic_key?: string }).semantic_key === FAMILY_REFRESH.semantic_key
)),
false,
);
assert.ok((idle.hostNarration ?? "").trim());
const overlayed = {
...dossier,
latestResult: {
...dossier.latestResult!,
decisionReceipt: {
...(dossier.latestResult?.decisionReceipt ?? {}),
inference_state: inference,
refresh_attempts: inference?.refresh_attempts,
},
},
};
const getDecision = decideFromDossier(overlayed, { birthDate: "1997-08-08" });
assert.equal(getDecision.nextAction, "ask_fact_collection", getDecision.nextAction);
assert.match(
followupPlan(overlayed, getDecision.sessionOutcome).next_followup?.collection_key ?? "",
/collect:targeted:/,
);
const second = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(engineCalls, 1, `second refresh called the engine ${engineCalls} times`);
assert.ok((second.hostNarration ?? idle.hostNarration ?? "").trim());
resetRefreshDiscriminatorProbesForTests();
});
@@ -949,7 +1040,13 @@ test("T3: changed candidate set is not persisted even when engine returns a prob
nextAction: publicNextAction(decideFromDossier(dossier, { birthDate: "1997-08-08" })),
birthDate: "1997-08-08",
});
assert.equal(transitions.length, 0);
const last = transitions.at(-1);
assert.equal(last?.p_reason, "already_answered");
assert.notEqual(last?.p_reason, "supersede");
assert.equal(last?.p_candidate_set_id, liveState(6).candidate_set_id);
const inference = last?.p_inference_state as { candidate_set_id?: string; candidates?: unknown[] } | undefined;
assert.equal(inference?.candidate_set_id, liveState(6).candidate_set_id);
assert.equal(inference?.candidates?.length, TIMES.length);
resetRefreshDiscriminatorProbesForTests();
});
@@ -973,7 +1070,12 @@ test("T3: empty candidate list is not persisted even when engine returns a probe
hasDatedProbe: false,
});
assert.equal(refreshed.refreshed, false);
assert.equal(transitions.length, 0);
assert.equal(refreshed.attemptRecorded, true);
const last = transitions.at(-1);
assert.equal(last?.p_reason, "already_answered");
assert.notEqual(last?.p_reason, "supersede");
const inference = last?.p_inference_state as { candidates?: unknown[] } | undefined;
assert.equal(inference?.candidates?.length, TIMES.length);
resetRefreshDiscriminatorProbesForTests();
});
@@ -5,6 +5,8 @@ import test from "node:test";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
COLLECT_FOCUS_RETRY_SUFFIX,
FOCUS_TARGET_KIND_CHECK,
collectFocusTargetKind,
openQuestionFromPersistedFocus,
persistableFocusDomain,
persistServerOwnedFocus,
@@ -924,3 +926,55 @@ test("frameless precision-stage distinguish is skipped instead of opening GENERI
GENERIC_COLLECT_QUESTION,
);
});
function targetKindCheckFromSql(): string[] {
const sql = readFileSync(new URL(
"../supabase/migrations/20260814020000_rectification_v10_runtime.sql",
import.meta.url,
), "utf8");
const match = /target_kind text check \(\s*target_kind is null or target_kind in \(\s*([\s\S]*?)\)\s*\)/.exec(sql);
assert.ok(match?.[1], "target_kind CHECK missing");
return [...match[1].matchAll(/'([a-z_]+)'/g)].map((item) => item[1]);
}
test("T1: collect focus kinds map to the table CHECK enum", async () => {
const allowed = targetKindCheckFromSql();
assert.deepEqual([...FOCUS_TARGET_KIND_CHECK], allowed);
const cases: ReadonlyArray<{
kind_hint: string;
domain: string;
collectKind: string;
}> = [
{ kind_hint: "targeted:family", domain: "family", collectKind: "targeted:family" },
{ kind_hint: "anchor:education_start:2016", domain: "education", collectKind: "anchor:education_start:2016" },
{ kind_hint: "generic:relationship", domain: "relationship", collectKind: "generic:relationship" },
];
for (const item of cases) {
const mapped = collectFocusTargetKind({ kind_hint: item.kind_hint, domain: item.domain });
assert.ok(mapped && allowed.includes(mapped), `${item.kind_hint} -> ${mapped}`);
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => focusRowFromArgs(args),
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup({
domain: item.domain,
kind_hint: item.kind_hint,
collection_key: item.kind_hint.startsWith("targeted:")
? `collect:targeted:${item.domain}`
: undefined,
}),
});
assert.equal(persisted.status, "created", item.kind_hint);
assert.ok(
persisted.focus?.targetKind && allowed.includes(persisted.focus.targetKind),
`${item.kind_hint} wrote ${persisted.focus?.targetKind}`,
);
assert.equal(persisted.focus?.expectedAnswerSchema.collect_kind, item.collectKind);
assert.equal(accounting.calls[0]?.args.p_target_kind, persisted.focus?.targetKind);
}
});
@@ -72,6 +72,13 @@ test("question gap: collect waiting is not the unavailable repair path", () => {
}),
true,
);
assert.equal(
interviewCollectWaiting({
sessionOutcome: "collect_evidence",
questionMissing: true,
}),
true,
);
assert.equal(
rectificationQuestionGapState({
...gapBase,