Skip to content

Commit 6059bf4

Browse files
authored
Merge pull request #60 from MaxLinCode/claude/proposal-resolution-slots
Fix proposal resolution use structured slots
2 parents 0f010d6 + 5abd8a7 commit 6059bf4

12 files changed

Lines changed: 149 additions & 117 deletions

apps/web/src/app/api/telegram/webhook/route.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ async function seedProposalEntity(input: {
179179
route: "conversation_then_mutation",
180180
replyText: input.replyText ?? "Shall I schedule that?",
181181
originatingTurnText: input.originatingTurnText,
182-
missingSlots: input.missingSlots ?? []
182+
missingSlots: input.missingSlots ?? [],
183+
slotSnapshot: {}
183184
}
184185
};
185186

apps/web/src/lib/server/conversation-state.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ export function deriveConversationReplyState(input: DeriveConversationReplyState
8888
mutationInputSource: null,
8989
confirmationRequired: true,
9090
originatingTurnText: input.userTurnText,
91-
missingSlots: input.policy.clarificationSlots
91+
missingSlots: input.policy.clarificationSlots,
92+
slotSnapshot: input.policy.committedSlots ?? {}
9293
}
9394
})
9495
);

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ describe("decideTurnPolicy", () => {
129129
data: {
130130
route: "conversation_then_mutation",
131131
replyText: "Would you like me to schedule it at 3pm?",
132-
confirmationRequired: true
132+
confirmationRequired: true,
133+
slotSnapshot: {}
133134
}
134135
}
135136
]
@@ -165,7 +166,8 @@ describe("decideTurnPolicy", () => {
165166
route: "conversation_then_mutation",
166167
replyText: "Would you like me to move it to 3pm?",
167168
confirmationRequired: true,
168-
targetEntityId: "task-1"
169+
targetEntityId: "task-1",
170+
slotSnapshot: {}
169171
}
170172
}
171173
]
@@ -216,7 +218,8 @@ describe("decideTurnPolicy", () => {
216218
route: "conversation_then_mutation",
217219
replyText: "Would you like me to schedule it tomorrow at 6pm?",
218220
confirmationRequired: true,
219-
targetEntityId: "task-1"
221+
targetEntityId: "task-1",
222+
slotSnapshot: {}
220223
}
221224
}
222225
]
@@ -252,7 +255,8 @@ describe("decideTurnPolicy", () => {
252255
route: "conversation_then_mutation",
253256
replyText: "Would you like me to move it to 3:15pm?",
254257
confirmationRequired: true,
255-
targetEntityId: "task-1"
258+
targetEntityId: "task-1",
259+
slotSnapshot: {}
256260
}
257261
}
258262
]
@@ -270,7 +274,7 @@ describe("decideTurnPolicy", () => {
270274
const result = decideTurnPolicy(
271275
input(
272276
{ turnType: "edit_request", confidence: 0.9, resolvedEntityIds: ["task-1"], resolvedProposalId: "proposal-1" },
273-
{},
277+
{ committedSlots: { time: "15:00", day: "tomorrow" } },
274278
{
275279
rawText: "make it 3 instead",
276280
normalizedText: "make it 3 instead",
@@ -289,7 +293,8 @@ describe("decideTurnPolicy", () => {
289293
replyText: "Would you like me to move it to tomorrow 2pm?",
290294
confirmationRequired: true,
291295
targetEntityId: "task-1",
292-
originatingTurnText: "move it to tomorrow 2pm"
296+
originatingTurnText: "move it to tomorrow 2pm",
297+
slotSnapshot: { time: "14:00", day: "tomorrow" }
293298
}
294299
}
295300
]
@@ -327,7 +332,8 @@ describe("decideTurnPolicy", () => {
327332
route: "conversation_then_mutation",
328333
replyText: "Would you like me to move task one to 2pm?",
329334
confirmationRequired: true,
330-
targetEntityId: "task-1"
335+
targetEntityId: "task-1",
336+
slotSnapshot: {}
331337
}
332338
}
333339
]
@@ -364,7 +370,8 @@ describe("decideTurnPolicy", () => {
364370
route: "conversation_then_mutation",
365371
replyText: "Would you like me to schedule it tomorrow at 6pm?",
366372
confirmationRequired: true,
367-
targetEntityId: "task-1"
373+
targetEntityId: "task-1",
374+
slotSnapshot: {}
368375
}
369376
}
370377
]
@@ -402,7 +409,8 @@ describe("decideTurnPolicy", () => {
402409
route: "conversation_then_mutation",
403410
replyText: "Would you like me to schedule it at 5pm?",
404411
confirmationRequired: true,
405-
targetEntityId: "task-1"
412+
targetEntityId: "task-1",
413+
slotSnapshot: {}
406414
}
407415
}
408416
]
@@ -438,7 +446,8 @@ describe("decideTurnPolicy", () => {
438446
route: "conversation_then_mutation",
439447
replyText: "Would you like me to schedule it tomorrow at 3pm?",
440448
confirmationRequired: true,
441-
targetEntityId: "task-1"
449+
targetEntityId: "task-1",
450+
slotSnapshot: { day: "tomorrow", time: "15:00" }
442451
}
443452
}
444453
]
@@ -476,7 +485,8 @@ describe("decideTurnPolicy", () => {
476485
data: {
477486
route: "conversation_then_mutation",
478487
replyText: "Would you like me to schedule it at 3pm?",
479-
confirmationRequired: true
488+
confirmationRequired: true,
489+
slotSnapshot: { time: "15:00" }
480490
}
481491
}
482492
]

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ function deriveStructuredWriteReadiness(
175175
const consentRequirement = deriveConsentRequirement({
176176
classification,
177177
entityRegistry: input.routingContext.entityRegistry ?? [],
178-
normalizedText: input.routingContext.normalizedText
178+
committedSlots: commitResult.committedSlots
179179
});
180180

181181
if (consentRequirement.required) {

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ describe("classifyTurn", () => {
3939
route: "conversation_then_mutation",
4040
replyText: "Would you like me to schedule it at 3pm?",
4141
confirmationRequired: true,
42-
targetEntityId: "task-1"
42+
targetEntityId: "task-1",
43+
slotSnapshot: {}
4344
}
4445
}
4546
]
@@ -69,7 +70,8 @@ describe("classifyTurn", () => {
6970
data: {
7071
route: "conversation_then_mutation",
7172
replyText: "Move it to 3pm?",
72-
confirmationRequired: true
73+
confirmationRequired: true,
74+
slotSnapshot: {}
7375
}
7476
}
7577
]
@@ -101,7 +103,7 @@ describe("classifyTurn", () => {
101103
status: "active",
102104
createdAt: "2026-03-20T16:00:00.000Z",
103105
updatedAt: "2026-03-20T16:00:00.000Z",
104-
data: { route: "conversation_then_mutation", replyText: "A?", confirmationRequired: true }
106+
data: { route: "conversation_then_mutation", replyText: "A?", confirmationRequired: true, slotSnapshot: {} }
105107
},
106108
{
107109
id: "p-2",
@@ -111,7 +113,7 @@ describe("classifyTurn", () => {
111113
status: "active",
112114
createdAt: "2026-03-20T16:00:00.000Z",
113115
updatedAt: "2026-03-20T16:00:00.000Z",
114-
data: { route: "conversation_then_mutation", replyText: "B?", confirmationRequired: true }
116+
data: { route: "conversation_then_mutation", replyText: "B?", confirmationRequired: true, slotSnapshot: {} }
115117
}
116118
]
117119
},
@@ -296,7 +298,8 @@ describe("classifyTurn", () => {
296298
data: {
297299
route: "conversation_then_mutation",
298300
replyText: "Schedule at 3pm?",
299-
confirmationRequired: true
301+
confirmationRequired: true,
302+
slotSnapshot: {}
300303
}
301304
}
302305
]

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ describe("turn router", () => {
9494
confirmationRequired: true,
9595
originatingTurnText: "Schedule dentist reminder tomorrow",
9696
targetEntityId: null,
97-
mutationInputSource: null
97+
mutationInputSource: null,
98+
slotSnapshot: {}
9899
}
99100
}
100101
]
@@ -145,7 +146,8 @@ describe("turn router", () => {
145146
confirmationRequired: true,
146147
originatingTurnText: "Schedule Malaysia trip planning at 5pm",
147148
targetEntityId: null,
148-
mutationInputSource: null
149+
mutationInputSource: null,
150+
slotSnapshot: {}
149151
}
150152
}
151153
]
@@ -285,7 +287,8 @@ describe("turn router", () => {
285287
replyText: "Would you like me to schedule it at 3pm?",
286288
confirmationRequired: true,
287289
targetEntityId: null,
288-
mutationInputSource: null
290+
mutationInputSource: null,
291+
slotSnapshot: {}
289292
}
290293
}
291294
]
@@ -321,7 +324,8 @@ describe("turn router", () => {
321324
replyText: "Would you like me to schedule it at 3pm?",
322325
confirmationRequired: true,
323326
targetEntityId: null,
324-
mutationInputSource: null
327+
mutationInputSource: null,
328+
slotSnapshot: {}
325329
}
326330
}
327331
]

packages/core/src/index.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1116,7 +1116,8 @@ describe("core package", () => {
11161116
updatedAt: "2026-03-20T16:04:00.000Z",
11171117
data: {
11181118
route: "conversation_then_mutation",
1119-
replyText: "It sounds like you want to move the dentist reminder after lunch."
1119+
replyText: "It sounds like you want to move the dentist reminder after lunch.",
1120+
slotSnapshot: {}
11201121
}
11211122
}
11221123
],

packages/core/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,8 @@ export const conversationProposalOptionEntitySchema = conversationEntityBaseSche
715715
mutationInputSource: z.enum(["direct_user_turn", "recovered_proposal"]).nullable().optional(),
716716
confirmationRequired: z.boolean().optional(),
717717
originatingTurnText: z.string().min(1).nullable().optional(),
718-
missingSlots: z.array(z.string().min(1)).optional()
718+
missingSlots: z.array(z.string().min(1)).optional(),
719+
slotSnapshot: resolvedSlotsSchema
719720
})
720721
});
721722

packages/core/src/proposal-rules.test.ts

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { describe, expect, it } from "vitest";
22

33
import { deriveProposalCompatibility } from "./proposal-rules";
44

5-
import type { ConversationEntity } from "./index";
5+
import type { ConversationEntity, ResolvedSlots } from "./index";
66

77
type ProposalOption = Extract<ConversationEntity, { kind: "proposal_option" }>;
88

9-
function makeProposal(overrides: Partial<ProposalOption["data"]> = {}): ProposalOption {
9+
function makeProposal(overrides: Partial<ProposalOption["data"]> & { slotSnapshot: ResolvedSlots }): ProposalOption {
1010
return {
1111
id: "proposal-1",
1212
conversationId: "c-1",
@@ -25,57 +25,110 @@ function makeProposal(overrides: Partial<ProposalOption["data"]> = {}): Proposal
2525
}
2626

2727
describe("deriveProposalCompatibility", () => {
28-
describe("clarification_answer parameter fingerprint check", () => {
29-
it("is compatible when parameters match", () => {
28+
describe("slot-based compatibility", () => {
29+
it("is compatible when committed slots match the snapshot", () => {
3030
const proposal = makeProposal({
31-
replyText: "Would you like me to move it to 3:15pm?"
31+
slotSnapshot: { time: "15:00", day: "friday" }
3232
});
3333

34-
const result = deriveProposalCompatibility("clarification_answer", "3:15pm", proposal);
34+
const result = deriveProposalCompatibility(
35+
"clarification_answer",
36+
{ time: "15:00", day: "friday" },
37+
proposal
38+
);
3539

3640
expect(result.compatible).toBe(true);
3741
});
3842

39-
it("is incompatible when parameters differ", () => {
43+
it("is incompatible when a committed slot differs from the snapshot", () => {
4044
const proposal = makeProposal({
41-
replyText: "Would you like me to schedule it at 3pm?"
45+
slotSnapshot: { time: "15:00" }
4246
});
4347

44-
const result = deriveProposalCompatibility("clarification_answer", "5pm", proposal);
48+
const result = deriveProposalCompatibility(
49+
"clarification_answer",
50+
{ time: "17:00" },
51+
proposal
52+
);
4553

4654
expect(result.compatible).toBe(false);
47-
expect(result.reason).toMatch(/changes proposal parameters/);
55+
expect(result.reason).toMatch(/differs from proposal snapshot/);
4856
});
4957

50-
it("is compatible when clarification has no explicit parameters", () => {
58+
it("is compatible when committed slot is new (not in snapshot)", () => {
5159
const proposal = makeProposal({
52-
replyText: "Would you like me to schedule it at 3pm?"
60+
slotSnapshot: { day: "friday" }
5361
});
5462

55-
const result = deriveProposalCompatibility("clarification_answer", "yes", proposal);
63+
const result = deriveProposalCompatibility(
64+
"clarification_answer",
65+
{ day: "friday", time: "17:00" },
66+
proposal
67+
);
5668

5769
expect(result.compatible).toBe(true);
5870
});
5971

60-
it("is compatible when proposal has no explicit parameters", () => {
72+
it("is compatible when committed slots are empty", () => {
6173
const proposal = makeProposal({
62-
replyText: "Would you like me to schedule it?"
74+
slotSnapshot: { time: "15:00", day: "friday" }
6375
});
6476

65-
const result = deriveProposalCompatibility("clarification_answer", "5pm", proposal);
77+
const result = deriveProposalCompatibility(
78+
"clarification_answer",
79+
{},
80+
proposal
81+
);
6682

6783
expect(result.compatible).toBe(true);
6884
});
6985

70-
it("uses originatingTurnText over replyText when available", () => {
86+
it("detects duration change as incompatible", () => {
7187
const proposal = makeProposal({
72-
replyText: "Would you like me to move it to 3pm?",
73-
originatingTurnText: "move it to 3pm"
88+
slotSnapshot: { duration: 30 }
7489
});
7590

76-
const result = deriveProposalCompatibility("clarification_answer", "5pm", proposal);
91+
const result = deriveProposalCompatibility(
92+
"planning_request",
93+
{ duration: 60 },
94+
proposal
95+
);
7796

7897
expect(result.compatible).toBe(false);
98+
expect(result.reason).toMatch(/duration.*differs/);
99+
});
100+
});
101+
102+
describe("action kind check for non-clarification turns", () => {
103+
it("is incompatible when action kind changes from plan to edit", () => {
104+
const proposal = makeProposal({
105+
originatingTurnText: "schedule a meeting",
106+
slotSnapshot: { time: "15:00" }
107+
});
108+
109+
const result = deriveProposalCompatibility(
110+
"edit_request",
111+
{ time: "15:00" },
112+
proposal
113+
);
114+
115+
expect(result.compatible).toBe(false);
116+
expect(result.reason).toMatch(/action type/);
117+
});
118+
119+
it("skips action kind check for clarification answers", () => {
120+
const proposal = makeProposal({
121+
originatingTurnText: "move the meeting",
122+
slotSnapshot: { time: "15:00" }
123+
});
124+
125+
const result = deriveProposalCompatibility(
126+
"clarification_answer",
127+
{ time: "15:00" },
128+
proposal
129+
);
130+
131+
expect(result.compatible).toBe(true);
79132
});
80133
});
81134
});

0 commit comments

Comments
 (0)