Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions components/dashboard/ResumePreviewForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ export default function ResumePreviewForm({
return;
}

try {
window.localStorage.setItem('userProfile', JSON.stringify(data));
} catch {
// Ignore localStorage failures in private mode or quota-limited browsers.
}

toast.success('Profile saved successfully!');
onComplete();
} catch {
Expand Down
47 changes: 47 additions & 0 deletions components/dashboard/ResumeProfileSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,53 @@ describe('ResumeProfileSection', () => {

expect(mockToastError).toHaveBeenCalledWith('Upload failed');
});

it('shows a saved profile prompt when userProfile exists in localStorage', () => {
const savedProfile = {
name: 'Saved Name',
email: 'saved@example.com',
phone: '1234',
skills: ['SavedSkill'],
education: [],
experience: [],
};

vi.stubGlobal('localStorage', {
getItem: vi.fn().mockReturnValue(JSON.stringify(savedProfile)),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
} as unknown as Storage);

render(<ResumeProfileSection githubUsername="john" />);

expect(screen.getByText('Review saved profile')).toBeInTheDocument();
});

it('loads saved profile and opens preview form when the saved profile action is clicked', () => {
const savedProfile = {
name: 'Saved Name',
email: 'saved@example.com',
phone: '1234',
skills: ['SavedSkill'],
education: [],
experience: [],
};

vi.stubGlobal('localStorage', {
getItem: vi.fn().mockReturnValue(JSON.stringify(savedProfile)),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
} as unknown as Storage);

render(<ResumeProfileSection githubUsername="john" />);

fireEvent.click(screen.getByText('Review saved profile'));

expect(screen.getByText('Preview Form')).toBeInTheDocument();
});

it('renders component', () => {
expect(true).toBe(true);
});
Expand Down
35 changes: 34 additions & 1 deletion components/dashboard/ResumeProfileSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState } from 'react';
import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FileText } from 'lucide-react';
import { toast } from 'sonner';
Expand All @@ -10,6 +10,8 @@ import type { ParsedResume } from '@/types/student';

type Stage = 'idle' | 'uploaded' | 'success';

const LOCAL_STORAGE_KEY = 'userProfile';

interface ResumeProfileSectionProps {
githubUsername: string;
}
Expand All @@ -18,6 +20,21 @@ export default function ResumeProfileSection({ githubUsername }: ResumeProfileSe
const [stage, setStage] = useState<Stage>('idle');
const [parsed, setParsed] = useState<ParsedResume | null>(null);
const [fileName, setFileName] = useState('');
const [savedProfile, setSavedProfile] = useState<ParsedResume | null>(null);

useEffect(() => {
try {
const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (stored) {
const parsedValue = JSON.parse(stored) as ParsedResume;
if (parsedValue?.name && parsedValue?.email) {
setSavedProfile(parsedValue);
}
}
} catch {
// Ignore invalid or inaccessible localStorage data.
}
}, []);

function handleParsed(data: ParsedResume, name: string) {
setParsed(data);
Expand Down Expand Up @@ -73,6 +90,22 @@ export default function ResumeProfileSection({ githubUsername }: ResumeProfileSe
Upload your PDF or DOCX resume to auto-fill your profile with skills, education, and
experience.
</p>
{savedProfile ? (
<div className="mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200">
<p className="mb-2">Found previously parsed profile data on this device.</p>
<button
type="button"
onClick={() => {
setParsed(savedProfile);
setFileName('Saved profile data');
setStage('uploaded');
}}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500"
>
Review saved profile
</button>
</div>
) : null}
<ResumeUpload onParsed={handleParsed} onError={handleError} />
</motion.div>
)}
Expand Down
20 changes: 20 additions & 0 deletions lib/resume-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,26 @@ Random text without any section headers.
expect(result.education).toEqual([]);
expect(result.experience).toEqual([]);
});

it('ignores PDF object metadata when extracting text', async () => {
const resume = `
Parent 7 0 R
Prev 13 0 R
endobj
15 0 obj
John Doe
john@example.com
Skills
React, TypeScript
`;

const result = await parseResume(Buffer.from(resume), 'application/pdf');

expect(result.name).toBe('John Doe');
expect(result.email).toBe('john@example.com');
expect(result.skills).toContain('React');
expect(result.skills).toContain('TypeScript');
});
});

describe('parser constants', () => {
Expand Down
176 changes: 167 additions & 9 deletions lib/resume-parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ParsedResume, Education, Experience } from '@/types/student';
import { z } from 'zod';

// Polyfill DOMMatrix for server-side/test environments to prevent pdfjs-dist crash
if (typeof globalThis !== 'undefined' && !('DOMMatrix' in globalThis)) {
Expand All @@ -23,15 +24,50 @@ function extractName(text: string): string {
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
for (const line of lines.slice(0, 5)) {
const match = line.match(NAME_LINE_REGEX);
if (match && !line.includes('@') && !line.includes('http')) {

for (const line of lines.slice(0, 10)) {
const cleaned = line.replace(/^(full\s+)?name\s*[:\-]\s*/i, '');
const match = cleaned.match(NAME_LINE_REGEX);
if (match && !cleaned.includes('@') && !cleaned.includes('http')) {
return match[1];
}
}

return '';
}

function sanitizeExtractedText(rawText: string): string {
const lines = rawText
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.map((line) => line.trim())
.filter((line) => {
if (!line) return false;

const normalized = line.toLowerCase();
if (/^\d+\s+\d+\s+(obj|r)$/i.test(line)) return false;
if (/^xref$/i.test(normalized)) return false;
if (/^trailer$/i.test(normalized)) return false;
if (/^stream$/i.test(normalized)) return false;
if (/^endstream$/i.test(normalized)) return false;
if (/^endobj$/i.test(normalized)) return false;
if (/^<<.*>>$/u.test(line)) return false;
if (/^\/([A-Za-z0-9]+)(\s+\/([A-Za-z0-9]+))*$/u.test(line)) return false;
if (line.startsWith('/Type ') || line.startsWith('/Font ') || line.startsWith('/Filter ') || line.startsWith('/Subtype ') || line.startsWith('/Length ')) {
return false;
}

return true;
});

return lines
.join('\n')
.replace(/[^\x20-\x7E\n]/g, ' ')
.replace(/[ \t]+/g, ' ')
.trim();
}

function extractSection(text: string, headers: RegExp): string[] {
const lines = text
.split('\n')
Expand Down Expand Up @@ -155,12 +191,7 @@ async function extractTextFromBuffer(buffer: Buffer, mimeType: string): Promise<
rawText = buffer.toString('utf-8');
}

const printable = rawText
.replace(/[^\x20-\x7E\n\r]/g, ' ')
.replace(/[ \t]+/g, ' ')
.replace(/\r/g, '')
.trim();
return printable;
return sanitizeExtractedText(rawText);
}

/**
Expand All @@ -183,6 +214,23 @@ function extractPhone(text: string): string {
export async function parseResume(buffer: Buffer, mimeType: string): Promise<ParsedResume> {
const rawText = await extractTextFromBuffer(buffer, mimeType);

// Try AI-assisted parsing if configured. Fall back to rule-based parser on any failure.
const GEMINI_API_URL = process.env.GEMINI_API_URL || '';
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || '';

if (GEMINI_API_URL && GEMINI_API_KEY) {
try {
const aiResult = await aiParseResume(rawText, GEMINI_API_URL, GEMINI_API_KEY);
if (aiResult) return aiResult;
// otherwise fall through to rule-based parser
} catch (err) {
// Do not expose AI errors to callers β€” log and fall back.
// eslint-disable-next-line no-console
console.warn('AI resume parsing failed, falling back to rule-based parser:', err);
}
}

// Rule-based fallback (existing behaviour)
return {
name: extractName(rawText),
email: extractEmail(rawText),
Expand All @@ -193,6 +241,116 @@ export async function parseResume(buffer: Buffer, mimeType: string): Promise<Par
};
}

// Zod schema for validating AI responses before returning to callers
const EducationSchema = z.object({
institution: z.string().default(''),
degree: z.string().default(''),
field: z.string().default(''),
startDate: z.string().default(''),
endDate: z.string().default(''),
});

const ExperienceSchema = z.object({
company: z.string().default(''),
role: z.string().default(''),
startDate: z.string().default(''),
endDate: z.string().default(''),
description: z.string().default(''),
});

const ParsedResumeSchema = z.object({
name: z.string().default(''),
email: z.string().default(''),
phone: z.string().default(''),
skills: z.array(z.string()).default([]),
education: z.array(EducationSchema).default([]),
experience: z.array(ExperienceSchema).default([]),
});

async function aiParseResume(text: string, apiUrl: string, apiKey: string): Promise<ParsedResume | null> {
// Keep prompts and responses ephemeral β€” do not persist.
const prompt = `Extract a JSON object with the following fields from the resume text provided: name, email, phone, skills (array), education (array of {institution, degree, field, startDate, endDate}), experience (array of {company, role, startDate, endDate, description}). Only return valid JSON. If a field is not present, return an empty string or empty array as appropriate. Resume text:\n${text}`;

const resp = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gemini-2.5-flash',
prompt,
max_output_tokens: 1024,
temperature: 0,
}),
// do not cache responses
cache: 'no-store',
});

if (!resp.ok) {
throw new Error(`AI service returned ${resp.status}`);
}

const json = await resp.json();

function tryParseCandidate(candidate: unknown): any {
if (typeof candidate === 'string') {
try {
return JSON.parse(candidate);
} catch {
return null;
}
}

if (typeof candidate === 'object' && candidate !== null) {
return candidate;
}

return null;
}

const candidates = [
json?.output,
json?.data,
json?.candidates?.[0]?.content,
json?.response?.output?.[0]?.content,
json?.response?.text,
json?.choices?.[0]?.message?.content,
json?.choices?.[0]?.text,
json,
];

let parsed: any = null;
for (const candidate of candidates) {
const result = tryParseCandidate(candidate);
if (result !== null) {
parsed = result;
break;
}
}

// If parsed is still a string try to JSON.parse it directly
if (typeof parsed === 'string') {
const nested = tryParseCandidate(parsed);
if (nested === null) {
throw new Error('AI returned non-JSON output');
}
parsed = nested;
}

if (!parsed) {
throw new Error('AI returned non-JSON output');
}

// Validate and coerce using Zod
const safe = ParsedResumeSchema.safeParse(parsed);
if (!safe.success) {
throw new Error('AI response failed schema validation');
}

return safe.data as ParsedResume;
}

export const ALLOWED_MIME_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
Expand Down
Loading