Skip to content

Commit b4b9893

Browse files
committed
feat: implement native turn interpretation and policy routing
- Refactor `interpretTurn` to derive meaning solely from message text and conversation state, removing reliance on legacy route semantics. - Update `routeMessageTurn` to utilize the new interpretation and policy structure, ensuring clear scheduling requests execute directly. - Enhance handling of confirmation and clarification scenarios, including proposal-first behavior only for explicit confirmation-required rules. - Introduce new tests to validate the updated routing logic and ensure correct interpretation of various turn types. - Document changes in architecture and decisions regarding the new routing model, emphasizing the importance of conversation state quality.
1 parent 2642f89 commit b4b9893

11 files changed

Lines changed: 755 additions & 409 deletions

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

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,9 @@ describe("telegram webhook route", () => {
491491

492492
it("lets mixed-intent messages fall through to the normal router even with outstanding followups", async () => {
493493
const { store, followUpStore } = await seedOutstandingFollowUpBundle(["Review launch checklist", "Send investor update"]);
494+
const conversationResponder = vi.fn(async () => ({
495+
reply: "What time tomorrow should I schedule the dentist?"
496+
}));
494497

495498
const response = await handleTelegramWebhook(
496499
buildRequest({
@@ -507,14 +510,25 @@ describe("telegram webhook route", () => {
507510
store,
508511
followUpStore,
509512
calendar: getDefaultCalendarAdapter(),
510-
primeProcessingStore: seedInboxItemForProcessingTests
513+
primeProcessingStore: seedInboxItemForProcessingTests,
514+
conversationResponder
511515
}
512516
);
513517

514518
expect(response.status).toBe(200);
515-
expect(routeTurnWithResponsesMock).toHaveBeenCalledTimes(1);
516-
expect((response.body as { processing: { outcome: string } }).processing.outcome).toBe("planned");
519+
expect(response.body).toMatchObject({
520+
processing: {
521+
outcome: "conversation_replied",
522+
reply: "What time tomorrow should I schedule the dentist?"
523+
},
524+
routing: {
525+
policy: {
526+
action: "ask_clarification"
527+
}
528+
}
529+
});
517530
expect(listTasksForTests().filter((task) => task.lifecycleState === "done")).toHaveLength(0);
531+
expect(conversationResponder).toHaveBeenCalledTimes(1);
518532
});
519533

520534
it("also gates /start for unlinked users", async () => {
@@ -806,6 +820,58 @@ describe("telegram webhook route", () => {
806820
expect(editTelegramMessageMock).toHaveBeenCalledTimes(1);
807821
});
808822

823+
it("does not keep clear scheduling requests in discuss-first mode", async () => {
824+
process.env.TELEGRAM_WEBHOOK_SECRET = "test-webhook-secret";
825+
826+
const response = await handleTelegramWebhook(
827+
buildRequest({
828+
update_id: 145,
829+
message: {
830+
message_id: 110,
831+
date: 1_700_000_020,
832+
text: "Schedule gym tomorrow at 6pm for 1 hour",
833+
chat: {
834+
id: 999,
835+
type: "private"
836+
},
837+
from: {
838+
id: 123,
839+
is_bot: false,
840+
first_name: "Max",
841+
last_name: "Lin"
842+
}
843+
}
844+
}),
845+
{
846+
store: getDefaultInboxProcessingStore(),
847+
calendar: getDefaultCalendarAdapter(),
848+
primeProcessingStore: seedInboxItemForProcessingTests
849+
}
850+
);
851+
852+
expect(response.status).toBe(200);
853+
expect(response.body).toMatchObject({
854+
accepted: true,
855+
turnRoute: "mutation",
856+
routing: {
857+
interpretation: {
858+
turnType: "planning_request",
859+
ambiguity: "none"
860+
},
861+
policy: {
862+
action: "execute_mutation"
863+
}
864+
},
865+
processing: {
866+
outcome: "planned"
867+
}
868+
});
869+
expect(sendTelegramMessageMock).toHaveBeenCalledWith({
870+
chatId: "999",
871+
text: "Checking your schedule"
872+
});
873+
});
874+
809875
it("treats confirmed mutation turns as write-capable when recovery finds one concrete proposal", async () => {
810876
process.env.TELEGRAM_WEBHOOK_SECRET = "test-webhook-secret";
811877
await recordOutgoingTelegramMessageIfNew({

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

Lines changed: 65 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ describe("decideTurnPolicy", () => {
2424
});
2525
});
2626

27-
it("asks for clarification when write intent still has missing slots", () => {
27+
it("asks for clarification when a scheduling request is still missing required slots", () => {
2828
expect(
2929
decideTurnPolicy({
3030
interpretation: {
3131
turnType: "planning_request",
32-
confidence: 0.7,
32+
confidence: 0.58,
3333
resolvedEntityIds: [],
34-
ambiguity: "low",
34+
ambiguity: "high",
3535
missingSlots: ["time"]
3636
},
3737
routingContext: {
@@ -46,28 +46,28 @@ describe("decideTurnPolicy", () => {
4646
});
4747
});
4848

49-
it("presents a proposal for medium-confidence write intent", () => {
49+
it("does not force proposal mode for low-confidence writes alone", () => {
5050
expect(
5151
decideTurnPolicy({
5252
interpretation: {
5353
turnType: "planning_request",
5454
confidence: 0.68,
5555
resolvedEntityIds: [],
56-
ambiguity: "low"
56+
ambiguity: "none"
5757
},
5858
routingContext: {
59-
rawText: "Could we move it to tomorrow morning?",
60-
normalizedText: "Could we move it to tomorrow morning?",
59+
rawText: "Schedule gym tomorrow at 6pm",
60+
normalizedText: "Schedule gym tomorrow at 6pm",
6161
recentTurns: []
6262
}
6363
})
6464
).toMatchObject({
65-
action: "present_proposal",
66-
requiresConfirmation: true
65+
action: "execute_mutation",
66+
requiresConfirmation: false
6767
});
6868
});
6969

70-
it("executes direct confident write intent", () => {
70+
it("executes complete scheduling requests with no ambiguity", () => {
7171
expect(
7272
decideTurnPolicy({
7373
interpretation: {
@@ -77,8 +77,8 @@ describe("decideTurnPolicy", () => {
7777
ambiguity: "none"
7878
},
7979
routingContext: {
80-
rawText: "Schedule gym tomorrow evening",
81-
normalizedText: "Schedule gym tomorrow evening",
80+
rawText: "Schedule gym tomorrow at 6pm for 1 hour",
81+
normalizedText: "Schedule gym tomorrow at 6pm for 1 hour",
8282
recentTurns: []
8383
}
8484
})
@@ -101,7 +101,23 @@ describe("decideTurnPolicy", () => {
101101
routingContext: {
102102
rawText: "Yes",
103103
normalizedText: "Yes",
104-
recentTurns: []
104+
recentTurns: [],
105+
entityRegistry: [
106+
{
107+
id: "proposal-1",
108+
conversationId: "conversation-1",
109+
kind: "proposal_option",
110+
label: "Schedule it at 3pm",
111+
status: "active",
112+
createdAt: "2026-03-20T16:00:00.000Z",
113+
updatedAt: "2026-03-20T16:00:00.000Z",
114+
data: {
115+
route: "conversation_then_mutation",
116+
replyText: "Would you like me to schedule it at 3pm?",
117+
confirmationRequired: true
118+
}
119+
}
120+
]
105121
}
106122
})
107123
).toMatchObject({
@@ -110,45 +126,63 @@ describe("decideTurnPolicy", () => {
110126
});
111127
});
112128

113-
it("falls back to clarification when confirmation has no recoverable proposal", () => {
129+
it("asks for clarification on ambiguous write-like turns", () => {
114130
expect(
115131
decideTurnPolicy({
116132
interpretation: {
117-
turnType: "confirmation",
118-
confidence: 0.4,
119-
resolvedEntityIds: [],
120-
ambiguity: "high"
133+
turnType: "edit_request",
134+
confidence: 0.6,
135+
resolvedEntityIds: ["task-1"],
136+
ambiguity: "high",
137+
ambiguityReason: "Underspecified edit."
121138
},
122139
routingContext: {
123-
rawText: "Yes",
124-
normalizedText: "Yes",
140+
rawText: "Move it",
141+
normalizedText: "Move it",
125142
recentTurns: []
126143
}
127144
})
128145
).toMatchObject({
129-
action: "ask_clarification",
130-
clarificationSlots: ["proposal"]
146+
action: "ask_clarification"
131147
});
132148
});
133149

134-
it("asks for clarification on high-ambiguity write-like turns", () => {
150+
it("uses present_proposal only for explicit confirmation-required policy", () => {
135151
expect(
136152
decideTurnPolicy({
137153
interpretation: {
138-
turnType: "edit_request",
139-
confidence: 0.6,
154+
turnType: "planning_request",
155+
confidence: 0.93,
140156
resolvedEntityIds: ["task-1"],
141-
ambiguity: "high",
142-
ambiguityReason: "Underspecified edit."
157+
resolvedProposalId: "proposal-1",
158+
ambiguity: "none"
143159
},
144160
routingContext: {
145-
rawText: "Move it",
146-
normalizedText: "Move it",
147-
recentTurns: []
161+
rawText: "Schedule it tomorrow at 6pm",
162+
normalizedText: "Schedule it tomorrow at 6pm",
163+
recentTurns: [],
164+
entityRegistry: [
165+
{
166+
id: "proposal-1",
167+
conversationId: "conversation-1",
168+
kind: "proposal_option",
169+
label: "Schedule it tomorrow at 6pm",
170+
status: "active",
171+
createdAt: "2026-03-20T16:00:00.000Z",
172+
updatedAt: "2026-03-20T16:00:00.000Z",
173+
data: {
174+
route: "conversation_then_mutation",
175+
replyText: "Would you like me to schedule it tomorrow at 6pm?",
176+
confirmationRequired: true,
177+
targetEntityId: "task-1"
178+
}
179+
}
180+
]
148181
}
149182
})
150183
).toMatchObject({
151-
action: "ask_clarification"
184+
action: "present_proposal",
185+
requiresConfirmation: true
152186
});
153187
});
154188
});

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

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export function decideTurnPolicy(input: DecideTurnPolicyInput): TurnPolicyDecisi
1818
activeClarifications.filter((clarification) => clarification.blocking).map((clarification) => clarification.slot)
1919
);
2020
const targetEntityId = input.interpretation.resolvedEntityIds[0];
21+
const confirmationRequired = doesTurnRequireProposal(input);
2122

2223
switch (input.interpretation.turnType) {
2324
case "informational":
@@ -61,7 +62,11 @@ export function decideTurnPolicy(input: DecideTurnPolicyInput): TurnPolicyDecisi
6162
clarificationSlots: ["proposal"]
6263
};
6364
case "clarification_answer":
64-
if (input.interpretation.ambiguity === "high") {
65+
if (
66+
input.interpretation.ambiguity === "high" ||
67+
(input.interpretation.missingSlots?.length ?? 0) > 0 ||
68+
blockingSlots.length > 0
69+
) {
6570
return {
6671
action: "ask_clarification",
6772
reason: "Clarification answer is still too ambiguous to safely continue.",
@@ -72,15 +77,15 @@ export function decideTurnPolicy(input: DecideTurnPolicyInput): TurnPolicyDecisi
7277
};
7378
}
7479

75-
if (input.interpretation.resolvedProposalId) {
80+
if (confirmationRequired) {
7681
return {
7782
action: "present_proposal",
78-
reason: "Clarification filled a pending proposal context but confirmation is still preferred.",
83+
reason: "Clarification resolved the missing details, but explicit product policy still requires proposal-first confirmation.",
7984
requiresWrite: false,
8085
requiresConfirmation: true,
8186
useMutationPipeline: false,
8287
...(targetEntityId ? { targetEntityId } : {}),
83-
targetProposalId: input.interpretation.resolvedProposalId,
88+
...(input.interpretation.resolvedProposalId ? { targetProposalId: input.interpretation.resolvedProposalId } : {}),
8489
clarificationSlots: input.interpretation.missingSlots
8590
};
8691
}
@@ -120,10 +125,23 @@ export function decideTurnPolicy(input: DecideTurnPolicyInput): TurnPolicyDecisi
120125
};
121126
}
122127

123-
if (input.interpretation.confidence >= 0.85 && input.interpretation.ambiguity === "none") {
128+
if (confirmationRequired) {
129+
return {
130+
action: "present_proposal",
131+
reason: "Write request is ready, but explicit product policy requires proposal-first confirmation.",
132+
requiresWrite: false,
133+
requiresConfirmation: true,
134+
useMutationPipeline: false,
135+
...(targetEntityId ? { targetEntityId } : {}),
136+
...(input.interpretation.resolvedProposalId ? { targetProposalId: input.interpretation.resolvedProposalId } : {}),
137+
clarificationSlots: input.interpretation.missingSlots
138+
};
139+
}
140+
141+
if (input.interpretation.ambiguity === "none") {
124142
return {
125143
action: "execute_mutation",
126-
reason: "Direct confident write intent can go straight to the mutation pipeline.",
144+
reason: "Write-ready request can go straight to the mutation pipeline.",
127145
requiresWrite: true,
128146
requiresConfirmation: false,
129147
useMutationPipeline: true,
@@ -133,26 +151,53 @@ export function decideTurnPolicy(input: DecideTurnPolicyInput): TurnPolicyDecisi
133151
}
134152

135153
return {
136-
action: "present_proposal",
137-
reason: "Write intent is present but should be proposed before applying it.",
154+
action: "ask_clarification",
155+
reason: "Write intent is present but still needs clarification before applying it.",
138156
requiresWrite: false,
139-
requiresConfirmation: true,
157+
requiresConfirmation: false,
140158
useMutationPipeline: false,
141159
...(targetEntityId ? { targetEntityId } : {}),
142-
clarificationSlots: input.interpretation.missingSlots
160+
clarificationSlots: input.interpretation.missingSlots ?? blockingSlots
143161
};
144162
case "unknown":
145163
return {
146-
action: "ask_clarification",
147-
reason: "Unknown turn meaning should prompt a clarification instead of guessing.",
164+
action:
165+
input.interpretation.ambiguity === "none" && !containsWriteVerb(input.routingContext.normalizedText)
166+
? "reply_only"
167+
: "ask_clarification",
168+
reason:
169+
input.interpretation.ambiguity === "none" && !containsWriteVerb(input.routingContext.normalizedText)
170+
? "Unknown non-write text should stay in the conversation responder."
171+
: "Unknown turn meaning should prompt a clarification instead of guessing.",
148172
requiresWrite: false,
149173
requiresConfirmation: false,
150174
useMutationPipeline: false,
151-
clarificationSlots: input.interpretation.missingSlots ?? blockingSlots
175+
clarificationSlots:
176+
input.interpretation.ambiguity === "none" && !containsWriteVerb(input.routingContext.normalizedText)
177+
? undefined
178+
: input.interpretation.missingSlots ?? blockingSlots
152179
};
153180
}
154181
}
155182

156183
function unique(values: string[]) {
157184
return Array.from(new Set(values));
158185
}
186+
187+
function doesTurnRequireProposal(input: DecideTurnPolicyInput) {
188+
const activeProposal = (input.routingContext.entityRegistry ?? []).find(
189+
(entity) =>
190+
entity.kind === "proposal_option" &&
191+
entity.status === "active" &&
192+
entity.id === input.interpretation.resolvedProposalId &&
193+
entity.data.confirmationRequired === true
194+
);
195+
196+
return Boolean(activeProposal);
197+
}
198+
199+
function containsWriteVerb(text: string) {
200+
return /\b(schedule|plan|move|reschedule|shift|create|add|book|put|mark|complete|archive|cancel|delete|change|update)\b/i.test(
201+
text
202+
);
203+
}

0 commit comments

Comments
 (0)