Keep targeted existence questions as A-D cards. Recover collect-schema stock by question-id prefix, surface the stem when persist fails, and send spoken targeted existence to the repair exit instead of a naked prompt.
719 lines
24 KiB
TypeScript
719 lines
24 KiB
TypeScript
import {
|
|
isPersistedFocusId,
|
|
parseAgentChoiceCopy,
|
|
serverOwnedChoiceCopy,
|
|
type RectificationChoiceFrame,
|
|
} from "./choice-card";
|
|
import {
|
|
askedProbeKeysFromReceipt,
|
|
stampChoiceSchemaWithProbe,
|
|
previousInferenceFromReceipt,
|
|
withNakshatraBoundaryProbe,
|
|
} from "./inference-adapter";
|
|
import {
|
|
spokenFollowupForUser,
|
|
spokenCollectFallbackFollowup,
|
|
collectQuestionDomain,
|
|
rebuildTargetedCollectExistenceFollowup,
|
|
type MethodFollowup,
|
|
} from "./method-followup";
|
|
import { isTargetedCollectExistenceFollowup } from "./collection-question-pool";
|
|
import { USER_COLLECT_QUESTION } from "../user-copy";
|
|
import { refinementFromDecisionReceipt } from "./refinement-packet";
|
|
import {
|
|
setV10ConversationFocus,
|
|
resolveV10ConversationFocus,
|
|
RectificationToolServiceError,
|
|
safeToolErrorCode,
|
|
type AccountingClient,
|
|
type ConversationFocus,
|
|
} from "./tool-service";
|
|
|
|
export { parsePersistedFollowupQuestionId, parseCollectFocusQuestionId } from "./method-followup";
|
|
|
|
export type PersistServerFocusStatus =
|
|
| "created"
|
|
| "already_open"
|
|
| "duplicate_focus"
|
|
| "probe_already_answered"
|
|
| "zero_information_gain"
|
|
| "invalid_choice_schema"
|
|
| "skipped";
|
|
|
|
export type PersistServerFocusResult = Readonly<{
|
|
status: PersistServerFocusStatus;
|
|
focus: ConversationFocus | null;
|
|
questionId: string | null;
|
|
prompt: string | null;
|
|
}>;
|
|
|
|
export function stableFollowupQuestionId(followup: MethodFollowup): string {
|
|
if (followup.collection_key) return followup.collection_key.slice(0, 160);
|
|
if (followup.semantic_key) return `probe:${followup.semantic_key}`.slice(0, 160);
|
|
if (
|
|
followup.probe_year
|
|
&& followup.domain
|
|
&& followup.intent !== "reverse_verify"
|
|
&& followup.intent !== "out_of_sample_check"
|
|
) {
|
|
return `${followup.method_id}:${followup.domain}:${followup.probe_year}`.slice(0, 160);
|
|
}
|
|
if (followup.intent === "collect_method_evidence" && !followup.choice_frame) {
|
|
return `collect:${collectQuestionDomain(followup.domain)}:${followup.intent}`.slice(0, 160);
|
|
}
|
|
return (followup.choice_frame?.question_id ?? `${followup.method_id}:${followup.ask_theme}`).slice(0, 160);
|
|
}
|
|
|
|
function schemaProbeId(schema: Readonly<Record<string, unknown>> | null | undefined): string | null {
|
|
const probeId = schema?.probe_id;
|
|
return typeof probeId === "string" && probeId.trim() ? probeId : null;
|
|
}
|
|
|
|
export function shouldSkipDiscriminatorFollowup(followup: MethodFollowup): PersistServerFocusStatus | null {
|
|
if (
|
|
followup.source === "event_probe"
|
|
&& (followup.information_gain ?? 0) <= 0
|
|
) {
|
|
return "zero_information_gain";
|
|
}
|
|
if (
|
|
followup.intent === "distinguish_candidates"
|
|
&& ((followup.candidate_ids?.length ?? 0) < 2 || (followup.expected_outcomes?.length ?? 0) < 2)
|
|
) {
|
|
return "zero_information_gain";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function expectedAnswerSchemaFor(
|
|
frame: RectificationChoiceFrame,
|
|
questionId: string,
|
|
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined,
|
|
followup: MethodFollowup,
|
|
): Record<string, unknown> | null {
|
|
const copy = serverOwnedChoiceCopy(frame);
|
|
if (!copy) return null;
|
|
const schema: Record<string, unknown> = {
|
|
choice: {
|
|
prompt: copy.prompt,
|
|
option_a: copy.option_a,
|
|
option_b: copy.option_b,
|
|
option_c: copy.option_c,
|
|
option_d: copy.option_d,
|
|
options: copy.options,
|
|
},
|
|
semantic_key: followup.semantic_key ?? null,
|
|
candidate_split_hash: followup.candidate_split_hash ?? null,
|
|
choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence",
|
|
scoring: frame.scoring,
|
|
...(followup.collection_key?.startsWith("collect:targeted:")
|
|
? {
|
|
targeted_collect: true,
|
|
collect_kind: followup.kind_hint ?? null,
|
|
}
|
|
: {}),
|
|
...(followup.tie_break_round ? { tie_break_round: true } : {}),
|
|
...(followup.block_periods ? { block_periods: followup.block_periods } : {}),
|
|
...(followup.widen_windows ? { widen_windows: followup.widen_windows } : {}),
|
|
};
|
|
const receipt = decisionReceipt ?? null;
|
|
const state = withNakshatraBoundaryProbe(
|
|
previousInferenceFromReceipt(receipt),
|
|
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
|
|
);
|
|
const verifyOnly = followup.intent === "reverse_verify" || followup.intent === "out_of_sample_check";
|
|
const targetedCollect = Boolean(followup.collection_key?.startsWith("collect:targeted:"));
|
|
if (!targetedCollect && decisionReceipt?.inference_state !== undefined && !state) return null;
|
|
const stamped = stampChoiceSchemaWithProbe(
|
|
schema,
|
|
verifyOnly || targetedCollect ? null : state,
|
|
questionId,
|
|
{
|
|
semantic_key: followup.semantic_key,
|
|
candidate_split_hash: followup.candidate_split_hash,
|
|
},
|
|
);
|
|
if (typeof followup.probe_year === "number" && followup.probe_year > 0) {
|
|
stamped.probe_year = followup.probe_year;
|
|
}
|
|
if (verifyOnly || targetedCollect) return stamped;
|
|
return state && stamped.scoring !== false && !schemaProbeId(stamped) ? null : stamped;
|
|
}
|
|
|
|
export type PersistedOpenQuestion = Readonly<{
|
|
question_id: string | null;
|
|
prompt: string | null;
|
|
status: PersistServerFocusStatus;
|
|
kind: "choice" | "collect_spoken";
|
|
focus_id?: string | null;
|
|
probe_id?: string | null;
|
|
intent?: string;
|
|
domain?: string | null;
|
|
unrenderable?: true;
|
|
reason?: string;
|
|
}>;
|
|
|
|
export function openQuestionFromPersistedFocus(result: PersistServerFocusResult): PersistedOpenQuestion | null {
|
|
if (
|
|
(result.status !== "created" && result.status !== "already_open")
|
|
|| !result.focus
|
|
|| !isPersistedFocusId(result.focus.id)
|
|
|| result.focus.questionId !== result.questionId
|
|
) return null;
|
|
const schema = result.focus.expectedAnswerSchema;
|
|
if (isCollectFocusSchema(schema) && schema) {
|
|
const prompt = (typeof result.prompt === "string" && result.prompt.trim()
|
|
? result.prompt.trim()
|
|
: typeof schema.prompt === "string" ? schema.prompt.trim() : "");
|
|
if (!prompt) return null;
|
|
return {
|
|
question_id: result.questionId,
|
|
prompt,
|
|
status: result.status,
|
|
kind: "collect_spoken",
|
|
focus_id: result.focus.id,
|
|
probe_id: typeof schema.probe_id === "string" ? schema.probe_id : null,
|
|
intent: result.focus.intent,
|
|
domain: result.focus.targetDomain ?? null,
|
|
};
|
|
}
|
|
if (!result.prompt || !parseAgentChoiceCopy(schema)) {
|
|
return {
|
|
question_id: result.questionId,
|
|
prompt: null,
|
|
status: result.status,
|
|
kind: "choice",
|
|
unrenderable: true,
|
|
reason: "invalid_choice_schema",
|
|
};
|
|
}
|
|
return {
|
|
question_id: result.questionId,
|
|
prompt: result.prompt,
|
|
status: result.status,
|
|
kind: "choice",
|
|
};
|
|
}
|
|
|
|
export function isRenderableChoiceOpenQuestion(
|
|
open: PersistedOpenQuestion | null | undefined,
|
|
): open is PersistedOpenQuestion & { kind: "choice" } {
|
|
return Boolean(open && open.kind === "choice" && open.unrenderable !== true);
|
|
}
|
|
|
|
export const COLLECT_FOCUS_SCHEMA_KEY = "collect";
|
|
export const COLLECT_FOCUS_RETRY_SUFFIX = "next";
|
|
/** Suffixes tried after a collect `question_id` unique conflict. Probe ids must not use these. */
|
|
export const COLLECT_FOCUS_RETRY_SUFFIXES = ["next", "next2", "next3"] as const;
|
|
|
|
export function collectFocusRetryQuestionIds(questionId: string): string[] {
|
|
return COLLECT_FOCUS_RETRY_SUFFIXES
|
|
.map((suffix) => `${questionId}:${suffix}`.slice(0, 160))
|
|
.filter((id) => id !== questionId);
|
|
}
|
|
|
|
const PERSISTABLE_FOCUS_DOMAINS = new Set([
|
|
"education",
|
|
"career",
|
|
"relationship",
|
|
"relocation",
|
|
"finance",
|
|
"health",
|
|
"family",
|
|
"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";
|
|
if (domain === "occupation") return "other";
|
|
if (PERSISTABLE_FOCUS_DOMAINS.has(domain)) return domain;
|
|
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)
|
|
?? persistableFocusDomain(collectQuestionDomain(followup.domain)),
|
|
);
|
|
}
|
|
|
|
function isFocusIdempotencyConflict(error: unknown): boolean {
|
|
const code = error instanceof RectificationToolServiceError
|
|
? error.code
|
|
: safeToolErrorCode(error);
|
|
return code === "focus_idempotency_conflict" || code.includes("focus_idempotency_conflict");
|
|
}
|
|
|
|
export async function linkFocusAskedTurn(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
focus: ConversationFocus;
|
|
askedTurnId?: string | null;
|
|
}): Promise<ConversationFocus> {
|
|
if (!input.askedTurnId || input.focus.askedTurnId) return input.focus;
|
|
try {
|
|
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
|
questionId: input.focus.questionId,
|
|
intent: input.focus.intent,
|
|
targetEvidenceId: input.focus.targetEvidenceId,
|
|
targetDomain: input.focus.targetDomain,
|
|
targetKind: input.focus.targetKind,
|
|
expectedAnswerSchema: input.focus.expectedAnswerSchema,
|
|
askedTurnId: input.askedTurnId,
|
|
});
|
|
return result.focus;
|
|
} catch {
|
|
return input.focus;
|
|
}
|
|
}
|
|
|
|
export function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> | null {
|
|
const prompt = spokenFollowupForUser({ ...followup, choice_frame: null });
|
|
if (!prompt) return null;
|
|
const schema: Record<string, unknown> = {
|
|
prompt,
|
|
[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;
|
|
}
|
|
return schema;
|
|
}
|
|
|
|
export function serverOwnedExpectedAnswerSchema(
|
|
followup: MethodFollowup,
|
|
decisionReceipt?: Readonly<Record<string, unknown>> | null,
|
|
): Record<string, unknown> | null {
|
|
const targeted = isTargetedCollectExistenceFollowup(followup)
|
|
? rebuildTargetedCollectExistenceFollowup(followup) ?? followup
|
|
: followup;
|
|
const frame = targeted.choice_frame;
|
|
if (frame) {
|
|
const schema = expectedAnswerSchemaFor(
|
|
frame,
|
|
stableFollowupQuestionId(targeted),
|
|
decisionReceipt,
|
|
targeted,
|
|
);
|
|
if (schema?.choice) return schema;
|
|
if (isTargetedCollectExistenceFollowup(targeted)) return null;
|
|
if (followup.intent !== "collect_method_evidence") return null;
|
|
return collectFocusSchema(spokenCollectFallbackFollowup(followup));
|
|
}
|
|
if (isTargetedCollectExistenceFollowup(followup)) return null;
|
|
return collectFocusSchema(followup);
|
|
}
|
|
|
|
export function isCollectFocusSchema(schema: Readonly<Record<string, unknown>> | null | undefined): boolean {
|
|
return schema?.[COLLECT_FOCUS_SCHEMA_KEY] === true && typeof schema.prompt === "string";
|
|
}
|
|
|
|
async function persistCollectFocus(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
activeFocus: ConversationFocus | null;
|
|
followup: MethodFollowup;
|
|
askedTurnId?: string | null;
|
|
}): Promise<PersistServerFocusResult> {
|
|
const schema = collectFocusSchema(input.followup);
|
|
if (!schema) {
|
|
return {
|
|
status: "skipped",
|
|
focus: input.activeFocus,
|
|
questionId: null,
|
|
prompt: null,
|
|
};
|
|
}
|
|
if (!followupHasPersistableDomain(input.followup)) {
|
|
return {
|
|
status: "skipped",
|
|
focus: input.activeFocus,
|
|
questionId: null,
|
|
prompt: null,
|
|
};
|
|
}
|
|
const questionId = stableFollowupQuestionId(input.followup);
|
|
const prompt = typeof schema.prompt === "string" ? schema.prompt : null;
|
|
const active = input.activeFocus;
|
|
if (
|
|
active
|
|
&& active.questionId === questionId
|
|
&& active.questionId !== "active_focus:active_focus"
|
|
&& isCollectFocusSchema(active.expectedAnswerSchema)
|
|
) {
|
|
const focus = await linkFocusAskedTurn({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
focus: active,
|
|
askedTurnId: input.askedTurnId,
|
|
});
|
|
return { status: "already_open", focus, questionId: focus.questionId, prompt };
|
|
}
|
|
const insertFocus = async (id: string): Promise<PersistServerFocusResult> => {
|
|
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
|
questionId: id,
|
|
intent: input.followup.intent,
|
|
targetEvidenceId: input.followup.date_reliability_evidence_id ?? null,
|
|
targetDomain: persistableFocusDomain(input.followup.domain)
|
|
?? (input.followup.intent === "collect_method_evidence"
|
|
? persistableFocusDomain(collectQuestionDomain(input.followup.domain))
|
|
: null),
|
|
targetKind: collectFocusTargetKind(input.followup),
|
|
expectedAnswerSchema: schema,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
return {
|
|
status: result.idempotent ? "already_open" : "created",
|
|
focus: result.focus,
|
|
questionId: result.focus.questionId,
|
|
prompt,
|
|
};
|
|
};
|
|
try {
|
|
return await insertFocus(questionId);
|
|
} catch (error) {
|
|
if (!isFocusIdempotencyConflict(error)) {
|
|
return {
|
|
status: "skipped",
|
|
focus: input.activeFocus,
|
|
questionId: null,
|
|
prompt: null,
|
|
};
|
|
}
|
|
// Superseded rows still occupy the unique (case_id, question_id). Always
|
|
// try suffix ids so an unasked collect can be asked again. collect_retry
|
|
// only switches copy, it does not gate the suffix.
|
|
let lastId = questionId;
|
|
for (const retryId of collectFocusRetryQuestionIds(questionId)) {
|
|
lastId = retryId;
|
|
try {
|
|
return await insertFocus(retryId);
|
|
} catch (retryError) {
|
|
if (!isFocusIdempotencyConflict(retryError)) {
|
|
return {
|
|
status: "skipped",
|
|
focus: input.activeFocus,
|
|
questionId: null,
|
|
prompt: null,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
status: "duplicate_focus",
|
|
focus: input.activeFocus,
|
|
questionId: lastId,
|
|
prompt,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function persistSpokenChoiceFallback(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
activeFocus: ConversationFocus | null;
|
|
followup: MethodFollowup;
|
|
askedTurnId?: string | null;
|
|
}): Promise<PersistServerFocusResult> {
|
|
const skipped = {
|
|
status: "skipped" as const,
|
|
focus: input.activeFocus,
|
|
questionId: null,
|
|
prompt: null,
|
|
};
|
|
if (input.followup.intent !== "collect_method_evidence") return skipped;
|
|
const domain = collectQuestionDomain(input.followup.domain);
|
|
if (!USER_COLLECT_QUESTION[domain] && !USER_COLLECT_QUESTION[input.followup.domain ?? ""]) {
|
|
return skipped;
|
|
}
|
|
if (!followupHasPersistableDomain(input.followup)) return skipped;
|
|
return persistCollectFocus({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
activeFocus: input.activeFocus,
|
|
followup: spokenCollectFallbackFollowup(input.followup),
|
|
askedTurnId: input.askedTurnId,
|
|
});
|
|
}
|
|
|
|
export async function persistServerOwnedFocus(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
activeFocus: ConversationFocus | null;
|
|
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
|
|
followup: MethodFollowup | null;
|
|
askedTurnId?: string | null;
|
|
}): Promise<PersistServerFocusResult> {
|
|
const result = await persistServerOwnedFocusCore(input);
|
|
if (result.status !== "created" && result.status !== "already_open") {
|
|
console.warn(JSON.stringify({
|
|
event: "rectification_discriminator_persist_skipped",
|
|
case_id: input.caseId,
|
|
status: result.status,
|
|
semantic_key: input.followup?.semantic_key ?? null,
|
|
}));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function persistServerOwnedFocusCore(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
activeFocus: ConversationFocus | null;
|
|
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
|
|
followup: MethodFollowup | null;
|
|
askedTurnId?: string | null;
|
|
}): Promise<PersistServerFocusResult> {
|
|
const followup = input.followup
|
|
? (isTargetedCollectExistenceFollowup(input.followup)
|
|
? rebuildTargetedCollectExistenceFollowup(input.followup) ?? input.followup
|
|
: input.followup)
|
|
: null;
|
|
const frame = followup?.choice_frame ?? null;
|
|
if (!followup) {
|
|
return {
|
|
status: "skipped",
|
|
focus: input.activeFocus,
|
|
questionId: null,
|
|
prompt: null,
|
|
};
|
|
}
|
|
if (isTargetedCollectExistenceFollowup(followup) && !frame) {
|
|
return {
|
|
status: "skipped",
|
|
focus: input.activeFocus,
|
|
questionId: null,
|
|
prompt: null,
|
|
};
|
|
}
|
|
if (!frame) {
|
|
if (followup.intent === "distinguish_candidates") {
|
|
return {
|
|
status: "skipped",
|
|
focus: input.activeFocus,
|
|
questionId: null,
|
|
prompt: null,
|
|
};
|
|
}
|
|
if (followup.intent === "collect_method_evidence") {
|
|
return persistCollectFocus({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
activeFocus: input.activeFocus,
|
|
followup,
|
|
askedTurnId: input.askedTurnId,
|
|
});
|
|
}
|
|
return {
|
|
status: "skipped",
|
|
focus: input.activeFocus,
|
|
questionId: null,
|
|
prompt: null,
|
|
};
|
|
}
|
|
const skip = shouldSkipDiscriminatorFollowup(followup);
|
|
if (skip) {
|
|
return { status: skip, focus: input.activeFocus, questionId: null, prompt: null };
|
|
}
|
|
const questionId = stableFollowupQuestionId(followup);
|
|
const answeredKeys = new Set(askedProbeKeysFromReceipt(input.decisionReceipt));
|
|
if (followup.semantic_key && answeredKeys.has(followup.semantic_key)) {
|
|
return {
|
|
status: "probe_already_answered",
|
|
focus: input.activeFocus,
|
|
questionId,
|
|
prompt: null,
|
|
};
|
|
}
|
|
const copy = serverOwnedChoiceCopy(frame);
|
|
if (!copy) {
|
|
if (followup.intent === "distinguish_candidates") {
|
|
return persistSpokenChoiceFallback({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
activeFocus: input.activeFocus,
|
|
followup,
|
|
askedTurnId: input.askedTurnId,
|
|
});
|
|
}
|
|
return { status: "invalid_choice_schema", focus: input.activeFocus, questionId, prompt: null };
|
|
}
|
|
const schema = expectedAnswerSchemaFor(frame, questionId, input.decisionReceipt, followup);
|
|
if (!schema?.choice) {
|
|
return { status: "invalid_choice_schema", focus: input.activeFocus, questionId, prompt: null };
|
|
}
|
|
const prompt = copy?.prompt ?? null;
|
|
const active = input.activeFocus;
|
|
if (
|
|
active
|
|
&& (
|
|
active.questionId === questionId
|
|
|| (schemaProbeId(schema) && schemaProbeId(active.expectedAnswerSchema) === schemaProbeId(schema))
|
|
)
|
|
) {
|
|
const focus = await linkFocusAskedTurn({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
focus: active,
|
|
askedTurnId: input.askedTurnId,
|
|
});
|
|
return { status: "already_open", focus, questionId: focus.questionId, prompt };
|
|
}
|
|
try {
|
|
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
|
questionId,
|
|
intent: followup.intent,
|
|
targetEvidenceId: followup.date_reliability_evidence_id ?? null,
|
|
targetDomain: followup.intent === "collect_method_evidence"
|
|
? persistableFocusDomain(followup.domain)
|
|
?? persistableFocusDomain(collectQuestionDomain(followup.domain))
|
|
: followup.domain,
|
|
targetKind: followup.intent === "collect_method_evidence"
|
|
? collectFocusTargetKind(followup)
|
|
: null,
|
|
expectedAnswerSchema: schema,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
return {
|
|
status: result.idempotent ? "already_open" : "created",
|
|
focus: result.focus,
|
|
questionId: result.focus.questionId,
|
|
prompt,
|
|
};
|
|
} catch (error) {
|
|
const code = error instanceof RectificationToolServiceError
|
|
? error.code
|
|
: safeToolErrorCode(error);
|
|
if (code === "focus_idempotency_conflict" || code.includes("focus_idempotency_conflict")) {
|
|
return {
|
|
status: "duplicate_focus",
|
|
focus: input.activeFocus,
|
|
questionId,
|
|
prompt,
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function persistSkippedCollectFocus(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
followup: MethodFollowup;
|
|
}): Promise<ConversationFocus | null> {
|
|
const targeted = isTargetedCollectExistenceFollowup(input.followup)
|
|
? rebuildTargetedCollectExistenceFollowup(input.followup)
|
|
: null;
|
|
const followup = targeted ?? input.followup;
|
|
const schema = targeted
|
|
? serverOwnedExpectedAnswerSchema(targeted, null)
|
|
: collectFocusSchema(followup);
|
|
if (!schema || (targeted && !schema.choice)) return null;
|
|
const questionId = stableFollowupQuestionId(followup);
|
|
try {
|
|
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
|
questionId,
|
|
intent: followup.intent,
|
|
targetEvidenceId: followup.date_reliability_evidence_id ?? null,
|
|
targetDomain: persistableFocusDomain(followup.domain)
|
|
?? persistableFocusDomain(collectQuestionDomain(followup.domain)),
|
|
targetKind: collectFocusTargetKind(followup),
|
|
expectedAnswerSchema: schema,
|
|
});
|
|
if (!result.focus.id) return result.focus;
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
try {
|
|
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
|
focusId: result.focus.id,
|
|
status: "skipped",
|
|
});
|
|
return { ...result.focus, status: "skipped" };
|
|
} catch (error) {
|
|
if (attempt === 1) {
|
|
console.warn(JSON.stringify({
|
|
event: "rectification_skipped_collect_focus_resolve_failed",
|
|
case_id: input.caseId,
|
|
question_id: questionId,
|
|
reason: safeToolErrorCode(error),
|
|
}));
|
|
return { ...result.focus, status: "skipped" };
|
|
}
|
|
}
|
|
}
|
|
return { ...result.focus, status: "skipped" };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|