Skip to content

Commit 98d18d4

Browse files
authored
feat: add deterministic discourse state (#43)
1 parent 570bc27 commit 98d18d4

8 files changed

Lines changed: 1971 additions & 7 deletions

File tree

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
listOutgoingBotEventsForTests,
1313
recordIncomingTelegramMessageIfNew,
1414
recordOutgoingTelegramMessageIfNew,
15+
resetConversationStateStoreForTests,
1516
resetGoogleCalendarConnectionStoreForTests,
1617
resetInboxProcessingStoreForTests,
1718
resetIncomingTelegramIngressStoreForTests,
@@ -259,6 +260,7 @@ beforeEach(async () => {
259260
}
260261
}));
261262
sendTelegramChatActionMock.mockResolvedValue(undefined);
263+
resetConversationStateStoreForTests();
262264
resetIncomingTelegramIngressStoreForTests();
263265
resetInboxProcessingStoreForTests();
264266
resetGoogleCalendarConnectionStoreForTests();
@@ -880,6 +882,12 @@ describe("telegram webhook route", () => {
880882
expect(confirmedMutationRecoverer).toHaveBeenCalledWith(
881883
expect.objectContaining({
882884
rawText: "Yes",
885+
memorySummary: null,
886+
entityRegistry: [],
887+
discourseState: expect.objectContaining({
888+
focus_entity_id: null,
889+
last_user_mentioned_entity_ids: []
890+
}),
883891
recentTurns: expect.arrayContaining([
884892
expect.objectContaining({
885893
role: "assistant",
@@ -1494,7 +1502,12 @@ describe("telegram webhook route", () => {
14941502
createdAt: expect.any(String)
14951503
}
14961504
],
1497-
memorySummary: "The user wants weekly prioritization help."
1505+
memorySummary: "The user wants weekly prioritization help.",
1506+
entityRegistry: [],
1507+
discourseState: expect.objectContaining({
1508+
focus_entity_id: null,
1509+
last_user_mentioned_entity_ids: []
1510+
})
14981511
});
14991512
expect(sendTelegramMessageMock).toHaveBeenCalledWith({
15001513
chatId: "999",
@@ -1581,7 +1594,12 @@ describe("telegram webhook route", () => {
15811594
createdAt: expect.any(String)
15821595
}
15831596
],
1584-
memorySummary: "The user is discussing a possible move to tomorrow morning."
1597+
memorySummary: "The user is discussing a possible move to tomorrow morning.",
1598+
entityRegistry: [],
1599+
discourseState: expect.objectContaining({
1600+
focus_entity_id: null,
1601+
last_user_mentioned_entity_ids: []
1602+
})
15851603
});
15861604
expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1);
15871605
expect(sendTelegramMessageMock).toHaveBeenCalledWith({
@@ -1648,7 +1666,12 @@ describe("telegram webhook route", () => {
16481666
createdAt: expect.any(String)
16491667
}
16501668
],
1651-
memorySummary: null
1669+
memorySummary: null,
1670+
entityRegistry: [],
1671+
discourseState: expect.objectContaining({
1672+
focus_entity_id: null,
1673+
last_user_mentioned_entity_ids: []
1674+
})
16521675
});
16531676
expect(listPlannerRunsForTests()).toHaveLength(0);
16541677
expect(listTasksForTests()).toHaveLength(0);
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { randomUUID } from "node:crypto";
2+
3+
import {
4+
createEmptyDiscourseState,
5+
updateDiscourseStateFromAssistantTurn,
6+
type ConversationEntity,
7+
type ConversationRecordMode,
8+
type ConversationStateSnapshot,
9+
type PendingClarification,
10+
type PresentedItem,
11+
type ScheduleBlock,
12+
type Task,
13+
type TurnRoute
14+
} from "@atlas/core";
15+
import { type ProcessedInboxResult } from "@atlas/db";
16+
17+
type DeriveConversationReplyStateInput = {
18+
snapshot: ConversationStateSnapshot;
19+
route: Extract<TurnRoute, "conversation" | "conversation_then_mutation">;
20+
reply: string;
21+
summaryText: string | null;
22+
occurredAt?: string;
23+
};
24+
25+
type DeriveMutationStateInput = {
26+
snapshot: ConversationStateSnapshot;
27+
processing: ProcessedInboxResult;
28+
occurredAt?: string;
29+
};
30+
31+
export function deriveConversationReplyState(input: DeriveConversationReplyStateInput) {
32+
const occurredAt = input.occurredAt ?? new Date().toISOString();
33+
const entityRegistry = [...input.snapshot.entityRegistry];
34+
const discourseState = input.snapshot.discourseState ?? createEmptyDiscourseState();
35+
const presentedItems: PresentedItem[] = [];
36+
const newClarifications: PendingClarification[] = [];
37+
let nextFocusEntityId: string | null | undefined;
38+
39+
if (input.route === "conversation_then_mutation") {
40+
const proposalEntity = buildConversationEntity(input.snapshot.conversation.id, {
41+
kind: "proposal_option",
42+
label: summarizeLabel(input.reply),
43+
status: "active",
44+
createdAt: occurredAt,
45+
updatedAt: occurredAt,
46+
data: {
47+
route: input.route,
48+
replyText: input.reply
49+
}
50+
});
51+
52+
entityRegistry.push(proposalEntity);
53+
presentedItems.push({
54+
id: `presented_${proposalEntity.id}`,
55+
type: "entity",
56+
entityId: proposalEntity.id,
57+
label: proposalEntity.label,
58+
ordinal: 1
59+
});
60+
nextFocusEntityId = proposalEntity.id;
61+
}
62+
63+
if (looksLikeClarification(input.reply)) {
64+
const clarificationEntity = buildConversationEntity(input.snapshot.conversation.id, {
65+
kind: "clarification",
66+
label: summarizeLabel(input.reply),
67+
status: "active",
68+
createdAt: occurredAt,
69+
updatedAt: occurredAt,
70+
data: {
71+
prompt: input.reply,
72+
reason: null,
73+
open: true
74+
}
75+
});
76+
77+
entityRegistry.push(clarificationEntity);
78+
newClarifications.push({
79+
id: clarificationEntity.id,
80+
slot: "unknown",
81+
question: input.reply,
82+
status: "pending",
83+
blocking: true,
84+
createdAt: occurredAt,
85+
createdTurnId: `assistant:${occurredAt}`
86+
});
87+
nextFocusEntityId ??= clarificationEntity.id;
88+
}
89+
const nextDiscourseState = updateDiscourseStateFromAssistantTurn(discourseState, {
90+
...(presentedItems.length > 0 ? { presentedItems } : {}),
91+
...(newClarifications.length > 0 ? { newClarifications } : {}),
92+
...(nextFocusEntityId !== undefined ? { focusEntityId: nextFocusEntityId } : {}),
93+
pendingConfirmation: input.route === "conversation_then_mutation",
94+
validEntityIds: entityRegistry.map((entity) => entity.id)
95+
}).state;
96+
97+
return {
98+
summaryText: input.summaryText,
99+
mode: input.route as ConversationRecordMode,
100+
entityRegistry: trimEntityRegistry(entityRegistry),
101+
discourseState: nextDiscourseState
102+
};
103+
}
104+
105+
export function deriveMutationState(input: DeriveMutationStateInput) {
106+
const occurredAt = input.occurredAt ?? new Date().toISOString();
107+
const existingByKey = new Map<string, ConversationEntity>(
108+
input.snapshot.entityRegistry.map((entity) => [entityKey(entity), entity])
109+
);
110+
const entityRegistry: ConversationEntity[] = input.snapshot.entityRegistry.map((entity) => {
111+
if (entity.kind === "proposal_option" || entity.kind === "clarification") {
112+
return {
113+
...entity,
114+
status: "resolved",
115+
updatedAt: occurredAt
116+
};
117+
}
118+
119+
return entity;
120+
});
121+
let lastConcreteEntityId: string | null = null;
122+
123+
for (const task of selectTasks(input.processing)) {
124+
const key = `task:${task.id}`;
125+
const existing = existingByKey.get(key);
126+
const nextEntity = buildConversationEntity(input.snapshot.conversation.id, {
127+
kind: "task",
128+
label: task.title,
129+
status: isTaskResolved(task) ? "resolved" : "active",
130+
createdAt: existing?.createdAt ?? occurredAt,
131+
updatedAt: occurredAt,
132+
...(existing ? { id: existing.id } : {}),
133+
data: {
134+
taskId: task.id,
135+
title: task.title,
136+
lifecycleState: task.lifecycleState,
137+
scheduledStartAt: task.scheduledStartAt,
138+
scheduledEndAt: task.scheduledEndAt
139+
}
140+
});
141+
142+
replaceEntity(entityRegistry, nextEntity, key);
143+
lastConcreteEntityId = nextEntity.id;
144+
}
145+
146+
for (const block of selectScheduleBlocks(input.processing)) {
147+
const taskTitle = findTaskTitleForBlock(block, input.processing);
148+
const key = `scheduled_block:${block.id}`;
149+
const existing = existingByKey.get(key);
150+
const nextEntity = buildConversationEntity(input.snapshot.conversation.id, {
151+
kind: "scheduled_block",
152+
label: `${taskTitle} at ${block.startAt}`,
153+
status: "active",
154+
createdAt: existing?.createdAt ?? occurredAt,
155+
updatedAt: occurredAt,
156+
...(existing ? { id: existing.id } : {}),
157+
data: {
158+
blockId: block.id,
159+
taskId: block.taskId,
160+
title: taskTitle,
161+
startAt: block.startAt,
162+
endAt: block.endAt,
163+
externalCalendarId: block.externalCalendarId ?? null
164+
}
165+
});
166+
167+
replaceEntity(entityRegistry, nextEntity, key);
168+
lastConcreteEntityId = nextEntity.id;
169+
}
170+
171+
const newClarifications: PendingClarification[] = [];
172+
173+
if (input.processing.outcome === "needs_clarification") {
174+
const clarificationEntity = buildConversationEntity(input.snapshot.conversation.id, {
175+
kind: "clarification",
176+
label: summarizeLabel(input.processing.followUpMessage),
177+
status: "active",
178+
createdAt: occurredAt,
179+
updatedAt: occurredAt,
180+
data: {
181+
prompt: input.processing.followUpMessage,
182+
reason: input.processing.reason,
183+
open: true
184+
}
185+
});
186+
187+
entityRegistry.push(clarificationEntity);
188+
newClarifications.push({
189+
id: clarificationEntity.id,
190+
entityId: lastConcreteEntityId ?? undefined,
191+
slot: input.processing.reason,
192+
question: input.processing.followUpMessage,
193+
status: "pending",
194+
blocking: true,
195+
createdAt: occurredAt,
196+
createdTurnId: `assistant:${occurredAt}`
197+
});
198+
}
199+
200+
const discourseState = updateDiscourseStateFromAssistantTurn(
201+
input.snapshot.discourseState ?? createEmptyDiscourseState(),
202+
{
203+
...(newClarifications.length > 0 ? { newClarifications } : {}),
204+
focusEntityId: lastConcreteEntityId,
205+
editableEntityId: lastConcreteEntityId,
206+
pendingConfirmation: false,
207+
validEntityIds: entityRegistry.map((entity) => entity.id)
208+
}
209+
).state;
210+
211+
return {
212+
mode: (input.processing.outcome === "needs_clarification" ? "conversation_then_mutation" : "mutation") as
213+
ConversationRecordMode,
214+
entityRegistry: trimEntityRegistry(entityRegistry),
215+
discourseState
216+
};
217+
}
218+
219+
function selectTasks(processing: ProcessedInboxResult): Task[] {
220+
switch (processing.outcome) {
221+
case "planned":
222+
return processing.createdTasks;
223+
case "scheduled_existing_tasks":
224+
return processing.scheduledTasks;
225+
case "completed_tasks":
226+
return processing.completedTasks;
227+
case "archived_tasks":
228+
return processing.archivedTasks;
229+
default:
230+
return [];
231+
}
232+
}
233+
234+
function selectScheduleBlocks(processing: ProcessedInboxResult): ScheduleBlock[] {
235+
switch (processing.outcome) {
236+
case "planned":
237+
case "scheduled_existing_tasks":
238+
return processing.scheduleBlocks;
239+
case "updated_schedule":
240+
return [processing.updatedBlock];
241+
default:
242+
return [];
243+
}
244+
}
245+
246+
function findTaskTitleForBlock(block: ScheduleBlock, processing: ProcessedInboxResult) {
247+
return (
248+
selectTasks(processing).find((task) => task.id === block.taskId)?.title ??
249+
"Scheduled work"
250+
);
251+
}
252+
253+
function isTaskResolved(task: Task) {
254+
return task.lifecycleState === "done" || task.lifecycleState === "archived";
255+
}
256+
257+
function looksLikeClarification(reply: string) {
258+
return reply.trim().endsWith("?");
259+
}
260+
261+
function summarizeLabel(text: string) {
262+
return text.trim().slice(0, 120);
263+
}
264+
265+
function buildConversationEntity(
266+
conversationId: string,
267+
input: {
268+
id?: string;
269+
kind: ConversationEntity["kind"];
270+
label: string;
271+
status: ConversationEntity["status"];
272+
createdAt: string;
273+
updatedAt: string;
274+
data: ConversationEntity["data"];
275+
}
276+
): ConversationEntity {
277+
return {
278+
...input,
279+
id: input.id ?? randomUUID(),
280+
conversationId
281+
} as ConversationEntity;
282+
}
283+
284+
function entityKey(entity: ConversationEntity) {
285+
switch (entity.kind) {
286+
case "task":
287+
return `task:${entity.data.taskId}`;
288+
case "scheduled_block":
289+
return `scheduled_block:${entity.data.blockId}`;
290+
case "reminder":
291+
return `reminder:${entity.data.taskId}:${entity.data.reminderKind}`;
292+
default:
293+
return `${entity.kind}:${entity.id}`;
294+
}
295+
}
296+
297+
function replaceEntity(entityRegistry: ConversationEntity[], nextEntity: ConversationEntity, key: string) {
298+
const index = entityRegistry.findIndex((entity) => entityKey(entity) === key);
299+
300+
if (index >= 0) {
301+
entityRegistry.splice(index, 1, nextEntity);
302+
return;
303+
}
304+
305+
entityRegistry.push(nextEntity);
306+
}
307+
308+
function trimEntityRegistry(entityRegistry: ConversationEntity[]) {
309+
return entityRegistry
310+
.sort((left, right) => Date.parse(left.updatedAt) - Date.parse(right.updatedAt))
311+
.slice(-20);
312+
}

0 commit comments

Comments
 (0)