fix(rectification): persist refresh attempts and map collect focus kinds (BUG-656/657/658)
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user