Add AI Resume Upload & Automatic Parsing feature
This commit is contained in:
@@ -223,6 +223,105 @@ Extract the following fields accurately and output JSON:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API 2B: Parse Master Resume Text / Document into User Profile
|
||||||
|
app.post("/api/gemini/parse-resume", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { resumeText, llmConfig } = req.body;
|
||||||
|
|
||||||
|
if (!resumeText || typeof resumeText !== 'string' || resumeText.trim().length === 0) {
|
||||||
|
return res.status(400).json({ success: false, error: "Resume text content is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `Analyze the following resume text and extract all candidate information into structured JSON:
|
||||||
|
|
||||||
|
Resume Content:
|
||||||
|
${resumeText}
|
||||||
|
|
||||||
|
Extract the following fields accurately:
|
||||||
|
- fullName: Full Name of candidate
|
||||||
|
- email: Email address
|
||||||
|
- phone: Phone number
|
||||||
|
- location: City, State or location preference
|
||||||
|
- linkedinUrl: LinkedIn profile URL if found
|
||||||
|
- githubUrl: GitHub profile URL if found
|
||||||
|
- portfolioUrl: Personal website/portfolio URL if found
|
||||||
|
- summary: Professional summary or objective paragraph
|
||||||
|
- skills: Array of technical & soft skills
|
||||||
|
- experience: Array of work experience objects, each with:
|
||||||
|
- id: unique string
|
||||||
|
- title: Job title
|
||||||
|
- company: Company name
|
||||||
|
- period: Date range (e.g. 2022 - Present)
|
||||||
|
- bullets: Array of accomplishment bullet points
|
||||||
|
- education: Array of education objects, each with:
|
||||||
|
- degree: Degree title
|
||||||
|
- institution: School or University name
|
||||||
|
- year: Graduation year`;
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
fullName: { type: Type.STRING },
|
||||||
|
email: { type: Type.STRING },
|
||||||
|
phone: { type: Type.STRING },
|
||||||
|
location: { type: Type.STRING },
|
||||||
|
linkedinUrl: { type: Type.STRING },
|
||||||
|
githubUrl: { type: Type.STRING },
|
||||||
|
portfolioUrl: { type: Type.STRING },
|
||||||
|
summary: { type: Type.STRING },
|
||||||
|
skills: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: { type: Type.STRING }
|
||||||
|
},
|
||||||
|
experience: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
id: { type: Type.STRING },
|
||||||
|
title: { type: Type.STRING },
|
||||||
|
company: { type: Type.STRING },
|
||||||
|
period: { type: Type.STRING },
|
||||||
|
bullets: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: { type: Type.STRING }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
education: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
degree: { type: Type.STRING },
|
||||||
|
institution: { type: Type.STRING },
|
||||||
|
year: { type: Type.STRING }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["fullName", "skills", "experience"]
|
||||||
|
};
|
||||||
|
|
||||||
|
const { text, providerUsed } = await generateTextWithLLMFallback(prompt, schema, llmConfig);
|
||||||
|
const parsedProfile = JSON.parse(text || "{}");
|
||||||
|
|
||||||
|
// Ensure IDs on experience items
|
||||||
|
if (Array.isArray(parsedProfile.experience)) {
|
||||||
|
parsedProfile.experience = parsedProfile.experience.map((exp: any, index: number) => ({
|
||||||
|
...exp,
|
||||||
|
id: exp.id || `exp_parsed_${Date.now()}_${index}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, profile: parsedProfile, providerUsed });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error parsing resume:", error);
|
||||||
|
res.status(500).json({ success: false, error: error.message || "Failed to parse resume" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// API 3: Tailor Resume & Calculate Match Score
|
// API 3: Tailor Resume & Calculate Match Score
|
||||||
app.post("/api/gemini/tailor-resume", async (req, res) => {
|
app.post("/api/gemini/tailor-resume", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -99,6 +99,54 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||||||
handleUpdateField('skills', updated);
|
handleUpdateField('skills', updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [isParsingResume, setIsParsingResume] = useState(false);
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleResumeFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setIsParsingResume(true);
|
||||||
|
setParseError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const res = await fetch('/api/gemini/parse-resume', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
resumeText: text,
|
||||||
|
llmConfig: profile.llmConfig
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.profile) {
|
||||||
|
const parsed = data.profile;
|
||||||
|
setProfile(prev => ({
|
||||||
|
...prev,
|
||||||
|
fullName: parsed.fullName || prev.fullName,
|
||||||
|
email: parsed.email || prev.email,
|
||||||
|
phone: parsed.phone || prev.phone,
|
||||||
|
location: parsed.location || prev.location,
|
||||||
|
linkedinUrl: parsed.linkedinUrl || prev.linkedinUrl,
|
||||||
|
githubUrl: parsed.githubUrl || prev.githubUrl,
|
||||||
|
portfolioUrl: parsed.portfolioUrl || prev.portfolioUrl,
|
||||||
|
summary: parsed.summary || prev.summary,
|
||||||
|
skills: Array.isArray(parsed.skills) && parsed.skills.length > 0 ? parsed.skills : prev.skills,
|
||||||
|
experience: Array.isArray(parsed.experience) && parsed.experience.length > 0 ? parsed.experience : prev.experience,
|
||||||
|
education: Array.isArray(parsed.education) && parsed.education.length > 0 ? parsed.education : prev.education
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setParseError(data.error || 'Failed to parse resume content.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setParseError(`Failed to read/parse file: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsParsingResume(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -126,6 +174,19 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 font-semibold text-xs cursor-pointer flex items-center gap-2 transition-all">
|
||||||
|
<Sparkles className="w-4 h-4 text-amber-400" />
|
||||||
|
<span>{isParsingResume ? 'Parsing Resume with AI...' : 'Upload & Parse Resume'}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".txt,.md,.json,.pdf,.docx"
|
||||||
|
onChange={handleResumeFileUpload}
|
||||||
|
disabled={isParsingResume}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -135,6 +196,14 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||||||
<span>{isSaving ? 'Saving...' : savedSuccess ? 'Saved to Firestore!' : 'Save Master Profile'}</span>
|
<span>{isSaving ? 'Saving...' : savedSuccess ? 'Saved to Firestore!' : 'Save Master Profile'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parseError && (
|
||||||
|
<div className="p-4 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
|
||||||
|
<span>{parseError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Profile Forms */}
|
{/* Main Profile Forms */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user