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