Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/server-focus.ts
T
jesse-ux 8221c6121b
Independent Staging Quality Gate / validate (push) Successful in 10m39s
Independent Staging Quality Gate / publish (push) Successful in 2m9s
fix(rectification): restore targeted collect cards from spoken focus (BUG-673)
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.
2026-09-14 01:51:55 +08:00

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;
}
}