Skip to content

Commit 3a444b7

Browse files
committed
Add slot extractor and commit
1 parent babb497 commit 3a444b7

10 files changed

Lines changed: 1335 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 643 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import type { RawSlotExtraction, SlotExtractorInput } from "@atlas/core";
4+
5+
import { extractSlots } from "./slot-extractor";
6+
7+
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+
};
22+
}
23+
24+
function failingClient() {
25+
return {
26+
responses: {
27+
parse: vi.fn().mockRejectedValue(new Error("LLM unavailable"))
28+
}
29+
};
30+
}
31+
32+
const baseInput: SlotExtractorInput = {
33+
currentTurnText: "5pm",
34+
pendingSlots: ["time"],
35+
priorResolvedSlots: {}
36+
};
37+
38+
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+
});
131+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
normalizeRawExtraction,
3+
rawSlotExtractionSchema,
4+
type SlotExtractorInput,
5+
type SlotExtractorOutput
6+
} from "@atlas/core";
7+
import { extractSlotsWithResponses, type OpenAIResponsesClient } from "@atlas/integrations";
8+
9+
export async function extractSlots(
10+
input: SlotExtractorInput,
11+
client?: OpenAIResponsesClient
12+
): Promise<SlotExtractorOutput> {
13+
try {
14+
const raw = await extractSlotsWithResponses(input, client);
15+
const parsed = rawSlotExtractionSchema.safeParse(raw);
16+
17+
if (!parsed.success) {
18+
return {
19+
extractedValues: {},
20+
confidence: {},
21+
unresolvable: [...input.pendingSlots]
22+
};
23+
}
24+
25+
return {
26+
extractedValues: normalizeRawExtraction(parsed.data),
27+
confidence: parsed.data.confidence,
28+
unresolvable: parsed.data.unresolvable
29+
};
30+
} catch {
31+
return {
32+
extractedValues: {},
33+
confidence: {},
34+
unresolvable: [...input.pendingSlots]
35+
};
36+
}
37+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { CommitPolicyInput } from "./commit-policy";
4+
import { applyCommitPolicy } from "./commit-policy";
5+
import type { WriteContract } from "./index";
6+
7+
const defaultContract: WriteContract = {
8+
requiredSlots: ["day", "time"],
9+
intentKind: "plan"
10+
};
11+
12+
function buildInput(overrides: Partial<CommitPolicyInput>): CommitPolicyInput {
13+
return {
14+
turnType: "planning_request",
15+
extractedValues: {},
16+
confidence: {},
17+
unresolvable: [],
18+
priorResolvedSlots: {},
19+
activeContract: defaultContract,
20+
...overrides
21+
};
22+
}
23+
24+
describe("applyCommitPolicy", () => {
25+
it("does not commit slots for informational turns", () => {
26+
const result = applyCommitPolicy(buildInput({
27+
turnType: "informational",
28+
extractedValues: { time: "15:00" },
29+
confidence: { time: 0.95 }
30+
}));
31+
32+
expect(result.committedSlots.time).toBeUndefined();
33+
});
34+
35+
it("does not commit slots for confirmation turns", () => {
36+
const result = applyCommitPolicy(buildInput({
37+
turnType: "confirmation",
38+
extractedValues: { time: "15:00" },
39+
confidence: { time: 0.95 }
40+
}));
41+
42+
expect(result.committedSlots.time).toBeUndefined();
43+
});
44+
45+
it("commits slots above confidence threshold for planning_request", () => {
46+
const result = applyCommitPolicy(buildInput({
47+
turnType: "planning_request",
48+
extractedValues: { time: "17:00", day: "tomorrow" },
49+
confidence: { time: 0.9, day: 0.85 }
50+
}));
51+
52+
expect(result.committedSlots.time).toBe("17:00");
53+
expect(result.committedSlots.day).toBe("tomorrow");
54+
expect(result.needsClarification).toEqual([]);
55+
});
56+
57+
it("routes low confidence extraction to needsClarification", () => {
58+
const result = applyCommitPolicy(buildInput({
59+
turnType: "planning_request",
60+
extractedValues: { time: "17:00" },
61+
confidence: { time: 0.6 }
62+
}));
63+
64+
expect(result.committedSlots.time).toBeUndefined();
65+
expect(result.needsClarification).toContain("time");
66+
});
67+
68+
it("does not commit correction below correction threshold", () => {
69+
const result = applyCommitPolicy(buildInput({
70+
turnType: "clarification_answer",
71+
extractedValues: { time: "15:00" },
72+
confidence: { time: 0.8 },
73+
priorResolvedSlots: { time: "14:00" }
74+
}));
75+
76+
expect(result.committedSlots.time).toBe("14:00");
77+
expect(result.needsClarification).toContain("time");
78+
});
79+
80+
it("commits correction at or above correction threshold", () => {
81+
const result = applyCommitPolicy(buildInput({
82+
turnType: "clarification_answer",
83+
extractedValues: { time: "15:00" },
84+
confidence: { time: 0.92 },
85+
priorResolvedSlots: { time: "14:00" }
86+
}));
87+
88+
expect(result.committedSlots.time).toBe("15:00");
89+
expect(result.needsClarification).not.toContain("time");
90+
});
91+
92+
it("routes unresolvable slots to needsClarification", () => {
93+
const result = applyCommitPolicy(buildInput({
94+
turnType: "clarification_answer",
95+
extractedValues: {},
96+
unresolvable: ["time"]
97+
}));
98+
99+
expect(result.committedSlots.time).toBeUndefined();
100+
expect(result.needsClarification).toContain("time");
101+
});
102+
103+
it("resets prior slots on contract change", () => {
104+
const result = applyCommitPolicy(buildInput({
105+
turnType: "planning_request",
106+
extractedValues: { day: "friday" },
107+
confidence: { day: 0.9 },
108+
priorResolvedSlots: { time: "14:00", day: "tomorrow" },
109+
activeContract: { requiredSlots: ["day", "time"], intentKind: "edit" },
110+
priorContract: { requiredSlots: ["day", "time"], intentKind: "plan" }
111+
}));
112+
113+
expect(result.committedSlots.time).toBeUndefined();
114+
expect(result.committedSlots.day).toBe("friday");
115+
});
116+
117+
it("does not reset slots when contract intentKind is unchanged", () => {
118+
const result = applyCommitPolicy(buildInput({
119+
turnType: "planning_request",
120+
extractedValues: { day: "friday" },
121+
confidence: { day: 0.9 },
122+
priorResolvedSlots: { time: "14:00" },
123+
activeContract: { requiredSlots: ["day", "time"], intentKind: "plan" },
124+
priorContract: { requiredSlots: ["day"], intentKind: "plan" }
125+
}));
126+
127+
expect(result.committedSlots.time).toBe("14:00");
128+
expect(result.committedSlots.day).toBe("friday");
129+
});
130+
131+
it("derives missingSlots from post-commit state", () => {
132+
const result = applyCommitPolicy(buildInput({
133+
turnType: "planning_request",
134+
extractedValues: { day: "tomorrow" },
135+
confidence: { day: 0.9 },
136+
activeContract: { requiredSlots: ["day", "time"], intentKind: "plan" }
137+
}));
138+
139+
expect(result.missingSlots).toEqual(["time"]);
140+
expect(result.missingSlots).not.toContain("day");
141+
});
142+
143+
it("reports all required slots as missing when nothing is committed", () => {
144+
const result = applyCommitPolicy(buildInput({
145+
turnType: "planning_request",
146+
extractedValues: {},
147+
activeContract: { requiredSlots: ["day", "time", "target"], intentKind: "plan" }
148+
}));
149+
150+
expect(result.missingSlots).toEqual(["day", "time", "target"]);
151+
});
152+
153+
it("preserves prior resolved slots when new turn adds more", () => {
154+
const result = applyCommitPolicy(buildInput({
155+
turnType: "clarification_answer",
156+
extractedValues: { time: "17:00" },
157+
confidence: { time: 0.9 },
158+
priorResolvedSlots: { day: "tomorrow" }
159+
}));
160+
161+
expect(result.committedSlots.day).toBe("tomorrow");
162+
expect(result.committedSlots.time).toBe("17:00");
163+
});
164+
165+
it("commits slots for edit_request turn type", () => {
166+
const result = applyCommitPolicy(buildInput({
167+
turnType: "edit_request",
168+
extractedValues: { time: "10:00" },
169+
confidence: { time: 0.88 },
170+
activeContract: { requiredSlots: ["time"], intentKind: "edit" }
171+
}));
172+
173+
expect(result.committedSlots.time).toBe("10:00");
174+
expect(result.missingSlots).toEqual([]);
175+
});
176+
177+
it("treats missing confidence as zero", () => {
178+
const result = applyCommitPolicy(buildInput({
179+
turnType: "planning_request",
180+
extractedValues: { time: "17:00" },
181+
confidence: {}
182+
}));
183+
184+
expect(result.committedSlots.time).toBeUndefined();
185+
expect(result.needsClarification).toContain("time");
186+
});
187+
188+
it("handles unresolvable slots not in extractedValues", () => {
189+
const result = applyCommitPolicy(buildInput({
190+
turnType: "clarification_answer",
191+
extractedValues: { day: "friday" },
192+
confidence: { day: 0.9 },
193+
unresolvable: ["time"]
194+
}));
195+
196+
expect(result.committedSlots.day).toBe("friday");
197+
expect(result.needsClarification).toContain("time");
198+
});
199+
});

0 commit comments

Comments
 (0)