Skip to content

Commit df3eaf4

Browse files
committed
feat(pathways): add AI-powered 'Why Match' job analysis
- Add job_match_insights table for caching AI explanations - Create /api/pathways/match-insights endpoint using AI SDK generateObject - Add WhyMatch component with expandable bullet points and match score - Integrate into SwipeDetailSheet and PathwaysJobPanel - Results cached in DB with resume/job hash for invalidation
1 parent a07a48a commit df3eaf4

File tree

5 files changed

+489
-0
lines changed

5 files changed

+489
-0
lines changed
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { generateObject } from 'ai';
2+
import { openai } from '@ai-sdk/openai';
3+
import { createClient } from '@supabase/supabase-js';
4+
import { z } from 'zod';
5+
import crypto from 'crypto';
6+
7+
function getSupabase() {
8+
if (!process.env.SUPABASE_KEY) {
9+
throw new Error('SUPABASE_KEY environment variable is required');
10+
}
11+
return createClient(
12+
'https://itxuhvvwryeuzuyihpkp.supabase.co',
13+
process.env.SUPABASE_KEY
14+
);
15+
}
16+
17+
// Create a hash of content for cache invalidation
18+
function hashContent(content) {
19+
return crypto
20+
.createHash('md5')
21+
.update(JSON.stringify(content))
22+
.digest('hex')
23+
.slice(0, 16);
24+
}
25+
26+
const insightsSchema = z.object({
27+
bullets: z.array(
28+
z.object({
29+
emoji: z.string().describe('A relevant emoji for this point'),
30+
title: z.string().describe('Short bold title (2-4 words)'),
31+
description: z.string().describe('Brief explanation (1-2 sentences)'),
32+
})
33+
),
34+
matchScore: z
35+
.number()
36+
.min(0)
37+
.max(100)
38+
.describe('Overall match percentage based on skills and experience'),
39+
summary: z
40+
.string()
41+
.describe('One sentence summary of why this could be a good fit'),
42+
});
43+
44+
const SYSTEM_PROMPT = `You are a career advisor helping a job seeker understand why they might be a good fit for a specific role.
45+
46+
Analyze the resume and job posting, then provide:
47+
1. 4-6 specific bullet points explaining why this person could be a strong candidate
48+
2. Focus on concrete skill matches, relevant experience, and potential growth areas
49+
3. Be honest but encouraging - mention any gaps constructively
50+
4. Each bullet should have a relevant emoji, a short title, and a brief description
51+
52+
Consider:
53+
- Direct skill matches between resume and job requirements
54+
- Relevant experience and projects
55+
- Transferable skills
56+
- Company culture fit indicators
57+
- Growth opportunities this role offers`;
58+
59+
export async function POST(request) {
60+
const supabase = getSupabase();
61+
62+
try {
63+
const { userId, jobId, resume, job } = await request.json();
64+
65+
if (!userId || !jobId || !resume || !job) {
66+
return Response.json(
67+
{ error: 'userId, jobId, resume, and job are required' },
68+
{ status: 400 }
69+
);
70+
}
71+
72+
const resumeHash = hashContent(resume);
73+
const jobHash = hashContent(job);
74+
75+
// Check cache first
76+
const { data: cached } = await supabase
77+
.from('job_match_insights')
78+
.select('*')
79+
.eq('user_id', userId)
80+
.eq('job_id', jobId)
81+
.single();
82+
83+
// Return cached if hashes match (content hasn't changed)
84+
if (
85+
cached &&
86+
cached.resume_hash === resumeHash &&
87+
cached.job_hash === jobHash
88+
) {
89+
return Response.json({
90+
...cached.insights,
91+
cached: true,
92+
generatedAt: cached.created_at,
93+
});
94+
}
95+
96+
// Generate new insights
97+
const { object, usage } = await generateObject({
98+
model: openai('gpt-4.1'),
99+
schema: insightsSchema,
100+
system: SYSTEM_PROMPT,
101+
prompt: `
102+
RESUME:
103+
${JSON.stringify(resume, null, 2)}
104+
105+
JOB POSTING:
106+
Title: ${job.title}
107+
Company: ${job.company}
108+
${job.description ? `Description: ${job.description}` : ''}
109+
${
110+
job.skills?.length
111+
? `Required Skills: ${job.skills
112+
.map((s) => (typeof s === 'string' ? s : s.name))
113+
.join(', ')}`
114+
: ''
115+
}
116+
${
117+
job.bonusSkills?.length
118+
? `Bonus Skills: ${job.bonusSkills
119+
.map((s) => (typeof s === 'string' ? s : s.name))
120+
.join(', ')}`
121+
: ''
122+
}
123+
${
124+
job.location
125+
? `Location: ${
126+
typeof job.location === 'string'
127+
? job.location
128+
: `${job.location.city || ''}, ${job.location.region || ''}`
129+
}`
130+
: ''
131+
}
132+
${job.remote ? 'Remote: Yes' : ''}
133+
${
134+
job.salaryMin && job.salaryMax
135+
? `Salary Range: $${job.salaryMin.toLocaleString()} - $${job.salaryMax.toLocaleString()}`
136+
: ''
137+
}
138+
139+
Provide specific, actionable insights about why this person could be a good fit for this role.`,
140+
});
141+
142+
// Upsert to cache
143+
const { error: upsertError } = await supabase
144+
.from('job_match_insights')
145+
.upsert(
146+
{
147+
user_id: userId,
148+
job_id: jobId,
149+
insights: object,
150+
resume_hash: resumeHash,
151+
job_hash: jobHash,
152+
model: 'gpt-4.1',
153+
tokens_used: usage?.totalTokens || null,
154+
updated_at: new Date().toISOString(),
155+
},
156+
{ onConflict: 'user_id,job_id' }
157+
);
158+
159+
if (upsertError) {
160+
console.error('Error caching insights:', upsertError);
161+
// Don't fail the request, just log the error
162+
}
163+
164+
return Response.json({
165+
...object,
166+
cached: false,
167+
generatedAt: new Date().toISOString(),
168+
});
169+
} catch (error) {
170+
console.error('Error generating match insights:', error);
171+
return Response.json(
172+
{ error: error.message || 'Failed to generate insights' },
173+
{ status: 500 }
174+
);
175+
}
176+
}
177+
178+
export async function GET(request) {
179+
const supabase = getSupabase();
180+
181+
try {
182+
const { searchParams } = new URL(request.url);
183+
const userId = searchParams.get('userId');
184+
const jobId = searchParams.get('jobId');
185+
186+
if (!userId || !jobId) {
187+
return Response.json(
188+
{ error: 'userId and jobId are required' },
189+
{ status: 400 }
190+
);
191+
}
192+
193+
const { data, error } = await supabase
194+
.from('job_match_insights')
195+
.select('*')
196+
.eq('user_id', userId)
197+
.eq('job_id', jobId)
198+
.single();
199+
200+
if (error && error.code !== 'PGRST116') {
201+
// PGRST116 = no rows returned
202+
console.error('Error fetching insights:', error);
203+
return Response.json(
204+
{ error: 'Failed to fetch insights' },
205+
{ status: 500 }
206+
);
207+
}
208+
209+
if (!data) {
210+
return Response.json({ exists: false });
211+
}
212+
213+
return Response.json({
214+
...data.insights,
215+
cached: true,
216+
generatedAt: data.created_at,
217+
});
218+
} catch (error) {
219+
console.error('Error in insights GET:', error);
220+
return Response.json({ error: 'Internal server error' }, { status: 500 });
221+
}
222+
}

0 commit comments

Comments
 (0)