Skip to content

Commit f3c94d4

Browse files
authored
Merge pull request #63 from MaxLinCode/codex/canonical-time-spec
Canonicalize time format with TimeSpec discriminated union
2 parents d70ec43 + 3a09b40 commit f3c94d4

17 files changed

Lines changed: 7181 additions & 6360 deletions

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

Lines changed: 2191 additions & 2095 deletions
Large diffs are not rendered by default.

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

Lines changed: 547 additions & 526 deletions
Large diffs are not rendered by default.

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

Lines changed: 557 additions & 486 deletions
Large diffs are not rendered by default.
Lines changed: 118 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,131 +1,134 @@
1+
import type { RawSlotExtraction, SlotExtractorInput, TimeSpec } from "@atlas/core";
12
import { describe, expect, it, vi } from "vitest";
23

3-
import type { RawSlotExtraction, SlotExtractorInput } from "@atlas/core";
4+
function t(hour: number, minute: number): TimeSpec {
5+
return { kind: "absolute", hour, minute };
6+
}
47

58
import { extractSlots } from "./slot-extractor";
69

710
function mockClient(output: Partial<RawSlotExtraction>) {
8-
const full: RawSlotExtraction = {
9-
time: null,
10-
day: null,
11-
duration: null,
12-
target: null,
13-
confidence: {},
14-
unresolvable: [],
15-
...output
16-
};
17-
return {
18-
responses: {
19-
parse: vi.fn().mockResolvedValue({ output_parsed: full })
20-
}
21-
};
11+
const full: RawSlotExtraction = {
12+
time: null,
13+
day: null,
14+
duration: null,
15+
target: null,
16+
confidence: {},
17+
unresolvable: [],
18+
...output,
19+
};
20+
return {
21+
responses: {
22+
parse: vi.fn().mockResolvedValue({ output_parsed: full }),
23+
},
24+
};
2225
}
2326

2427
function failingClient() {
25-
return {
26-
responses: {
27-
parse: vi.fn().mockRejectedValue(new Error("LLM unavailable"))
28-
}
29-
};
28+
return {
29+
responses: {
30+
parse: vi.fn().mockRejectedValue(new Error("LLM unavailable")),
31+
},
32+
};
3033
}
3134

3235
const baseInput: SlotExtractorInput = {
33-
currentTurnText: "5pm",
34-
pendingSlots: ["time"],
35-
priorResolvedSlots: {}
36+
currentTurnText: "5pm",
37+
pendingSlots: ["time"],
38+
priorResolvedSlots: {},
3639
};
3740

3841
describe("extractSlots", () => {
39-
it("normalizes a successful time extraction", async () => {
40-
const client = mockClient({
41-
time: { hour: 17, minute: 0 },
42-
confidence: { time: 0.9 },
43-
unresolvable: []
44-
});
45-
46-
const result = await extractSlots(baseInput, client);
47-
48-
expect(result.extractedValues).toEqual({ time: "17:00" });
49-
expect(result.confidence).toEqual({ time: 0.9 });
50-
expect(result.unresolvable).toEqual([]);
51-
});
52-
53-
it("returns unresolvable for vague inputs", async () => {
54-
const client = mockClient({
55-
confidence: {},
56-
unresolvable: ["time"]
57-
});
58-
59-
const result = await extractSlots(baseInput, client);
60-
61-
expect(result.extractedValues).toEqual({});
62-
expect(result.unresolvable).toEqual(["time"]);
63-
});
64-
65-
it("handles multiple slots", async () => {
66-
const client = mockClient({
67-
time: { hour: 14, minute: 30 },
68-
day: { kind: "relative", value: "Tomorrow" },
69-
confidence: { time: 0.95, day: 0.92 },
70-
unresolvable: []
71-
});
72-
73-
const input: SlotExtractorInput = {
74-
currentTurnText: "tomorrow at 2:30pm",
75-
pendingSlots: ["time", "day"],
76-
priorResolvedSlots: {}
77-
};
78-
79-
const result = await extractSlots(input, client);
80-
81-
expect(result.extractedValues).toEqual({ time: "14:30", day: "tomorrow" });
82-
});
83-
84-
it("degrades gracefully on LLM failure", async () => {
85-
const client = failingClient();
86-
87-
const result = await extractSlots(baseInput, client);
88-
89-
expect(result.extractedValues).toEqual({});
90-
expect(result.unresolvable).toEqual(["time"]);
91-
});
92-
93-
it("degrades gracefully on malformed LLM response", async () => {
94-
const client = {
95-
responses: {
96-
parse: vi.fn().mockResolvedValue({ output_parsed: { invalid: true } })
97-
}
98-
};
99-
100-
const input: SlotExtractorInput = {
101-
currentTurnText: "5pm",
102-
pendingSlots: ["time", "day"],
103-
priorResolvedSlots: {}
104-
};
105-
106-
const result = await extractSlots(input, client);
107-
108-
expect(result.extractedValues).toEqual({});
109-
expect(result.unresolvable).toEqual(["time", "day"]);
110-
});
111-
112-
it("passes conversation context to the client", async () => {
113-
const client = mockClient({
114-
time: { hour: 9, minute: 30 },
115-
confidence: { time: 0.88 },
116-
unresolvable: []
117-
});
118-
119-
const input: SlotExtractorInput = {
120-
currentTurnText: "after the standup",
121-
pendingSlots: ["time"],
122-
priorResolvedSlots: { day: "tomorrow" },
123-
conversationContext: "standup is at 9:30am"
124-
};
125-
126-
const result = await extractSlots(input, client);
127-
128-
expect(result.extractedValues).toEqual({ time: "09:30" });
129-
expect(client.responses.parse).toHaveBeenCalledOnce();
130-
});
42+
it("normalizes a successful time extraction", async () => {
43+
const client = mockClient({
44+
time: { kind: "absolute", hour: 17, minute: 0 },
45+
confidence: { time: 0.9 },
46+
unresolvable: [],
47+
});
48+
49+
const result = await extractSlots(baseInput, client);
50+
51+
expect(result.extractedValues).toEqual({ time: t(17, 0) });
52+
expect(result.confidence).toEqual({ time: 0.9 });
53+
expect(result.unresolvable).toEqual([]);
54+
});
55+
56+
it("returns unresolvable for vague inputs", async () => {
57+
const client = mockClient({
58+
confidence: {},
59+
unresolvable: ["time"],
60+
});
61+
62+
const result = await extractSlots(baseInput, client);
63+
64+
expect(result.extractedValues).toEqual({});
65+
expect(result.unresolvable).toEqual(["time"]);
66+
});
67+
68+
it("handles multiple slots", async () => {
69+
const client = mockClient({
70+
time: { kind: "absolute", hour: 14, minute: 30 },
71+
day: { kind: "relative", value: "Tomorrow" },
72+
confidence: { time: 0.95, day: 0.92 },
73+
unresolvable: [],
74+
});
75+
76+
const input: SlotExtractorInput = {
77+
currentTurnText: "tomorrow at 2:30pm",
78+
pendingSlots: ["time", "day"],
79+
priorResolvedSlots: {},
80+
};
81+
82+
const result = await extractSlots(input, client);
83+
84+
expect(result.extractedValues).toEqual({ time: t(14, 30), day: "tomorrow" });
85+
});
86+
87+
it("degrades gracefully on LLM failure", async () => {
88+
const client = failingClient();
89+
90+
const result = await extractSlots(baseInput, client);
91+
92+
expect(result.extractedValues).toEqual({});
93+
expect(result.unresolvable).toEqual(["time"]);
94+
});
95+
96+
it("degrades gracefully on malformed LLM response", async () => {
97+
const client = {
98+
responses: {
99+
parse: vi.fn().mockResolvedValue({ output_parsed: { invalid: true } }),
100+
},
101+
};
102+
103+
const input: SlotExtractorInput = {
104+
currentTurnText: "5pm",
105+
pendingSlots: ["time", "day"],
106+
priorResolvedSlots: {},
107+
};
108+
109+
const result = await extractSlots(input, client);
110+
111+
expect(result.extractedValues).toEqual({});
112+
expect(result.unresolvable).toEqual(["time", "day"]);
113+
});
114+
115+
it("passes conversation context to the client", async () => {
116+
const client = mockClient({
117+
time: { kind: "absolute", hour: 9, minute: 30 },
118+
confidence: { time: 0.88 },
119+
unresolvable: [],
120+
});
121+
122+
const input: SlotExtractorInput = {
123+
currentTurnText: "after the standup",
124+
pendingSlots: ["time"],
125+
priorResolvedSlots: { day: "tomorrow" },
126+
conversationContext: "standup is at 9:30am",
127+
};
128+
129+
const result = await extractSlots(input, client);
130+
131+
expect(result.extractedValues).toEqual({ time: t(9, 30) });
132+
expect(client.responses.parse).toHaveBeenCalledOnce();
133+
});
131134
});

0 commit comments

Comments
 (0)