Skip to content

Commit c4f4a89

Browse files
committed
feat(registry): add LLM resume transformation via ?llm= parameter
- Add transformResumeWithLLM function using Vercel AI SDK with gpt-4.1-mini - Integrate into generateResume pipeline after validation - Uses generateObject for structured JSON Resume output - Gracefully falls back to original resume on errors - Add comprehensive test suite for transformation logic Usage: registry.jsonresume.org/username?llm=promote%20my%20frontend%20experience
1 parent 16dde98 commit c4f4a89

File tree

3 files changed

+335
-3
lines changed

3 files changed

+335
-3
lines changed

apps/registry/lib/generateResume.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { validateExtension, validateResume } from './generateResume/validation';
55
import { cacheResume } from './generateResume/cacheResume';
66
import { formatResume } from './generateResume/formatResume';
77
import { getRandomTheme } from './formatters/template/themeConfig';
8+
import { transformResumeWithLLM } from './generateResume/transformResumeWithLLM';
89

910
const generateResume = async (username, extension = 'template', query = {}) => {
10-
const { theme, gistname } = query;
11+
const { theme, gistname, llm } = query;
1112
const formatter = formatters[extension];
1213

1314
const { error: extensionError } = validateExtension(extension);
@@ -27,7 +28,13 @@ const generateResume = async (username, extension = 'template', query = {}) => {
2728
const { error: validationError } = validateResume(resume);
2829
if (validationError) return validationError;
2930

30-
let selectedTheme = theme || resume.meta?.theme || 'elegant';
31+
// Transform resume with LLM if llm parameter is provided
32+
let transformedResume = resume;
33+
if (llm) {
34+
transformedResume = await transformResumeWithLLM(resume, llm);
35+
}
36+
37+
let selectedTheme = theme || transformedResume.meta?.theme || 'elegant';
3138
selectedTheme = selectedTheme.toLowerCase();
3239

3340
// Handle ?theme=random by returning a redirect page
@@ -114,7 +121,7 @@ const generateResume = async (username, extension = 'template', query = {}) => {
114121

115122
const options = { ...query, theme: selectedTheme, username };
116123

117-
return formatResume(resume, formatter, options);
124+
return formatResume(transformedResume, formatter, options);
118125
};
119126

120127
export default generateResume;
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { generateObject } from 'ai';
2+
import { openai } from '@ai-sdk/openai';
3+
import { z } from 'zod';
4+
import { logger } from '../logger';
5+
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+
163+
const SYSTEM_PROMPT = `You are a professional resume editor. Your task is to modify a JSON Resume based on user instructions.
164+
165+
IMPORTANT RULES:
166+
1. ONLY modify content that is relevant to the user's request
167+
2. NEVER invent fake information, companies, or experiences
168+
3. Keep all dates, company names, and factual information accurate
169+
4. You may rewrite summaries, highlights, and descriptions to emphasize certain aspects
170+
5. You may reorder items to prioritize relevant experience
171+
6. You may adjust the label/title to better match the target role
172+
7. Preserve the overall structure and all existing sections
173+
8. Make the changes sound natural and professional
174+
175+
Return the complete modified resume in JSON Resume format.`;
176+
177+
/**
178+
* Transform a resume using an LLM based on a user prompt
179+
* @param {Object} resume - The original JSON Resume object
180+
* @param {string} prompt - The user's transformation prompt
181+
* @returns {Promise<Object>} The transformed resume
182+
*/
183+
export async function transformResumeWithLLM(resume, prompt) {
184+
if (!process.env.OPENAI_API_KEY) {
185+
logger.warn('OPENAI_API_KEY not set, skipping LLM transformation');
186+
return resume;
187+
}
188+
189+
if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
190+
return resume;
191+
}
192+
193+
const startTime = Date.now();
194+
195+
try {
196+
logger.info(
197+
{ prompt: prompt.substring(0, 100) },
198+
'Starting LLM resume transformation'
199+
);
200+
201+
const result = await generateObject({
202+
model: openai('gpt-4.1-mini'),
203+
schema: resumeSchema,
204+
system: SYSTEM_PROMPT,
205+
prompt: `Here is the original resume:\n\n${JSON.stringify(
206+
resume,
207+
null,
208+
2
209+
)}\n\nUser request: ${prompt}\n\nPlease modify the resume according to the user's request and return the complete updated resume.`,
210+
});
211+
212+
const duration = Date.now() - startTime;
213+
logger.info(
214+
{ duration, prompt: prompt.substring(0, 100) },
215+
'LLM transformation completed'
216+
);
217+
218+
return result.object;
219+
} catch (error) {
220+
logger.error(
221+
{ error: error.message, prompt: prompt.substring(0, 100) },
222+
'LLM transformation failed'
223+
);
224+
// Return original resume on error
225+
return resume;
226+
}
227+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
// Mock the AI SDK
4+
vi.mock('ai', () => ({
5+
generateObject: vi.fn(),
6+
}));
7+
8+
vi.mock('@ai-sdk/openai', () => ({
9+
openai: vi.fn(() => 'mocked-model'),
10+
}));
11+
12+
vi.mock('../logger', () => ({
13+
logger: {
14+
info: vi.fn(),
15+
warn: vi.fn(),
16+
error: vi.fn(),
17+
},
18+
}));
19+
20+
describe('transformResumeWithLLM', () => {
21+
beforeEach(() => {
22+
vi.resetModules();
23+
vi.clearAllMocks();
24+
});
25+
26+
it('returns original resume when no prompt provided', async () => {
27+
const { transformResumeWithLLM } = await import(
28+
'./transformResumeWithLLM.js'
29+
);
30+
const resume = { basics: { name: 'Test User' } };
31+
32+
const result = await transformResumeWithLLM(resume, '');
33+
expect(result).toEqual(resume);
34+
35+
const result2 = await transformResumeWithLLM(resume, null);
36+
expect(result2).toEqual(resume);
37+
});
38+
39+
it('returns original resume when OPENAI_API_KEY not set', async () => {
40+
const originalKey = process.env.OPENAI_API_KEY;
41+
delete process.env.OPENAI_API_KEY;
42+
43+
const { transformResumeWithLLM } = await import(
44+
'./transformResumeWithLLM.js'
45+
);
46+
const resume = { basics: { name: 'Test User' } };
47+
48+
const result = await transformResumeWithLLM(resume, 'promote frontend');
49+
expect(result).toEqual(resume);
50+
51+
process.env.OPENAI_API_KEY = originalKey;
52+
});
53+
54+
it('calls generateObject with correct parameters', async () => {
55+
process.env.OPENAI_API_KEY = 'test-key';
56+
57+
const { generateObject } = await import('ai');
58+
generateObject.mockResolvedValue({
59+
object: {
60+
basics: { name: 'Test User', label: 'Frontend Developer' },
61+
},
62+
});
63+
64+
const { transformResumeWithLLM } = await import(
65+
'./transformResumeWithLLM.js'
66+
);
67+
const resume = { basics: { name: 'Test User', label: 'Developer' } };
68+
69+
const result = await transformResumeWithLLM(
70+
resume,
71+
'promote frontend experience'
72+
);
73+
74+
expect(generateObject).toHaveBeenCalledWith(
75+
expect.objectContaining({
76+
system: expect.stringContaining('professional resume editor'),
77+
prompt: expect.stringContaining('promote frontend experience'),
78+
})
79+
);
80+
81+
expect(result.basics.label).toBe('Frontend Developer');
82+
});
83+
84+
it('returns original resume on API error', async () => {
85+
process.env.OPENAI_API_KEY = 'test-key';
86+
87+
const { generateObject } = await import('ai');
88+
generateObject.mockRejectedValue(new Error('API Error'));
89+
90+
const { transformResumeWithLLM } = await import(
91+
'./transformResumeWithLLM.js'
92+
);
93+
const resume = { basics: { name: 'Test User' } };
94+
95+
const result = await transformResumeWithLLM(resume, 'some prompt');
96+
expect(result).toEqual(resume);
97+
});
98+
});

0 commit comments

Comments
 (0)