Skip to content

Commit 100ed60

Browse files
committed
feat: Enhance complex object and JSON extraction, add validation tests, and improve error messages with LLM output.
1 parent b50783a commit 100ed60

File tree

4 files changed

+248
-3
lines changed

4 files changed

+248
-3
lines changed
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { AxGen } from './generate.js';
3+
import { AxSignature, f } from './sig.js';
4+
5+
describe('AxGen setExamples with complex signatures', () => {
6+
it('should validate examples with complex object signatures', () => {
7+
const sig = new AxSignature({
8+
inputs: [
9+
{
10+
name: 'queryInput',
11+
type: { name: 'string' },
12+
},
13+
],
14+
outputs: [
15+
{
16+
name: 'userProfile',
17+
type: {
18+
name: 'object',
19+
fields: {
20+
nestedString: { type: 'string' },
21+
nestedNumber: { type: 'number' },
22+
nestedObject: {
23+
type: 'object',
24+
fields: {
25+
deepString: { type: 'string' },
26+
},
27+
},
28+
},
29+
},
30+
},
31+
],
32+
});
33+
34+
const gen = new AxGen(sig);
35+
36+
const examples = [
37+
{
38+
queryInput: 'test input',
39+
userProfile: {
40+
nestedString: 'hello',
41+
nestedNumber: 123,
42+
nestedObject: {
43+
deepString: 'deep',
44+
},
45+
},
46+
},
47+
];
48+
49+
// This should not throw
50+
expect(() => gen.setExamples(examples)).not.toThrow();
51+
});
52+
53+
it('should validate examples with array of complex objects', () => {
54+
const sig = new AxSignature({
55+
inputs: [
56+
{
57+
name: 'queryInput',
58+
type: { name: 'string' },
59+
},
60+
],
61+
outputs: [
62+
{
63+
name: 'itemList',
64+
type: {
65+
name: 'object',
66+
isArray: true,
67+
fields: {
68+
id: { type: 'number' },
69+
name: { type: 'string' },
70+
},
71+
},
72+
},
73+
],
74+
});
75+
76+
const gen = new AxGen(sig);
77+
78+
const examples = [
79+
{
80+
queryInput: 'list input',
81+
itemList: [
82+
{ id: 1, name: 'Item 1' },
83+
{ id: 2, name: 'Item 2' },
84+
],
85+
},
86+
];
87+
88+
// This should not throw
89+
expect(() => gen.setExamples(examples)).not.toThrow();
90+
});
91+
92+
it('should validate examples with fluent API signature', () => {
93+
const sig = f()
94+
.input('searchQuery', f.string())
95+
.output(
96+
'searchResult',
97+
f.object({
98+
title: f.string(),
99+
meta: f.object({
100+
score: f.number(),
101+
tags: f.string().array(),
102+
}),
103+
})
104+
)
105+
.build();
106+
107+
const gen = new AxGen(sig);
108+
109+
const examples = [
110+
{
111+
searchQuery: 'search term',
112+
searchResult: {
113+
title: 'Result Title',
114+
meta: {
115+
score: 0.95,
116+
tags: ['tag1', 'tag2'],
117+
},
118+
},
119+
},
120+
];
121+
122+
// This should not throw
123+
expect(() => gen.setExamples(examples)).not.toThrow();
124+
});
125+
126+
it('should validate examples with user provided complex scenario', () => {
127+
const signature = f()
128+
.input(
129+
'freeTimeRanges',
130+
f
131+
.json(
132+
'List of available free time ranges with human-readable datetime strings, ISO strings, and timezone. You can pick any part of a time range to select a time slot of the proper duration'
133+
)
134+
.array()
135+
)
136+
.input(
137+
'durationMinutes',
138+
f.number('Required meeting duration in minutes')
139+
)
140+
.input('subject', f.string('Meeting subject').optional())
141+
.input(
142+
'previousRejectionContext',
143+
f
144+
.string(
145+
"Natural language feedback from participants about what times don't work, WITH specific times"
146+
)
147+
.array()
148+
.optional()
149+
)
150+
.input(
151+
'participantCommunications',
152+
f
153+
.json(
154+
'List of participant communications with participantId, from (participant name, email), content (message body), and receivedAt'
155+
)
156+
.array()
157+
.optional()
158+
)
159+
.input(
160+
'userPreferences',
161+
f
162+
.string(
163+
"User's time preferences or scheduling constraints (e.g., 'tomorrow morning', 'Thursday afternoon', 'early next week', or general preferences like 'mornings preferred'). Use this to understand their timing preferences."
164+
)
165+
.optional()
166+
)
167+
.input(
168+
'userPreferenceTimeRange',
169+
f
170+
.json(
171+
"Structured datetime range representing the user's explicit time preference, parsed from natural language using englishToDatetimeRange. Contains precise start/end in the user's timezone."
172+
)
173+
.optional()
174+
)
175+
.output(
176+
'selectedSlots',
177+
f
178+
.object({
179+
startTimeISO: f.string(
180+
"Start time as a complete ISO-8601 Instant string (e.g. '2025-05-20T16:00:00Z'). MUST include date, time, and timezone offset (Z or +HH:MM)."
181+
),
182+
endTimeISO: f.string(
183+
"End time as a complete ISO-8601 Instant string (e.g. '2025-05-20T17:00:00Z'). MUST include date, time, and timezone offset (Z or +HH:MM)."
184+
),
185+
participantIds: f
186+
.string(
187+
'Participant ID that accepted this time slot (if matching)'
188+
)
189+
.array()
190+
.optional(),
191+
})
192+
.array()
193+
)
194+
.build();
195+
196+
const gen = new AxGen(signature);
197+
198+
const examples = [
199+
{
200+
freeTimeRanges: [
201+
{
202+
startTime: 'Monday, June 2, 2025 at 9:00 AM PDT',
203+
endTime: 'Monday, June 2, 2025 at 12:00 PM PDT',
204+
durationMinutes: 180,
205+
startTimeISO: '2025-06-02T16:00:00Z',
206+
endTimeISO: '2025-06-02T19:00:00Z',
207+
},
208+
],
209+
durationMinutes: 60,
210+
subject: 'Team Sync',
211+
selectedSlots: [
212+
{
213+
startTimeISO: '2025-06-02T16:00:00Z',
214+
endTimeISO: '2025-06-02T17:00:00Z',
215+
},
216+
{
217+
startTimeISO: '2025-06-02T17:00:00Z',
218+
endTimeISO: '2025-06-02T18:00:00Z',
219+
},
220+
],
221+
},
222+
];
223+
224+
expect(() => gen.setExamples(examples)).not.toThrow();
225+
});
226+
});

src/ax/dsp/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const toFieldType = (type: Readonly<AxField['type']>) => {
3333
return 'classification class';
3434
case 'code':
3535
return 'code';
36+
case 'object':
37+
return 'object';
3638
default:
3739
return 'string';
3840
}

src/ax/dsp/extract.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ function validateAndParseFieldValue(
615615

616616
let value: unknown | undefined;
617617

618-
if (field.type?.name === 'json') {
618+
if (field.type?.name === 'json' && !field.type?.isArray) {
619619
try {
620620
const text = extractBlock(fieldValue);
621621
value = JSON.parse(text);
@@ -645,7 +645,24 @@ function validateAndParseFieldValue(
645645
if (Array.isArray(value)) {
646646
for (const [index, item] of value.entries()) {
647647
if (item !== undefined) {
648-
const v = typeof item === 'string' ? item.trim() : item;
648+
let v = typeof item === 'string' ? item.trim() : item;
649+
650+
// If we have a string item but expect an object/json, try to parse it as JSON
651+
// This handles the case where the LLM outputs a markdown list of JSON strings
652+
if (
653+
typeof v === 'string' &&
654+
(field.type?.name === 'object' ||
655+
(field.type?.name as string) === 'json')
656+
) {
657+
try {
658+
// Try to extract a JSON block if present, otherwise parse directly
659+
const jsonText = extractBlock(v);
660+
v = JSON.parse(jsonText);
661+
} catch {
662+
// Ignore parsing errors here, let convertValueToType or validation handle it
663+
}
664+
}
665+
649666
value[index] = convertValueToType(field, v, true);
650667
}
651668
}

src/ax/dsp/generate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,7 @@ export class AxGen<IN = any, OUT extends AxGenOut = any>
822822
(err ?? lastError)?.message ??
823823
(err ?? lastError)?.toString() ??
824824
'unknown error'
825-
}`
825+
}\n\nLLM Output:\n${states.map((s) => s.content).join('\n---\n')}`
826826
),
827827
ai,
828828
this.signature

0 commit comments

Comments
 (0)