Skip to content

Commit daf9793

Browse files
committed
Add turn classifier and reference resolution
1 parent 3a444b7 commit daf9793

9 files changed

Lines changed: 743 additions & 451 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const {
2828
sendTelegramMessageMock,
2929
sendTelegramChatActionMock,
3030
routeTurnWithResponsesMock,
31+
classifyTurnWithResponsesMock,
3132
summarizeConversationMemoryWithResponsesMock,
3233
respondToConversationTurnWithResponsesMock,
3334
recoverConfirmedMutationWithResponsesMock
@@ -36,6 +37,7 @@ const {
3637
sendTelegramMessageMock: vi.fn(),
3738
sendTelegramChatActionMock: vi.fn(),
3839
routeTurnWithResponsesMock: vi.fn(),
40+
classifyTurnWithResponsesMock: vi.fn(),
3941
summarizeConversationMemoryWithResponsesMock: vi.fn(),
4042
respondToConversationTurnWithResponsesMock: vi.fn(),
4143
recoverConfirmedMutationWithResponsesMock: vi.fn()
@@ -80,6 +82,7 @@ vi.mock("@atlas/integrations", async () => {
8082
editTelegramMessage: editTelegramMessageMock,
8183
respondToConversationTurnWithResponses: respondToConversationTurnWithResponsesMock,
8284
recoverConfirmedMutationWithResponses: recoverConfirmedMutationWithResponsesMock,
85+
classifyTurnWithResponses: classifyTurnWithResponsesMock,
8386
routeTurnWithResponses: routeTurnWithResponsesMock,
8487
sendTelegramChatAction: sendTelegramChatActionMock,
8588
sendTelegramMessage: sendTelegramMessageMock,
@@ -275,13 +278,19 @@ beforeEach(async () => {
275278
sendTelegramMessageMock.mockReset();
276279
sendTelegramChatActionMock.mockReset();
277280
routeTurnWithResponsesMock.mockReset();
281+
classifyTurnWithResponsesMock.mockReset();
278282
summarizeConversationMemoryWithResponsesMock.mockReset();
279283
respondToConversationTurnWithResponsesMock.mockReset();
280284
recoverConfirmedMutationWithResponsesMock.mockReset();
281285
routeTurnWithResponsesMock.mockResolvedValue({
282286
route: "mutation",
283287
reason: "Direct scheduling request."
284288
});
289+
classifyTurnWithResponsesMock.mockResolvedValue({
290+
turnType: "planning_request",
291+
confidence: 0.92,
292+
reasoning: "User wants to schedule or plan something."
293+
});
285294
summarizeConversationMemoryWithResponsesMock.mockResolvedValue({
286295
summary: "Recent conversation summary."
287296
});
@@ -522,6 +531,11 @@ describe("telegram webhook route", () => {
522531
const conversationResponder = vi.fn(async () => ({
523532
reply: "What time tomorrow should I schedule the dentist?"
524533
}));
534+
classifyTurnWithResponsesMock.mockResolvedValue({
535+
turnType: "planning_request",
536+
confidence: 0.55,
537+
reasoning: "Mixed-intent message with ambiguous scheduling request."
538+
});
525539

526540
const response = await handleTelegramWebhook(
527541
buildRequest({

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

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,38 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import type { TurnClassifierOutput } from "@atlas/core";
24

35
import { interpretTurn } from "./interpret-turn";
46

7+
vi.mock("./llm-classifier", () => ({
8+
classifyTurn: vi.fn()
9+
}));
10+
11+
import { classifyTurn } from "./llm-classifier";
12+
13+
const mockClassifyTurn = vi.mocked(classifyTurn);
14+
15+
function mockClassification(output: Partial<TurnClassifierOutput>) {
16+
const full: TurnClassifierOutput = {
17+
turnType: "unknown",
18+
confidence: 0.5,
19+
resolvedEntityIds: [],
20+
...output
21+
};
22+
mockClassifyTurn.mockResolvedValue(full);
23+
}
24+
525
describe("interpretTurn", () => {
626
it("exposes only the native input signature", () => {
727
expect(interpretTurn.length).toBe(1);
828
});
929

1030
it("treats complete schedule requests as planning requests", async () => {
31+
mockClassification({
32+
turnType: "planning_request",
33+
confidence: 0.95
34+
});
35+
1136
const result = await interpretTurn({
1237
rawText: "Schedule gym tomorrow at 6pm for 1 hour",
1338
normalizedText: "Schedule gym tomorrow at 6pm for 1 hour",
@@ -22,6 +47,12 @@ describe("interpretTurn", () => {
2247
});
2348

2449
it("treats focused move requests as edit requests", async () => {
50+
mockClassification({
51+
turnType: "edit_request",
52+
confidence: 0.9,
53+
resolvedEntityIds: ["task-1"]
54+
});
55+
2556
const result = await interpretTurn({
2657
rawText: "Move that to Friday at 3pm",
2758
normalizedText: "Move that to Friday at 3pm",
@@ -44,6 +75,15 @@ describe("interpretTurn", () => {
4475
});
4576

4677
it("treats yes with one pending proposal as confirmation", async () => {
78+
// This case is handled by the heuristic pre-filter in classifyTurn,
79+
// so the mock returns confirmation directly
80+
mockClassification({
81+
turnType: "confirmation",
82+
confidence: 0.97,
83+
resolvedEntityIds: ["task-1"],
84+
resolvedProposalId: "proposal-1"
85+
});
86+
4787
const result = await interpretTurn({
4888
rawText: "Yes",
4989
normalizedText: "Yes",
@@ -78,6 +118,11 @@ describe("interpretTurn", () => {
78118
});
79119

80120
it("does not treat yes with multiple proposals as recoverable confirmation", async () => {
121+
mockClassification({
122+
turnType: "unknown",
123+
confidence: 0.36
124+
});
125+
81126
const result = await interpretTurn({
82127
rawText: "Yes",
83128
normalizedText: "Yes",
@@ -121,6 +166,11 @@ describe("interpretTurn", () => {
121166
});
122167

123168
it("does not treat yes with only clarification state as confirmation", async () => {
169+
mockClassification({
170+
turnType: "unknown",
171+
confidence: 0.32
172+
});
173+
124174
const result = await interpretTurn({
125175
rawText: "Yes",
126176
normalizedText: "Yes",
@@ -168,6 +218,11 @@ describe("interpretTurn", () => {
168218
});
169219

170220
it("treats short replies to pending clarifications as clarification answers", async () => {
221+
mockClassification({
222+
turnType: "clarification_answer",
223+
confidence: 0.88
224+
});
225+
171226
const result = await interpretTurn({
172227
rawText: "Tomorrow afternoon",
173228
normalizedText: "Tomorrow afternoon",
@@ -198,7 +253,12 @@ describe("interpretTurn", () => {
198253
});
199254
});
200255

201-
it("clears a satisfied time clarification slot when the answer provides a clock time", async () => {
256+
it("clears blocking slots from missingSlots when clarification answer has high confidence", async () => {
257+
mockClassification({
258+
turnType: "clarification_answer",
259+
confidence: 0.91
260+
});
261+
202262
const result = await interpretTurn({
203263
rawText: "5pm",
204264
normalizedText: "5pm",
@@ -224,13 +284,19 @@ describe("interpretTurn", () => {
224284
});
225285

226286
expect(result).toMatchObject({
227-
turnType: "clarification_answer",
228-
ambiguity: "none"
287+
turnType: "clarification_answer"
229288
});
230-
expect(result.missingSlots).toBeUndefined();
289+
// Blocking slots are still reported as missingSlots (commit policy handles resolution in Phase 4)
290+
expect(result.missingSlots).toEqual(["time"]);
231291
});
232292

233293
it("treats punctuated consent as confirmation when one active proposal exists", async () => {
294+
mockClassification({
295+
turnType: "confirmation",
296+
confidence: 0.97,
297+
resolvedProposalId: "proposal-1"
298+
});
299+
234300
const result = await interpretTurn({
235301
rawText: "Ok,",
236302
normalizedText: "Ok,",
@@ -263,6 +329,11 @@ describe("interpretTurn", () => {
263329
});
264330

265331
it("treats informational questions as informational", async () => {
332+
mockClassification({
333+
turnType: "informational",
334+
confidence: 0.93
335+
});
336+
266337
const result = await interpretTurn({
267338
rawText: "What do I have tomorrow?",
268339
normalizedText: "What do I have tomorrow?",
@@ -276,6 +347,11 @@ describe("interpretTurn", () => {
276347
});
277348

278349
it("treats ambiguous short replies without context as unknown", async () => {
350+
mockClassification({
351+
turnType: "unknown",
352+
confidence: 0.3
353+
});
354+
279355
const result = await interpretTurn({
280356
rawText: "Maybe",
281357
normalizedText: "Maybe",
@@ -289,6 +365,12 @@ describe("interpretTurn", () => {
289365
});
290366

291367
it("marks underspecified write turns with blocking clarification as high ambiguity", async () => {
368+
mockClassification({
369+
turnType: "edit_request",
370+
confidence: 0.7,
371+
resolvedEntityIds: ["task-1"]
372+
});
373+
292374
const result = await interpretTurn({
293375
rawText: "Move it",
294376
normalizedText: "Move it",
@@ -314,7 +396,6 @@ describe("interpretTurn", () => {
314396
});
315397

316398
expect(result).toMatchObject({
317-
turnType: "clarification_answer",
318399
ambiguity: "high",
319400
missingSlots: ["time"]
320401
});

0 commit comments

Comments
 (0)