Skip to content

Commit 987bb4a

Browse files
committed
Fix confirmation with edits
1 parent 9485d23 commit 987bb4a

6 files changed

Lines changed: 260 additions & 23 deletions

File tree

apps/web/src/lib/server/decide-turn-policy.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,79 @@ describe("decideTurnPolicy", () => {
415415
});
416416
});
417417

418+
it("routes compound confirmation (classified as clarification_answer) with enough info to execute_mutation when proposal ID cleared", () => {
419+
const result = decideTurnPolicy(
420+
input(
421+
// resolvedProposalId is cleared by the guard — no stale proposal binding
422+
{ turnType: "clarification_answer", confidence: 0.9, resolvedEntityIds: ["task-1"] },
423+
{ committedSlots: { day: "tomorrow", time: "17:00" } },
424+
{
425+
rawText: "ok but make it 5pm",
426+
normalizedText: "ok but make it 5pm",
427+
recentTurns: [],
428+
entityRegistry: [
429+
{
430+
id: "proposal-1",
431+
conversationId: "conversation-1",
432+
kind: "proposal_option",
433+
label: "Schedule it tomorrow at 3pm",
434+
status: "presented",
435+
createdAt: "2026-03-20T16:00:00.000Z",
436+
updatedAt: "2026-03-20T16:00:00.000Z",
437+
data: {
438+
route: "conversation_then_mutation",
439+
replyText: "Would you like me to schedule it tomorrow at 3pm?",
440+
confirmationRequired: true,
441+
targetEntityId: "task-1"
442+
}
443+
}
444+
]
445+
}
446+
)
447+
);
448+
449+
expect(result).toMatchObject({
450+
action: "execute_mutation",
451+
mutationInputSource: "direct_user_turn"
452+
});
453+
expect(result.targetProposalId).toBeUndefined();
454+
});
455+
456+
it("routes compound confirmation (classified as clarification_answer) with missing slots to ask_clarification", () => {
457+
expect(
458+
decideTurnPolicy(
459+
input(
460+
{ turnType: "clarification_answer", confidence: 0.9 },
461+
{ committedSlots: { day: "tomorrow" }, missingSlots: ["time"], needsClarification: ["time"] },
462+
{
463+
rawText: "ok but tomorrow",
464+
normalizedText: "ok but tomorrow",
465+
recentTurns: [],
466+
entityRegistry: [
467+
{
468+
id: "proposal-1",
469+
conversationId: "conversation-1",
470+
kind: "proposal_option",
471+
label: "Schedule it at 3pm",
472+
status: "presented",
473+
createdAt: "2026-03-20T16:00:00.000Z",
474+
updatedAt: "2026-03-20T16:00:00.000Z",
475+
data: {
476+
route: "conversation_then_mutation",
477+
replyText: "Would you like me to schedule it at 3pm?",
478+
confirmationRequired: true
479+
}
480+
}
481+
]
482+
}
483+
)
484+
)
485+
).toMatchObject({
486+
action: "ask_clarification",
487+
clarificationSlots: expect.arrayContaining(["time"])
488+
});
489+
});
490+
418491
it("returns committedSlots on every policy decision", () => {
419492
const slots = { day: "tomorrow", time: "17:00" };
420493
const result = decideTurnPolicy(

apps/web/src/lib/server/llm-classifier.test.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,17 +122,27 @@ describe("classifyTurn", () => {
122122
expect(client.responses.parse).toHaveBeenCalledOnce();
123123
});
124124

125-
it("fast-exits informational when no clarifications and no write verbs", async () => {
126-
const result = await classifyTurn({
127-
normalizedText: "What do I have tomorrow?",
128-
discourseState: null,
129-
entityRegistry: []
125+
it("routes informational questions through LLM when fast-exit is disabled", async () => {
126+
const client = mockClient({
127+
turnType: "informational",
128+
confidence: 0.91,
129+
reasoning: "User is asking about their schedule"
130130
});
131131

132+
const result = await classifyTurn(
133+
{
134+
normalizedText: "What do I have tomorrow?",
135+
discourseState: null,
136+
entityRegistry: []
137+
},
138+
client
139+
);
140+
132141
expect(result).toMatchObject({
133142
turnType: "informational",
134-
confidence: 0.93
143+
confidence: 0.91
135144
});
145+
expect(client.responses.parse).toHaveBeenCalledOnce();
136146
});
137147

138148
it("does not fast-exit informational when active clarifications exist", async () => {

apps/web/src/lib/server/llm-classifier.ts

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export async function classifyTurn(
2929
const singleProposal = activeProposals.length === 1 ? activeProposals[0] : null;
3030

3131
// Fast-exit confirmation: exact match + exactly one active/presented proposal
32-
if (isConfirmationTurn(lower) && singleProposal) {
32+
if (isPureConfirmationTurn(lower) && singleProposal) {
3333
return {
3434
turnType: "confirmation",
3535
confidence: 0.97,
@@ -40,18 +40,19 @@ export async function classifyTurn(
4040
};
4141
}
4242

43+
// TEMP: disabled informational fast-exit until routing stabilizes
4344
// Fast-exit informational: question lead + no write verbs + no active clarifications
44-
const activeClarifications = discourseState
45-
? getActivePendingClarifications(discourseState)
46-
: [];
45+
// const activeClarifications = discourseState
46+
// ? getActivePendingClarifications(discourseState)
47+
// : [];
4748

48-
if (isInformationalTurn(lower) && activeClarifications.length === 0 && !containsWriteVerb(lower)) {
49-
return {
50-
turnType: "informational",
51-
confidence: 0.93,
52-
resolvedEntityIds
53-
};
54-
}
49+
// if (isInformationalTurn(lower) && activeClarifications.length === 0 && !containsWriteVerb(lower)) {
50+
// return {
51+
// turnType: "informational",
52+
// confidence: 0.93,
53+
// resolvedEntityIds
54+
// };
55+
// }
5556

5657
// Everything else → LLM
5758
try {
@@ -73,10 +74,8 @@ export async function classifyTurn(
7374
}
7475
}
7576

76-
function isConfirmationTurn(lower: string) {
77-
return /^(yes|yeah|yep|ok|okay|do it|sounds good|works|that works|go ahead|please do|confirm)([.,!? ]*)?$/.test(
78-
lower
79-
);
77+
function isPureConfirmationTurn(lower: string) {
78+
return /^(ok|okay|yes|yep|yeah|confirm|do it|go ahead)([.,!? ]*)?$/.test(lower);
8079
}
8180

8281
function isInformationalTurn(lower: string) {

apps/web/src/lib/server/turn-router.test.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
22

33
import type { TurnClassifierOutput } from "@atlas/core";
44

5-
import { routeMessageTurn } from "./turn-router";
5+
import { routeMessageTurn, containsModificationPayload } from "./turn-router";
66

77
vi.mock("./llm-classifier", () => ({
88
classifyTurn: vi.fn()
@@ -254,4 +254,123 @@ describe("turn router", () => {
254254
intentKind: "plan"
255255
});
256256
});
257+
258+
it("reclassifies compound confirmation with active proposal to clarification_answer", async () => {
259+
mockClassification({
260+
turnType: "confirmation",
261+
confidence: 0.95,
262+
resolvedProposalId: "proposal-1"
263+
});
264+
mockExtractSlots.mockResolvedValueOnce({
265+
extractedValues: { time: "17:00" },
266+
confidence: { time: 0.92 },
267+
unresolvable: []
268+
});
269+
270+
const result = await routeMessageTurn({
271+
rawText: "ok but make it 5pm",
272+
normalizedText: "ok but make it 5pm",
273+
recentTurns: [],
274+
entityRegistry: [
275+
{
276+
id: "proposal-1",
277+
conversationId: "conversation-1",
278+
kind: "proposal_option",
279+
label: "Schedule it at 3pm",
280+
status: "presented",
281+
createdAt: "2026-03-20T16:00:00.000Z",
282+
updatedAt: "2026-03-20T16:00:00.000Z",
283+
data: {
284+
route: "conversation_then_mutation",
285+
replyText: "Would you like me to schedule it at 3pm?",
286+
confirmationRequired: true,
287+
targetEntityId: null,
288+
mutationInputSource: null
289+
}
290+
}
291+
]
292+
});
293+
294+
expect(result.interpretation.turnType).toBe("clarification_answer");
295+
expect(result.interpretation.resolvedProposalId).toBeUndefined();
296+
expect(result.policy.action).not.toBe("recover_and_execute");
297+
});
298+
299+
it("does not reclassify pure confirmation with active proposal", async () => {
300+
mockClassification({
301+
turnType: "confirmation",
302+
confidence: 0.97,
303+
resolvedProposalId: "proposal-1"
304+
});
305+
306+
const result = await routeMessageTurn({
307+
rawText: "ok",
308+
normalizedText: "ok",
309+
recentTurns: [],
310+
entityRegistry: [
311+
{
312+
id: "proposal-1",
313+
conversationId: "conversation-1",
314+
kind: "proposal_option",
315+
label: "Schedule it at 3pm",
316+
status: "active",
317+
createdAt: "2026-03-20T16:00:00.000Z",
318+
updatedAt: "2026-03-20T16:00:00.000Z",
319+
data: {
320+
route: "conversation_then_mutation",
321+
replyText: "Would you like me to schedule it at 3pm?",
322+
confirmationRequired: true,
323+
targetEntityId: null,
324+
mutationInputSource: null
325+
}
326+
}
327+
]
328+
});
329+
330+
expect(result.interpretation.turnType).toBe("confirmation");
331+
expect(result.policy.action).toBe("recover_and_execute");
332+
});
333+
334+
it("does not reclassify compound confirmation without active proposal context", async () => {
335+
mockClassification({
336+
turnType: "confirmation",
337+
confidence: 0.9
338+
});
339+
340+
const result = await routeMessageTurn({
341+
rawText: "ok but 5pm",
342+
normalizedText: "ok but 5pm",
343+
recentTurns: [],
344+
entityRegistry: []
345+
});
346+
347+
expect(result.interpretation.turnType).toBe("confirmation");
348+
});
349+
});
350+
351+
describe("containsModificationPayload", () => {
352+
it("detects time patterns", () => {
353+
expect(containsModificationPayload("ok but 5pm")).toBe(true);
354+
expect(containsModificationPayload("sure, at 3")).toBe(true);
355+
expect(containsModificationPayload("yes 17:00")).toBe(true);
356+
});
357+
358+
it("detects day patterns", () => {
359+
expect(containsModificationPayload("ok but tomorrow")).toBe(true);
360+
expect(containsModificationPayload("yes friday")).toBe(true);
361+
});
362+
363+
it("detects modification signals", () => {
364+
expect(containsModificationPayload("ok but different")).toBe(true);
365+
expect(containsModificationPayload("yes change it")).toBe(true);
366+
expect(containsModificationPayload("sure, actually")).toBe(true);
367+
});
368+
369+
it("does not match pure confirmations", () => {
370+
expect(containsModificationPayload("ok")).toBe(false);
371+
expect(containsModificationPayload("yes")).toBe(false);
372+
expect(containsModificationPayload("sure")).toBe(false);
373+
expect(containsModificationPayload("do it")).toBe(false);
374+
expect(containsModificationPayload("yup")).toBe(false);
375+
});
257376
});

apps/web/src/lib/server/turn-router.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
applyCommitPolicy,
33
createEmptyDiscourseState,
44
routedTurnSchema,
5+
type ConversationEntity,
56
type ConversationTurn,
67
type RoutedTurn,
78
type SlotKey,
@@ -32,12 +33,31 @@ export async function routeMessageTurn(input: TurnRouterInput): Promise<TurnRout
3233
const entityRegistry = input.entityRegistry ?? [];
3334

3435
// Pipeline A: classify intent
35-
const classification = await classifyTurn({
36+
let classification = await classifyTurn({
3637
normalizedText: input.normalizedText,
3738
discourseState,
3839
entityRegistry
3940
});
4041

42+
// Guard: reclassify compound confirmations (confirmation + modification payload)
43+
// so slot extraction runs and the edit is not silently dropped.
44+
// Scoped to active write/proposal context only.
45+
if (classification.turnType === "confirmation") {
46+
const hasActiveProposal = entityRegistry.some(
47+
(e: ConversationEntity) =>
48+
e.kind === "proposal_option" &&
49+
(e.status === "active" || e.status === "presented")
50+
);
51+
52+
if (hasActiveProposal && containsModificationPayload(input.normalizedText)) {
53+
classification = {
54+
...classification,
55+
turnType: "clarification_answer",
56+
resolvedProposalId: undefined
57+
};
58+
}
59+
}
60+
4161
// Pipeline B: extract slots (conditional)
4262
const priorContract = discourseState.pending_write_contract;
4363
const activeContract = resolveWriteContract({
@@ -163,3 +183,18 @@ export function getConversationRouteForPolicy(action: TurnPolicyAction): Extract
163183
> {
164184
return action === "reply_only" ? "conversation" : "conversation_then_mutation";
165185
}
186+
187+
export function containsModificationPayload(text: string): boolean {
188+
const lower = text.toLowerCase();
189+
// Time patterns: "5pm", "17:00", "at 3"
190+
if (/\d{1,2}(:\d{2})?\s*(am|pm|a\.m\.|p\.m\.)/i.test(lower)) return true;
191+
if (/\d{1,2}:\d{2}/.test(lower)) return true;
192+
if (/\bat\s+\d{1,2}\b/.test(lower)) return true;
193+
// Day patterns: "tomorrow", "friday", "next week"
194+
if (/\b(tomorrow|today|tonight|monday|tuesday|wednesday|thursday|friday|saturday|sunday|next\s+\w+)\b/.test(lower)) return true;
195+
// Duration: "for 1 hour", "30 minutes"
196+
if (/\b\d+\s*(hour|minute|min|hr)s?\b/.test(lower)) return true;
197+
// Modification signals: "but", "change", "make it", "instead", "actually"
198+
if (/\b(but|change|make it|instead|switch|rather|actually|different)\b/.test(lower)) return true;
199+
return false;
200+
}

packages/integrations/src/prompts/turn-classifier.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const turnClassifierSystemPrompt = buildPromptSpec([
3636
"Bare noun phrases without question marks or write verbs in planning context are likely planning_request (task capture).",
3737
"Questions starting with what/when/where/why/how/who/which are typically informational.",
3838
"When uncertain between planning_request and informational, consider whether the text implies action or just discussion.",
39+
"If the user confirms a proposal but also provides new or changed scheduling details (time, day, duration, target), classify as clarification_answer, not confirmation. Examples: 'ok but make it 5pm', 'yes but on friday', 'sounds good, change the time to 3'. Pure confirmation without modifications ('ok', 'yes', 'do it') remains confirmation.",
3940
"Set confidence between 0 and 1. Use higher confidence (>0.85) when context strongly supports the classification. Use lower confidence (<0.7) when the classification is uncertain."
4041
]
4142
},

0 commit comments

Comments
 (0)