fix(rectification): keep same-sentence dated events after structured answers
The choice and collect fast paths applied the answer then returned, so a dated event in the same utterance never reached the evidence ledger. Idle persist also prechecked follow-up with a hardcoded collect_evidence outcome instead of the dossier decision. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
classifyRectificationTurnIntent,
|
||||
optionIdForAnswerClass,
|
||||
shouldDeclineCollectFocus,
|
||||
shouldContinueAgentForDatedEvent,
|
||||
} from "@/lib/rectification-agentic/v9/turn-intent-classifier";
|
||||
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema } from "@/lib/rectification-agentic/v9/server-focus";
|
||||
import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup";
|
||||
@@ -348,6 +349,7 @@ export async function POST(request: Request) {
|
||||
if (!optionId) {
|
||||
return completedMessageResponse("当前问题已更新,请刷新后重新作答。", requestId, caseId);
|
||||
}
|
||||
const continueToAgent = shouldContinueAgentForDatedEvent(classified);
|
||||
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
|
||||
const applied = await applyRectificationChoice(accounting, {
|
||||
userId,
|
||||
@@ -363,8 +365,11 @@ export async function POST(request: Request) {
|
||||
optionId,
|
||||
expectedRevision: previous?.revision ?? 0,
|
||||
userDisplay: parsed.data.message ?? null,
|
||||
deferFollowup: continueToAgent,
|
||||
});
|
||||
return completedMessageResponse(applied.narration, requestId, caseId);
|
||||
if (!continueToAgent) {
|
||||
return completedMessageResponse(applied.narration, requestId, caseId);
|
||||
}
|
||||
}
|
||||
if (classified.intent === "stop_rectification") {
|
||||
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
|
||||
@@ -399,17 +404,21 @@ export async function POST(request: Request) {
|
||||
classified = null;
|
||||
}
|
||||
if (shouldDeclineCollectFocus(classified)) {
|
||||
const continueToAgent = shouldContinueAgentForDatedEvent(classified);
|
||||
const applied = await applyCollectFocusDenial(accounting, {
|
||||
userId,
|
||||
caseId,
|
||||
focusId: focus.id,
|
||||
deferFollowup: continueToAgent,
|
||||
});
|
||||
await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
requestId,
|
||||
userMessage: parsed.data.message ?? null,
|
||||
assistantMessage: applied.narration,
|
||||
});
|
||||
return completedMessageResponse(applied.narration, requestId, caseId);
|
||||
if (!continueToAgent) {
|
||||
await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
requestId,
|
||||
userMessage: parsed.data.message ?? null,
|
||||
assistantMessage: applied.narration,
|
||||
});
|
||||
return completedMessageResponse(applied.narration, requestId, caseId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let birthDate: string | null = null;
|
||||
|
||||
@@ -60,6 +60,7 @@ export type ApplyChoiceCommand = Readonly<{
|
||||
optionId: ChoiceOptionId;
|
||||
expectedRevision: number;
|
||||
userDisplay?: string | null;
|
||||
deferFollowup?: boolean;
|
||||
}>;
|
||||
|
||||
export type AppliedChoiceReceipt = Readonly<{
|
||||
@@ -388,7 +389,7 @@ async function persistFocusAfterChoice(input: {
|
||||
|
||||
export async function applyCollectFocusDenial(
|
||||
accounting: AccountingClient,
|
||||
input: { userId: string; caseId: string; focusId: string },
|
||||
input: { userId: string; caseId: string; focusId: string; deferFollowup?: boolean },
|
||||
): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> {
|
||||
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
|
||||
const focus = dossier.conversationSummary.activeFocus;
|
||||
@@ -400,6 +401,13 @@ export async function applyCollectFocusDenial(
|
||||
status: "declined",
|
||||
evidenceId: null,
|
||||
});
|
||||
if (input.deferFollowup === true) {
|
||||
return {
|
||||
narration: "记下了,这方面先跳过。",
|
||||
nextInterviewPersisted: false,
|
||||
nextChoiceReady: false,
|
||||
};
|
||||
}
|
||||
let birthDate: string | null = null;
|
||||
try {
|
||||
const compute = await loadV9CaseCompute(accounting, input.userId, input.caseId);
|
||||
@@ -456,12 +464,13 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
} catch {
|
||||
birthDate = null;
|
||||
}
|
||||
const decision = decideFromDossier(dossier, { birthDate });
|
||||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: dossier.evidence,
|
||||
activeFocus: null,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: "collect_evidence",
|
||||
sessionOutcome: decision.sessionOutcome,
|
||||
...catalog,
|
||||
birthDate,
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
@@ -469,7 +478,7 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
if (!plan.next_followup) {
|
||||
return { persisted: false, choiceReady: false };
|
||||
}
|
||||
const nextAction = publicNextAction(decideFromDossier(dossier, { birthDate }));
|
||||
const nextAction = publicNextAction(decision);
|
||||
const nextInterview = await persistNextInterviewAfterChoice({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
@@ -543,7 +552,11 @@ async function persistApplied(
|
||||
let nextInterviewPersisted = false;
|
||||
let nextChoiceReady = false;
|
||||
let hostNarration = input.narration;
|
||||
if (input.userStopped !== true && shouldContinueAfterStructuredChoice(nextAction)) {
|
||||
if (
|
||||
command.deferFollowup !== true
|
||||
&& input.userStopped !== true
|
||||
&& shouldContinueAfterStructuredChoice(nextAction)
|
||||
) {
|
||||
const nextInterview = await persistNextInterviewAfterChoice({
|
||||
accounting,
|
||||
userId: command.userId,
|
||||
@@ -559,7 +572,10 @@ async function persistApplied(
|
||||
nextInterviewPersisted = true;
|
||||
}
|
||||
}
|
||||
if (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted })) {
|
||||
if (
|
||||
command.deferFollowup !== true
|
||||
&& (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted }))
|
||||
) {
|
||||
try {
|
||||
await persistV9DeterministicTurn(accounting, command.userId, command.caseId, {
|
||||
requestId: command.actionId,
|
||||
|
||||
@@ -16,6 +16,7 @@ const turnIntentSchema = z.object({
|
||||
"unclear",
|
||||
]),
|
||||
answer_class: z.enum(["yes", "weak_yes", "no", "unsure"]).nullable(),
|
||||
has_new_dated_event: z.boolean().optional(),
|
||||
}).strict();
|
||||
|
||||
export type RectificationTurnIntent = z.infer<typeof turnIntentSchema>;
|
||||
@@ -35,6 +36,12 @@ export function shouldDeclineCollectFocus(
|
||||
return classified?.intent === "answer_current_focus" && classified.answer_class === "no";
|
||||
}
|
||||
|
||||
export function shouldContinueAgentForDatedEvent(
|
||||
classified: RectificationTurnIntent | null,
|
||||
): boolean {
|
||||
return classified?.has_new_dated_event === true;
|
||||
}
|
||||
|
||||
export function optionIdForAnswerClass(
|
||||
focus: ConversationFocus,
|
||||
answerClass: AnswerClass,
|
||||
@@ -66,12 +73,16 @@ export async function classifyRectificationTurnIntent(
|
||||
? `你只做当前生时校正问题的意图分类,不回答用户,也不修改任何状态。
|
||||
结合当前问题和动态选项判断用户是在回答当前问题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。
|
||||
若是在回答当前问题,answer_class 必须使用某个选项提供的 answer_class;否则 answer_class 必须为 null。
|
||||
has_new_dated_event 仅在用户同一句里除了回答当前问题之外,还提供了新的、带大概时间的经历时为 true;单纯的否定或单纯的选项回答必须为 false。
|
||||
若同一句话既回答了当前问题又补充了新的带时间经历,intent 仍为 answer_current_focus,has_new_dated_event 为 true。
|
||||
“当前方面没有、那段时间没有变化”通常是回答当前问题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。
|
||||
不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。`
|
||||
: `你只做当前生时校正采集题的意图分类,不回答用户,也不修改任何状态。
|
||||
当前问题没有点选选项。判断用户是在回答当前采集题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。
|
||||
若用户明确表示这个方面没有发生过、没有这类事,intent 为 answer_current_focus,answer_class 为 no。
|
||||
若用户在补充带时间的经历,intent 为 provide_new_evidence,answer_class 必须为 null。
|
||||
若用户只在补充带时间的经历、并没有回答当前采集题,intent 为 provide_new_evidence,answer_class 必须为 null。
|
||||
has_new_dated_event 仅在用户同一句里除了回答当前采集题之外,还提供了新的、带大概时间的经历时为 true;单纯的否定必须为 false。
|
||||
若同一句话既明确否定当前采集题又补充了新的带时间经历,intent 仍为 answer_current_focus 且 answer_class 为 no,不要改成 provide_new_evidence。
|
||||
“当前方面没有”通常是回答当前采集题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。
|
||||
不要按关键词表或正则猜测,只根据当前问题与用户这句话的语义分类。`,
|
||||
});
|
||||
|
||||
@@ -1195,7 +1195,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
const resolveFocusTool = createTool({
|
||||
id: "rectification-resolve-focus",
|
||||
description:
|
||||
"处理用户对当前问题的明确拒答、跳过或无证据式解决。必须引用服务器返回的 active focusId;若目标是既有证据,可同时引用 evidenceId。点选卡由服务器 answer_choice 处理,不要为 A/B/C/D 或「先这样」调用本工具。自由文本拒答/跳过才带 choiceKey,服务器会立刻更新候选后验,不要等下一次 compare。采集题得到「确实没有」必须用 declined;resolved 只用于已落证据的情形。不得从中文措辞或上一条助手消息猜测目标。",
|
||||
"处理用户对当前问题的明确拒答、跳过或无证据式解决。必须引用服务器返回的 active focusId;若当前没有 active focus,不要调用本工具。若目标是既有证据,可同时引用 evidenceId。点选卡由服务器 answer_choice 处理,不要为 A/B/C/D 或「先这样」调用本工具。自由文本拒答/跳过才带 choiceKey,服务器会立刻更新候选后验,不要等下一次 compare。采集题得到「确实没有」必须用 declined;resolved 只用于已落证据的情形。不得从中文措辞或上一条助手消息猜测目标。",
|
||||
inputSchema: z.object({
|
||||
caseId: z.string().uuid(),
|
||||
focusId: z.string().uuid(),
|
||||
|
||||
@@ -663,6 +663,38 @@ test("answering a discriminator persists the next dated card so GET still has a
|
||||
assert.doesNotMatch(card.prompt, /2015/);
|
||||
});
|
||||
|
||||
test("deferFollowup scores the choice without persisting the next interview or turn", async () => {
|
||||
const accounting = persistChoiceAccounting(twoProbeDossier());
|
||||
const applied = await applyRectificationChoice(accounting.client, {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
actionId: ACTION_ID,
|
||||
action: CHOICE_ACTION,
|
||||
focusId: FOCUS_ID,
|
||||
questionId: QUESTION_ID,
|
||||
probeId: RELOCATION_2015_PROBE.id,
|
||||
optionId: "C",
|
||||
expectedRevision: twoProbeInference().revision,
|
||||
deferFollowup: true,
|
||||
});
|
||||
assert.equal(applied.applied, true);
|
||||
assert.equal(applied.answerClass, "no");
|
||||
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
|
||||
assert.equal(persist?.args.p_option_id, "C");
|
||||
assert.ok(persist?.args.p_inference);
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "append_agentic_rectification_turn"),
|
||||
false,
|
||||
);
|
||||
assert.equal(applied.nextInterviewPersisted, false);
|
||||
assert.equal(applied.narrationPersisted, false);
|
||||
});
|
||||
|
||||
test("answering the last discriminator persists a year-locked family collect focus, not a yearless D24 card", async () => {
|
||||
const accounting = persistChoiceAccounting(familyCollectDossier(), {
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { applyAnswerToState, buildInferenceState } from "../src/lib/rectificatio
|
||||
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
|
||||
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import {
|
||||
decideFromDossier,
|
||||
rectificationFollowupCatalog,
|
||||
type DecisionDossier,
|
||||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
@@ -15,6 +16,7 @@ import { serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choic
|
||||
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import {
|
||||
shouldContinueAgentForDatedEvent,
|
||||
shouldDeclineCollectFocus,
|
||||
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
|
||||
import {
|
||||
@@ -745,4 +747,191 @@ test("collect-focus classifier and resolve-focus copy do not treat explicit no a
|
||||
assert.match(resolveTool, /declined/);
|
||||
assert.match(resolveTool, /采集题|确实没有/);
|
||||
assert.match(resolveTool, /resolved 只用于已落证据|已落证据/);
|
||||
assert.match(resolveTool, /没有 active focus|没有当前.*focus/);
|
||||
});
|
||||
|
||||
test("has_new_dated_event continues into the agent after applying the answer", () => {
|
||||
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||||
const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8");
|
||||
const fastPath = route.slice(
|
||||
route.indexOf('if (action === "message")'),
|
||||
route.indexOf("const requestTime"),
|
||||
);
|
||||
assert.match(classifier, /has_new_dated_event/);
|
||||
assert.match(classifier, /带大概时间的经历/);
|
||||
assert.match(classifier, /intent 仍为 answer_current_focus/);
|
||||
assert.match(classifier, /不要改成 provide_new_evidence/);
|
||||
assert.doesNotMatch(classifier + fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/);
|
||||
assert.doesNotMatch(classifier + fastPath, /(?:userMessage|user_message|message)\.(?:match|search|includes|startsWith|endsWith)\(/);
|
||||
assert.match(fastPath, /shouldContinueAgentForDatedEvent/);
|
||||
assert.match(fastPath, /deferFollowup:\s*continueToAgent/);
|
||||
const choiceApply = fastPath.slice(
|
||||
fastPath.indexOf("if (classified.intent === \"answer_current_focus\")"),
|
||||
fastPath.indexOf("if (classified.intent === \"stop_rectification\")"),
|
||||
);
|
||||
assert.ok(choiceApply.indexOf("applyRectificationChoice") < choiceApply.indexOf("if (!continueToAgent)"));
|
||||
assert.match(choiceApply, /return completedMessageResponse\(applied\.narration/);
|
||||
assert.ok(choiceApply.indexOf("if (!continueToAgent)") < choiceApply.indexOf("return completedMessageResponse(applied.narration"));
|
||||
const collectApply = fastPath.slice(
|
||||
fastPath.indexOf("if (shouldDeclineCollectFocus(classified))"),
|
||||
fastPath.indexOf("} else {"),
|
||||
);
|
||||
assert.ok(collectApply.indexOf("applyCollectFocusDenial") < collectApply.indexOf("if (!continueToAgent)"));
|
||||
assert.match(collectApply, /persistV9DeterministicTurn/);
|
||||
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistV9DeterministicTurn"));
|
||||
assert.ok(route.indexOf("if (action === \"message\")") < route.indexOf("runV9AgentTurn({"));
|
||||
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||||
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
|
||||
assert.match(persistApplied, /command\.deferFollowup !== true/);
|
||||
assert.equal(shouldContinueAgentForDatedEvent({
|
||||
intent: "answer_current_focus",
|
||||
answer_class: "no",
|
||||
has_new_dated_event: true,
|
||||
}), true);
|
||||
assert.equal(shouldContinueAgentForDatedEvent({
|
||||
intent: "answer_current_focus",
|
||||
answer_class: "no",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("collect denial with a new dated event does not persist the next interview before the agent", async () => {
|
||||
const occupationFocus = {
|
||||
id: FOCUS_ID,
|
||||
case_id: CASE_ID,
|
||||
question_id: "occupation:occupation",
|
||||
intent: "collect_method_evidence",
|
||||
target_evidence_id: null,
|
||||
target_domain: "occupation",
|
||||
target_kind: null,
|
||||
expected_answer_schema: {
|
||||
prompt: "你长期做什么工作?",
|
||||
collect: true,
|
||||
},
|
||||
status: "active",
|
||||
asked_at: "2026-08-29T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
};
|
||||
let loads = 0;
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => {
|
||||
loads += 1;
|
||||
if (loads === 1) {
|
||||
return rpcDossier(revision5Dossier(revision5State(), {
|
||||
declinedTopics: [{ target_domain: "family", status: "declined" }],
|
||||
}), occupationFocus);
|
||||
}
|
||||
return rpcDossier(revision5Dossier(revision5State(), {
|
||||
declinedTopics: [
|
||||
{ target_domain: "family", status: "declined" },
|
||||
{ target_domain: "occupation", status: "declined" },
|
||||
],
|
||||
}));
|
||||
},
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus_id: args.p_focus_id,
|
||||
status: args.p_status,
|
||||
evidence_id: null,
|
||||
idempotent: false,
|
||||
}),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: {
|
||||
id: "acacacac-acac-4cac-8cac-acacacacacac",
|
||||
case_id: CASE_ID,
|
||||
question_id: args.p_question_id,
|
||||
intent: args.p_intent,
|
||||
target_evidence_id: args.p_target_evidence_id,
|
||||
target_domain: args.p_target_domain,
|
||||
target_kind: args.p_target_kind,
|
||||
expected_answer_schema: args.p_expected_answer_schema,
|
||||
status: "active",
|
||||
asked_at: "2026-08-29T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
},
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const applied = await applyCollectFocusDenial(accounting.client, {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
focusId: FOCUS_ID,
|
||||
deferFollowup: true,
|
||||
});
|
||||
const resolve = accounting.calls.find((item) => item.fn === "resolve_agentic_rectification_conversation_focus");
|
||||
assert.equal(resolve?.args.p_status, "declined");
|
||||
assert.equal(
|
||||
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
);
|
||||
assert.equal(applied.nextInterviewPersisted, false);
|
||||
});
|
||||
|
||||
test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", async () => {
|
||||
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||||
const idle = source.slice(
|
||||
source.indexOf("export async function persistNextInterviewIfIdle"),
|
||||
source.indexOf("async function persistApplied"),
|
||||
);
|
||||
assert.equal(idle.split("decideFromDossier").length - 1, 1);
|
||||
assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/);
|
||||
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
|
||||
assert.match(idle, /publicNextAction\(decision\)/);
|
||||
|
||||
const covered = revision5Dossier(revision5State([DATED_RELOCATION_2016]), {
|
||||
declinedTopics: [
|
||||
{ target_domain: "family", status: "declined" },
|
||||
{ target_domain: "occupation", status: "declined" },
|
||||
{ target_domain: "horary", status: "declined" },
|
||||
],
|
||||
});
|
||||
const decision = decideFromDossier(covered);
|
||||
assert.notEqual(decision.sessionOutcome, "collect_evidence");
|
||||
const catalog = rectificationFollowupCatalog(covered.latestResult, covered.evidence);
|
||||
const collectPlan = buildMethodFollowupPlan({
|
||||
evidence: covered.evidence,
|
||||
declinedTopics: covered.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: "collect_evidence",
|
||||
...catalog,
|
||||
candidatesSeparated: false,
|
||||
});
|
||||
const decisionPlan = buildMethodFollowupPlan({
|
||||
evidence: covered.evidence,
|
||||
declinedTopics: covered.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: decision.sessionOutcome,
|
||||
...catalog,
|
||||
candidatesSeparated: false,
|
||||
});
|
||||
assert.ok(decisionPlan.next_followup);
|
||||
assert.notEqual(collectPlan.next_followup?.intent, decisionPlan.next_followup?.intent);
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rpcDossier(covered),
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: {
|
||||
id: FOCUS_ID,
|
||||
case_id: CASE_ID,
|
||||
question_id: args.p_question_id,
|
||||
intent: args.p_intent,
|
||||
target_evidence_id: args.p_target_evidence_id,
|
||||
target_domain: args.p_target_domain,
|
||||
target_kind: args.p_target_kind,
|
||||
expected_answer_schema: args.p_expected_answer_schema,
|
||||
status: "active",
|
||||
asked_at: "2026-08-29T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
},
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const persisted = await persistNextInterviewIfIdle({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
});
|
||||
assert.equal(persisted.persisted, true);
|
||||
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||||
assert.ok(setFocus);
|
||||
assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence");
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import test from "node:test";
|
||||
import {
|
||||
optionIdForAnswerClass,
|
||||
parseRectificationTurnIntent,
|
||||
shouldContinueAgentForDatedEvent,
|
||||
shouldDeclineCollectFocus,
|
||||
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
|
||||
import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { CASE_ID, FOCUS_ID } from "./rectification-v9-test-support.ts";
|
||||
@@ -71,6 +73,36 @@ test("turn intent parser enforces answer_class only for current-focus answers",
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("missing has_new_dated_event is treated as false and true is fail-closed optional", () => {
|
||||
const legacy = parseRectificationTurnIntent({
|
||||
intent: "answer_current_focus",
|
||||
answer_class: "no",
|
||||
});
|
||||
assert.equal(legacy?.intent, "answer_current_focus");
|
||||
assert.equal(legacy?.answer_class, "no");
|
||||
assert.equal(legacy?.has_new_dated_event, undefined);
|
||||
assert.equal(shouldContinueAgentForDatedEvent(legacy), false);
|
||||
assert.equal(shouldContinueAgentForDatedEvent(null), false);
|
||||
assert.equal(shouldContinueAgentForDatedEvent({
|
||||
intent: "answer_current_focus",
|
||||
answer_class: "no",
|
||||
has_new_dated_event: false,
|
||||
}), false);
|
||||
const withEvent = parseRectificationTurnIntent({
|
||||
intent: "answer_current_focus",
|
||||
answer_class: "no",
|
||||
has_new_dated_event: true,
|
||||
});
|
||||
assert.equal(withEvent?.has_new_dated_event, true);
|
||||
assert.equal(shouldContinueAgentForDatedEvent(withEvent), true);
|
||||
assert.equal(shouldDeclineCollectFocus(withEvent), true);
|
||||
assert.equal(parseRectificationTurnIntent({
|
||||
intent: "answer_current_focus",
|
||||
answer_class: "no",
|
||||
has_new_dated_event: "yes",
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("answer classes resolve through each dynamic option instead of A/B/C/D position", () => {
|
||||
const focus = focusWithOptions(SHUFFLED_CHOICE);
|
||||
assert.equal(optionIdForAnswerClass(focus, "no"), "A");
|
||||
|
||||
Reference in New Issue
Block a user