Skip to content

Commit 6b23301

Browse files
committed
fix(registry): use generateText instead of generateObject for LLM
OpenAI's structured output requires all properties in 'required' array, which conflicts with JSON Resume's optional fields. Switch to generateText and parse JSON manually to avoid schema validation errors. - Use generateText from Vercel AI SDK - Parse JSON response, handling markdown code blocks - Add tests for JSON parsing edge cases
1 parent 5fc5a1f commit 6b23301

File tree

2 files changed

+56
-172
lines changed

2 files changed

+56
-172
lines changed

apps/registry/lib/generateResume/transformResumeWithLLM.js

Lines changed: 13 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -1,165 +1,7 @@
1-
import { generateObject } from 'ai';
1+
import { generateText } from 'ai';
22
import { openai } from '@ai-sdk/openai';
3-
import { z } from 'zod';
43
import { logger } from '../logger';
54

6-
// JSON Resume schema for structured output
7-
const resumeSchema = z.object({
8-
basics: z
9-
.object({
10-
name: z.string().optional(),
11-
label: z.string().optional(),
12-
image: z.string().optional(),
13-
email: z.string().optional(),
14-
phone: z.string().optional(),
15-
url: z.string().optional(),
16-
summary: z.string().optional(),
17-
location: z
18-
.object({
19-
address: z.string().optional(),
20-
postalCode: z.string().optional(),
21-
city: z.string().optional(),
22-
countryCode: z.string().optional(),
23-
region: z.string().optional(),
24-
})
25-
.optional(),
26-
profiles: z
27-
.array(
28-
z.object({
29-
network: z.string().optional(),
30-
username: z.string().optional(),
31-
url: z.string().optional(),
32-
})
33-
)
34-
.optional(),
35-
})
36-
.optional(),
37-
work: z
38-
.array(
39-
z.object({
40-
name: z.string().optional(),
41-
position: z.string().optional(),
42-
url: z.string().optional(),
43-
startDate: z.string().optional(),
44-
endDate: z.string().optional(),
45-
summary: z.string().optional(),
46-
highlights: z.array(z.string()).optional(),
47-
})
48-
)
49-
.optional(),
50-
volunteer: z
51-
.array(
52-
z.object({
53-
organization: z.string().optional(),
54-
position: z.string().optional(),
55-
url: z.string().optional(),
56-
startDate: z.string().optional(),
57-
endDate: z.string().optional(),
58-
summary: z.string().optional(),
59-
highlights: z.array(z.string()).optional(),
60-
})
61-
)
62-
.optional(),
63-
education: z
64-
.array(
65-
z.object({
66-
institution: z.string().optional(),
67-
url: z.string().optional(),
68-
area: z.string().optional(),
69-
studyType: z.string().optional(),
70-
startDate: z.string().optional(),
71-
endDate: z.string().optional(),
72-
score: z.string().optional(),
73-
courses: z.array(z.string()).optional(),
74-
})
75-
)
76-
.optional(),
77-
awards: z
78-
.array(
79-
z.object({
80-
title: z.string().optional(),
81-
date: z.string().optional(),
82-
awarder: z.string().optional(),
83-
summary: z.string().optional(),
84-
})
85-
)
86-
.optional(),
87-
certificates: z
88-
.array(
89-
z.object({
90-
name: z.string().optional(),
91-
date: z.string().optional(),
92-
issuer: z.string().optional(),
93-
url: z.string().optional(),
94-
})
95-
)
96-
.optional(),
97-
publications: z
98-
.array(
99-
z.object({
100-
name: z.string().optional(),
101-
publisher: z.string().optional(),
102-
releaseDate: z.string().optional(),
103-
url: z.string().optional(),
104-
summary: z.string().optional(),
105-
})
106-
)
107-
.optional(),
108-
skills: z
109-
.array(
110-
z.object({
111-
name: z.string().optional(),
112-
level: z.string().optional(),
113-
keywords: z.array(z.string()).optional(),
114-
})
115-
)
116-
.optional(),
117-
languages: z
118-
.array(
119-
z.object({
120-
language: z.string().optional(),
121-
fluency: z.string().optional(),
122-
})
123-
)
124-
.optional(),
125-
interests: z
126-
.array(
127-
z.object({
128-
name: z.string().optional(),
129-
keywords: z.array(z.string()).optional(),
130-
})
131-
)
132-
.optional(),
133-
references: z
134-
.array(
135-
z.object({
136-
name: z.string().optional(),
137-
reference: z.string().optional(),
138-
})
139-
)
140-
.optional(),
141-
projects: z
142-
.array(
143-
z.object({
144-
name: z.string().optional(),
145-
startDate: z.string().optional(),
146-
endDate: z.string().optional(),
147-
description: z.string().optional(),
148-
highlights: z.array(z.string()).optional(),
149-
url: z.string().optional(),
150-
})
151-
)
152-
.optional(),
153-
meta: z
154-
.object({
155-
canonical: z.string().optional(),
156-
version: z.string().optional(),
157-
lastModified: z.string().optional(),
158-
theme: z.string().optional(),
159-
})
160-
.optional(),
161-
});
162-
1635
const SYSTEM_PROMPT = `You are a professional resume editor. Your task is to modify a JSON Resume based on user instructions.
1646
1657
IMPORTANT RULES:
@@ -198,15 +40,14 @@ export async function transformResumeWithLLM(resume, prompt) {
19840
'Starting LLM resume transformation'
19941
);
20042

201-
const result = await generateObject({
43+
const result = await generateText({
20244
model: openai('gpt-4.1-mini'),
203-
schema: resumeSchema,
20445
system: SYSTEM_PROMPT,
20546
prompt: `Here is the original resume:\n\n${JSON.stringify(
20647
resume,
20748
null,
20849
2
209-
)}\n\nUser request: ${prompt}\n\nPlease modify the resume according to the user's request and return the complete updated resume.`,
50+
)}\n\nUser request: ${prompt}\n\nPlease modify the resume according to the user's request and return the complete updated resume as valid JSON.`,
21051
});
21152

21253
const duration = Date.now() - startTime;
@@ -215,7 +56,16 @@ export async function transformResumeWithLLM(resume, prompt) {
21556
'LLM transformation completed'
21657
);
21758

218-
return result.object;
59+
// Parse the JSON from the response
60+
const text = result.text.trim();
61+
// Extract JSON from markdown code blocks if present
62+
const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/) || [
63+
null,
64+
text,
65+
];
66+
const jsonStr = jsonMatch[1].trim();
67+
68+
return JSON.parse(jsonStr);
21969
} catch (error) {
22070
logger.error(
22171
{ error: error.message, prompt: prompt.substring(0, 100) },

apps/registry/lib/generateResume/transformResumeWithLLM.test.js

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
22

33
// Mock the AI SDK
44
vi.mock('ai', () => ({
5-
generateObject: vi.fn(),
5+
generateText: vi.fn(),
66
}));
77

88
vi.mock('@ai-sdk/openai', () => ({
@@ -51,14 +51,14 @@ describe('transformResumeWithLLM', () => {
5151
process.env.OPENAI_API_KEY = originalKey;
5252
});
5353

54-
it('calls generateObject with correct parameters', async () => {
54+
it('calls generateText and parses JSON response', async () => {
5555
process.env.OPENAI_API_KEY = 'test-key';
5656

57-
const { generateObject } = await import('ai');
58-
generateObject.mockResolvedValue({
59-
object: {
57+
const { generateText } = await import('ai');
58+
generateText.mockResolvedValue({
59+
text: JSON.stringify({
6060
basics: { name: 'Test User', label: 'Frontend Developer' },
61-
},
61+
}),
6262
});
6363

6464
const { transformResumeWithLLM } = await import(
@@ -71,7 +71,7 @@ describe('transformResumeWithLLM', () => {
7171
'promote frontend experience'
7272
);
7373

74-
expect(generateObject).toHaveBeenCalledWith(
74+
expect(generateText).toHaveBeenCalledWith(
7575
expect.objectContaining({
7676
system: expect.stringContaining('professional resume editor'),
7777
prompt: expect.stringContaining('promote frontend experience'),
@@ -81,11 +81,45 @@ describe('transformResumeWithLLM', () => {
8181
expect(result.basics.label).toBe('Frontend Developer');
8282
});
8383

84+
it('handles JSON wrapped in markdown code blocks', async () => {
85+
process.env.OPENAI_API_KEY = 'test-key';
86+
87+
const { generateText } = await import('ai');
88+
generateText.mockResolvedValue({
89+
text: '```json\n{"basics": {"name": "Test", "label": "Engineer"}}\n```',
90+
});
91+
92+
const { transformResumeWithLLM } = await import(
93+
'./transformResumeWithLLM.js'
94+
);
95+
const resume = { basics: { name: 'Test User' } };
96+
97+
const result = await transformResumeWithLLM(resume, 'some prompt');
98+
expect(result.basics.label).toBe('Engineer');
99+
});
100+
84101
it('returns original resume on API error', async () => {
85102
process.env.OPENAI_API_KEY = 'test-key';
86103

87-
const { generateObject } = await import('ai');
88-
generateObject.mockRejectedValue(new Error('API Error'));
104+
const { generateText } = await import('ai');
105+
generateText.mockRejectedValue(new Error('API Error'));
106+
107+
const { transformResumeWithLLM } = await import(
108+
'./transformResumeWithLLM.js'
109+
);
110+
const resume = { basics: { name: 'Test User' } };
111+
112+
const result = await transformResumeWithLLM(resume, 'some prompt');
113+
expect(result).toEqual(resume);
114+
});
115+
116+
it('returns original resume on JSON parse error', async () => {
117+
process.env.OPENAI_API_KEY = 'test-key';
118+
119+
const { generateText } = await import('ai');
120+
generateText.mockResolvedValue({
121+
text: 'not valid json',
122+
});
89123

90124
const { transformResumeWithLLM } = await import(
91125
'./transformResumeWithLLM.js'

0 commit comments

Comments
 (0)