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',