Skip to content

Commit dd76f5d

Browse files
authored
Merge pull request #54 from MaxLinCode/codex/turn-routing-review-fixes
fix: turn routing review fixes
2 parents 71b5ef5 + 6f7df73 commit dd76f5d

13 files changed

Lines changed: 320 additions & 727 deletions

File tree

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

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ const {
3131
classifyTurnWithResponsesMock,
3232
summarizeConversationMemoryWithResponsesMock,
3333
respondToConversationTurnWithResponsesMock,
34-
recoverConfirmedMutationWithResponsesMock
34+
recoverConfirmedMutationWithResponsesMock,
35+
extractSlotsWithResponsesMock
3536
} = vi.hoisted(() => ({
3637
editTelegramMessageMock: vi.fn(),
3738
sendTelegramMessageMock: vi.fn(),
@@ -40,7 +41,8 @@ const {
4041
classifyTurnWithResponsesMock: vi.fn(),
4142
summarizeConversationMemoryWithResponsesMock: vi.fn(),
4243
respondToConversationTurnWithResponsesMock: vi.fn(),
43-
recoverConfirmedMutationWithResponsesMock: vi.fn()
44+
recoverConfirmedMutationWithResponsesMock: vi.fn(),
45+
extractSlotsWithResponsesMock: vi.fn()
4446
}));
4547

4648
vi.mock("@atlas/integrations", async () => {
@@ -84,6 +86,7 @@ vi.mock("@atlas/integrations", async () => {
8486
recoverConfirmedMutationWithResponses: recoverConfirmedMutationWithResponsesMock,
8587
classifyTurnWithResponses: classifyTurnWithResponsesMock,
8688
routeTurnWithResponses: routeTurnWithResponsesMock,
89+
extractSlotsWithResponses: extractSlotsWithResponsesMock,
8790
sendTelegramChatAction: sendTelegramChatActionMock,
8891
sendTelegramMessage: sendTelegramMessageMock,
8992
summarizeConversationMemoryWithResponses: summarizeConversationMemoryWithResponsesMock
@@ -283,6 +286,15 @@ beforeEach(async () => {
283286
summarizeConversationMemoryWithResponsesMock.mockReset();
284287
respondToConversationTurnWithResponsesMock.mockReset();
285288
recoverConfirmedMutationWithResponsesMock.mockReset();
289+
extractSlotsWithResponsesMock.mockReset();
290+
extractSlotsWithResponsesMock.mockResolvedValue({
291+
time: { hour: 9, minute: 0 },
292+
day: { kind: "relative", value: "tomorrow" },
293+
duration: null,
294+
target: null,
295+
confidence: { day: 0.95, time: 0.95 },
296+
unresolvable: []
297+
});
286298
routeTurnWithResponsesMock.mockResolvedValue({
287299
route: "mutation",
288300
reason: "Direct scheduling request."
@@ -715,8 +727,16 @@ describe("telegram webhook route", () => {
715727
expect(listInboxItemsForTests()).toHaveLength(1);
716728
});
717729

718-
it("normalizes a Telegram text message and hands it to app services", async () => {
730+
it("normalizes a Telegram text message and routes to clarification when slots are missing", async () => {
719731
process.env.TELEGRAM_WEBHOOK_SECRET = "test-webhook-secret";
732+
extractSlotsWithResponsesMock.mockResolvedValueOnce({
733+
time: null,
734+
day: null,
735+
duration: null,
736+
target: null,
737+
confidence: {},
738+
unresolvable: []
739+
});
720740

721741
const response = await handleTelegramWebhook(
722742
buildRequest({
@@ -773,42 +793,23 @@ describe("telegram webhook route", () => {
773793
processingStatus: "received",
774794
linkedTaskIds: []
775795
},
776-
processing: {
777-
outcome: "planned"
778-
},
779-
outboundDelivery: {
780-
status: "edited",
781-
attempts: 1
782-
}
783-
});
784-
expect(response.body).toMatchObject({
785-
outboundDelivery: {
786-
idempotencyKey: expect.any(String),
787-
message: {
788-
message_id: 88
796+
routing: {
797+
interpretation: {
798+
turnType: "planning_request",
799+
ambiguity: "high"
800+
},
801+
policy: {
802+
action: "ask_clarification"
789803
}
804+
},
805+
processing: {
806+
outcome: "conversation_replied"
790807
}
791808
});
792809
expect(listIncomingBotEventsForTests()).toHaveLength(1);
793-
expect(listOutgoingBotEventsForTests()).toHaveLength(1);
794810
expect(listInboxItemsForTests()).toHaveLength(1);
795-
expect(listPlannerRunsForTests()).toHaveLength(1);
796-
expect(listTasksForTests()).toHaveLength(1);
797-
expect(listScheduleBlocksForTests()).toHaveLength(1);
798-
expect(sendTelegramChatActionMock).toHaveBeenCalledWith({
799-
chatId: "999",
800-
action: "typing"
801-
});
802-
expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1);
803-
expect(sendTelegramMessageMock).toHaveBeenCalledWith({
804-
chatId: "999",
805-
text: "Checking your schedule"
806-
});
807-
expect(editTelegramMessageMock).toHaveBeenCalledWith({
808-
chatId: "999",
809-
messageId: 88,
810-
text: expect.stringContaining("Scheduled 'Review launch checklist'")
811-
});
811+
expect(listPlannerRunsForTests()).toHaveLength(0);
812+
expect(listTasksForTests()).toHaveLength(0);
812813
});
813814

814815
it("preserves mutation behavior when the turn router explicitly returns mutation", async () => {
@@ -865,6 +866,14 @@ describe("telegram webhook route", () => {
865866

866867
it("does not keep clear scheduling requests in discuss-first mode", async () => {
867868
process.env.TELEGRAM_WEBHOOK_SECRET = "test-webhook-secret";
869+
extractSlotsWithResponsesMock.mockResolvedValueOnce({
870+
time: { hour: 18, minute: 0 },
871+
day: { kind: "relative", value: "tomorrow" },
872+
duration: { minutes: 60 },
873+
target: null,
874+
confidence: { day: 0.95, time: 0.95, duration: 0.9 },
875+
unresolvable: []
876+
});
868877

869878
const response = await handleTelegramWebhook(
870879
buildRequest({

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

Lines changed: 17 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import {
2+
containsWriteVerb,
3+
deriveAmbiguity,
4+
deriveConsentRequirement,
25
type CommitPolicyOutput,
3-
type ConversationEntity,
46
type TurnAmbiguity,
57
type TurnClassifierOutput,
6-
type TurnInterpretationType,
78
type TurnPolicyDecision,
89
type TurnRoutingInput
910
} from "@atlas/core";
@@ -33,7 +34,12 @@ type StructuredWriteReadiness =
3334
export function decideTurnPolicy(input: DecideTurnPolicyInput): TurnPolicyDecision {
3435
const { classification, commitResult } = input;
3536
const targetEntityId = classification.resolvedEntityIds[0];
36-
const ambiguity = deriveAmbiguity(classification, commitResult);
37+
const ambiguity = deriveAmbiguity({
38+
classifierConfidence: classification.confidence,
39+
missingSlots: commitResult.missingSlots,
40+
needsClarification: commitResult.needsClarification,
41+
blockingSlots: []
42+
});
3743

3844
switch (classification.turnType) {
3945
case "informational":
@@ -109,17 +115,6 @@ export function decideTurnPolicy(input: DecideTurnPolicyInput): TurnPolicyDecisi
109115
}
110116
}
111117

112-
function deriveAmbiguity(
113-
classification: TurnClassifierOutput,
114-
commitResult: CommitPolicyOutput
115-
): TurnAmbiguity {
116-
if (classification.confidence < 0.6) return "high";
117-
if (commitResult.missingSlots.length > 0) return "high";
118-
if (commitResult.needsClarification.length > 0) return "high";
119-
if (classification.confidence < 0.8) return "low";
120-
return "none";
121-
}
122-
123118
function deriveStructuredWriteReadiness(
124119
input: DecideTurnPolicyInput,
125120
ambiguity: TurnAmbiguity
@@ -152,8 +147,6 @@ function deriveStructuredWriteReadiness(
152147
};
153148
}
154149

155-
// Bug 4 fix: if this is a clarification answer and the proposal is already confirmed,
156-
// skip re-presenting and go straight to execution
157150
if (classification.turnType === "clarification_answer") {
158151
const entityRegistry = input.routingContext.entityRegistry ?? [];
159152
const alreadyConfirmed = entityRegistry.some(
@@ -171,13 +164,19 @@ function deriveStructuredWriteReadiness(
171164
}
172165
}
173166

174-
const consentRequirement = deriveConsentRequirement(input);
167+
const consentRequirement = deriveConsentRequirement({
168+
classification,
169+
entityRegistry: input.routingContext.entityRegistry ?? [],
170+
normalizedText: input.routingContext.normalizedText
171+
});
175172

176173
if (consentRequirement.required) {
177174
return {
178175
state: "ready_needs_consent",
179176
reason: consentRequirement.reason,
180-
...(consentRequirement.targetProposalId ? { targetProposalId: consentRequirement.targetProposalId } : {})
177+
...(consentRequirement.required && "targetProposalId" in consentRequirement
178+
? { targetProposalId: consentRequirement.targetProposalId }
179+
: {})
181180
};
182181
}
183182

@@ -231,145 +230,3 @@ function buildPolicyFromStructuredReadiness(
231230
};
232231
}
233232
}
234-
235-
function deriveConsentRequirement(input: DecideTurnPolicyInput) {
236-
const { classification } = input;
237-
// Bug 2 fix: match "presented" status in addition to "active"
238-
const activeProposal = (input.routingContext.entityRegistry ?? []).find(
239-
(entity): entity is Extract<ConversationEntity, { kind: "proposal_option" }> =>
240-
entity.kind === "proposal_option" &&
241-
(entity.status === "active" || entity.status === "presented") &&
242-
entity.id === classification.resolvedProposalId &&
243-
entity.data.confirmationRequired === true
244-
);
245-
246-
if (!activeProposal) {
247-
return {
248-
required: false,
249-
reason: "Deterministic product rules do not require additional consent."
250-
};
251-
}
252-
253-
if (!matchesProposalTarget(activeProposal.data.targetEntityId ?? null, classification.resolvedEntityIds)) {
254-
return {
255-
required: false,
256-
reason: "Deterministic product rules do not require additional consent."
257-
};
258-
}
259-
260-
const compatibility = deriveProposalCompatibility(input, activeProposal);
261-
262-
if (!compatibility.compatible) {
263-
return {
264-
required: true,
265-
reason: compatibility.reason
266-
};
267-
}
268-
269-
return {
270-
required: true,
271-
reason: "Write request is ready, but deterministic product policy still requires user consent.",
272-
targetProposalId: activeProposal.id
273-
};
274-
}
275-
276-
function matchesProposalTarget(targetEntityId: string | null, resolvedEntityIds: string[]) {
277-
if (!targetEntityId || resolvedEntityIds.length === 0) {
278-
return true;
279-
}
280-
281-
return resolvedEntityIds.includes(targetEntityId);
282-
}
283-
284-
function deriveProposalCompatibility(
285-
input: DecideTurnPolicyInput,
286-
proposal: Extract<ConversationEntity, { kind: "proposal_option" }>
287-
) {
288-
if (input.classification.turnType === "clarification_answer") {
289-
return {
290-
compatible: true,
291-
reason: "Clarification answers may continue the same consent-required proposal."
292-
};
293-
}
294-
295-
const currentActionKind = deriveActionKind(input.routingContext.normalizedText, input.classification.turnType);
296-
const proposalActionKind = deriveActionKind(
297-
proposal.data.originatingTurnText ?? proposal.data.replyText,
298-
inferProposalTurnType(proposal)
299-
);
300-
301-
if (currentActionKind !== proposalActionKind) {
302-
return {
303-
compatible: false,
304-
reason: "The new turn changes the action type, so it needs fresh consent."
305-
};
306-
}
307-
308-
const currentFingerprint = deriveParameterFingerprint(input.routingContext.normalizedText);
309-
const proposalFingerprint = deriveParameterFingerprint(proposal.data.originatingTurnText ?? proposal.data.replyText);
310-
311-
if (currentFingerprint.explicit && proposalFingerprint.explicit && currentFingerprint.value !== proposalFingerprint.value) {
312-
return {
313-
compatible: false,
314-
reason: "The new turn changes proposal parameters, so it needs fresh consent."
315-
};
316-
}
317-
318-
return {
319-
compatible: true,
320-
reason: "The pending proposal still matches the current turn."
321-
};
322-
}
323-
324-
function inferProposalTurnType(
325-
proposal: Extract<ConversationEntity, { kind: "proposal_option" }>
326-
): TurnInterpretationType {
327-
const source = (proposal.data.originatingTurnText ?? proposal.data.replyText).toLowerCase();
328-
329-
if (/\b(move|reschedule|shift|push|pull|complete|archive|cancel|delete|update|change|mark)\b/.test(source)) {
330-
return "edit_request";
331-
}
332-
333-
return "planning_request";
334-
}
335-
336-
function deriveActionKind(text: string, turnType: TurnInterpretationType) {
337-
if (turnType === "edit_request") {
338-
return "edit";
339-
}
340-
341-
if (turnType === "planning_request") {
342-
return "plan";
343-
}
344-
345-
const lower = text.toLowerCase();
346-
347-
if (/\b(move|reschedule|shift|push|pull|complete|archive|cancel|delete|update|change|mark)\b/.test(lower)) {
348-
return "edit";
349-
}
350-
351-
return "plan";
352-
}
353-
354-
function deriveParameterFingerprint(text: string) {
355-
const lower = text.toLowerCase();
356-
const dayTokens = lower.match(
357-
/\b(today|tonight|tomorrow|tmr|monday|tuesday|wednesday|thursday|friday|saturday|sunday|weekend|next week|next month|morning|afternoon|evening)\b/g
358-
) ?? [];
359-
const timeTokens =
360-
lower.match(/\b\d{1,2}(?::\d{2})?\s?(?:am|pm)?\b|\bnoon\b|\bmidnight\b/g) ?? [];
361-
const durationTokens =
362-
lower.match(/\bfor\s+\d+\s*(?:minutes?|mins?|hours?|hrs?)\b|\b\d+\s*(?:minutes?|mins?|hours?|hrs?)\b/g) ?? [];
363-
const fingerprintParts = [...dayTokens, ...timeTokens, ...durationTokens].map((part) => part.trim()).sort();
364-
365-
return {
366-
explicit: fingerprintParts.length > 0,
367-
value: fingerprintParts.join("|")
368-
};
369-
}
370-
371-
function containsWriteVerb(text: string) {
372-
return /\b(schedule|plan|move|reschedule|shift|create|add|book|put|mark|complete|archive|cancel|delete|change|update)\b/i.test(
373-
text
374-
);
375-
}

0 commit comments

Comments
 (0)