diff --git a/components/dashboard/ResumePreviewForm.tsx b/components/dashboard/ResumePreviewForm.tsx
index c8b3aede1..1d24bc406 100644
--- a/components/dashboard/ResumePreviewForm.tsx
+++ b/components/dashboard/ResumePreviewForm.tsx
@@ -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 {
diff --git a/components/dashboard/ResumeProfileSection.test.tsx b/components/dashboard/ResumeProfileSection.test.tsx
index fec9bf89d..1ad519f0b 100644
--- a/components/dashboard/ResumeProfileSection.test.tsx
+++ b/components/dashboard/ResumeProfileSection.test.tsx
@@ -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();
+
+ 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();
+
+ fireEvent.click(screen.getByText('Review saved profile'));
+
+ expect(screen.getByText('Preview Form')).toBeInTheDocument();
+ });
+
it('renders component', () => {
expect(true).toBe(true);
});
diff --git a/components/dashboard/ResumeProfileSection.tsx b/components/dashboard/ResumeProfileSection.tsx
index 45ff51a0f..f2cc89942 100644
--- a/components/dashboard/ResumeProfileSection.tsx
+++ b/components/dashboard/ResumeProfileSection.tsx
@@ -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';
@@ -10,6 +10,8 @@ import type { ParsedResume } from '@/types/student';
type Stage = 'idle' | 'uploaded' | 'success';
+const LOCAL_STORAGE_KEY = 'userProfile';
+
interface ResumeProfileSectionProps {
githubUsername: string;
}
@@ -18,6 +20,21 @@ export default function ResumeProfileSection({ githubUsername }: ResumeProfileSe
const [stage, setStage] = useState('idle');
const [parsed, setParsed] = useState(null);
const [fileName, setFileName] = useState('');
+ const [savedProfile, setSavedProfile] = useState(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);
@@ -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.
+ {savedProfile ? (
+
+
Found previously parsed profile data on this device.
+
+
+ ) : null}
)}
diff --git a/lib/resume-parser.test.ts b/lib/resume-parser.test.ts
index c54b29a04..bf630ccb7 100644
--- a/lib/resume-parser.test.ts
+++ b/lib/resume-parser.test.ts
@@ -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', () => {
diff --git a/lib/resume-parser.ts b/lib/resume-parser.ts
index c58666004..9bdf379c1 100644
--- a/lib/resume-parser.ts
+++ b/lib/resume-parser.ts
@@ -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)) {
@@ -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')
@@ -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);
}
/**
@@ -183,6 +214,23 @@ function extractPhone(text: string): string {
export async function parseResume(buffer: Buffer, mimeType: string): Promise {
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),
@@ -193,6 +241,116 @@ export async function parseResume(buffer: Buffer, mimeType: string): Promise {
+ // 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',