diff --git a/.gitignore b/.gitignore index 48bbc48..9e42270 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ web_modules/ .env.test.local .env.production.local .env.local +.env* # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/nextstep-backend/src/controllers/resources_controller.ts b/nextstep-backend/src/controllers/resources_controller.ts index 7e62bcd..1ef354a 100644 --- a/nextstep-backend/src/controllers/resources_controller.ts +++ b/nextstep-backend/src/controllers/resources_controller.ts @@ -54,7 +54,9 @@ const getImageResource = async (req: Request, res: Response) => { const createResumeResource = async (req: Request, res: Response) => { try { - const resumeFilename = await uploadResume(req); + const resumeFilename = await uploadResume(req); + + return res.status(201).send(resumeFilename); } catch (error) { if (error instanceof multer.MulterError || error instanceof TypeError) { diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts index 27dee76..8adbf77 100644 --- a/nextstep-backend/src/controllers/resume_controller.ts +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -2,8 +2,11 @@ import { Request, Response } from 'express'; import { config } from '../config/config'; import fs from 'fs'; import path from 'path'; -import { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, parseResumeFields } from '../services/resume_service'; +import { scoreResume, streamScoreResume, getResumeTemplates, + generateImprovedResume, parseResumeFields, + saveParsedResume, getResumeByOwner } from '../services/resume_service'; import multer from 'multer'; +import {getResumeBuffer, resumeExists, uploadResume} from '../services/resources_service'; import { CustomRequest } from "types/customRequest"; import { handleError } from "../utils/handle_error"; @@ -94,12 +97,19 @@ const generateResume = async (req: Request, res: Response) => { }; -const parseResume = async (req: Request, res: Response) => { +const parseResume = async (req: CustomRequest, res: Response) => { try { - if (!req.file) { + if (!req.body.resumefileName) { return res.status(400).json({ error: 'No resume file uploaded' }); } - const parsed = await parseResumeFields(req.file.buffer, req.file.originalname); + else if (!resumeExists(req.body.resumefileName)) { + return res.status(400).json({ error: 'No resume file uploaded' }); + } + + const resumeFilename = req.body.resumefileName; + const parsed = await parseResumeFields(getResumeBuffer(req.body.resumefileName), resumeFilename); + const resumeData = await saveParsedResume(parsed, req.user.id, resumeFilename); + return res.status(200).json(parsed); } catch (err: any) { console.error('Error parsing resume:', err); @@ -107,4 +117,20 @@ const parseResume = async (req: Request, res: Response) => { } }; -export default { parseResume, getResumeScore, getStreamResumeScore, getTemplates, generateResume }; \ No newline at end of file +const getResumeData = async (req: CustomRequest, res: Response) => { + try { + const ownerId = req.user.id; + // Get the optional version parameter from query string + const version = req.query.version ? parseInt(req.query.version as string) : undefined; + const resume = await getResumeByOwner(ownerId, version); + + return res.status(200).json(resume); + } catch (error) { + console.error('Error retrieving resume data:', error); + return handleError(error, res); + } +}; + +export default { parseResume, getResumeScore, + getStreamResumeScore, getTemplates, + generateResume, getResumeData }; \ No newline at end of file diff --git a/nextstep-backend/src/models/resume_model.ts b/nextstep-backend/src/models/resume_model.ts new file mode 100644 index 0000000..4a3499a --- /dev/null +++ b/nextstep-backend/src/models/resume_model.ts @@ -0,0 +1,36 @@ +import mongoose, { Schema } from 'mongoose'; +import {ResumeData} from "types/resume_types"; + +const ResumeSchema = new Schema({ + owner: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + version: { type: Number, required: true }, + rawContentLink: { type: String, required: true }, + parsedData: { + type: { + aboutMe: { type: String, required: false }, + skills: { type: [String], required: false }, + roleMatch: { type: String, required: false }, + experience: { type: [String], required: false } + + }, + required: false + }, + createdAt: { type: Date, default: Date.now } +}, { versionKey: false }); + + +ResumeSchema.set('toJSON', { + transform: (doc, ret): ResumeData => { + return { + id: ret._id, + owner: ret.owner._id.toString(), + createdAt: ret.createdAt, + updatedAt: ret.updatedAt, + version: ret.version, + rawContentLink: ret.rawContentLink, + parsedData: ret.parsedData + }; + } +}); + +export const ResumeModel = mongoose.model('Resume', ResumeSchema); \ No newline at end of file diff --git a/nextstep-backend/src/routes/resume_routes.ts b/nextstep-backend/src/routes/resume_routes.ts index b365b02..9bb93bd 100644 --- a/nextstep-backend/src/routes/resume_routes.ts +++ b/nextstep-backend/src/routes/resume_routes.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from 'express'; import Resume from '../controllers/resume_controller'; import { CustomRequest } from "types/customRequest"; import multer from 'multer'; +import * as commentsController from "../controllers/comments_controller"; const upload = multer(); @@ -15,6 +16,10 @@ router.get('/templates', Resume.getTemplates); router.post('/generate', Resume.generateResume); -router.post('/parseResume', upload.single('resume'), Resume.parseResume); +router.post('/parseResume', upload.single('resume'), (req: Request, res: Response) => Resume.parseResume(req as CustomRequest, res)); + +// TODO - Use it in the frontend after the parse and upload resume +router.get('/resumeData/:version', (req: Request, res: Response) => Resume.getResumeData(req as CustomRequest, res)) + export default router; \ No newline at end of file diff --git a/nextstep-backend/src/services/posts_service.ts b/nextstep-backend/src/services/posts_service.ts index b8998ce..f463af5 100644 --- a/nextstep-backend/src/services/posts_service.ts +++ b/nextstep-backend/src/services/posts_service.ts @@ -1,6 +1,6 @@ import {PostModel } from '../models/posts_model'; import { IPost, PostData } from 'types/post_types'; -import {ClientSession, Document} from 'mongoose'; +import {Document} from 'mongoose'; import * as mongoose from 'mongoose'; import * as commentsService from './comments_service'; import * as usersService from './users_service'; @@ -9,7 +9,8 @@ import likeModel from "../models/like_model"; import {CommentData} from "types/comment_types"; import {UserData} from 'types/user_types'; import * as chatService from './chat_api_service'; -import {config} from "../config/config"; +import {config} from "../config/config" + const postToPostData = async (post: Document & IPost): Promise => { diff --git a/nextstep-backend/src/services/resources_service.ts b/nextstep-backend/src/services/resources_service.ts index 8370c26..5907a39 100644 --- a/nextstep-backend/src/services/resources_service.ts +++ b/nextstep-backend/src/services/resources_service.ts @@ -123,4 +123,32 @@ const uploadResume = (req: MulterRequest): Promise => { }); }; -export { uploadImage, uploadResume }; \ No newline at end of file +const getResumePath = (filename: string): string => { + const resumesDirectoryPath = config.resources.resumesDirectoryPath(); + const resumePath = path.resolve(resumesDirectoryPath, filename); + + if (!fs.existsSync(resumePath)) { + throw new TypeError('Resume not found'); + } + + return resumePath; +} + +const getResumeBuffer = (filename: string): Buffer => { + const resumePath = getResumePath(filename); + + try { + return fs.readFileSync(resumePath); + } catch (error: any) { + throw new Error(`Failed to read resume file: ${error.message}`); + } +} + +const resumeExists = (filename: string): boolean => { + const resumesDirectoryPath = config.resources.resumesDirectoryPath(); + const resumePath = path.resolve(resumesDirectoryPath, filename); + return fs.existsSync(resumePath); +}; + + +export { uploadImage, uploadResume, getResumeBuffer, resumeExists }; \ No newline at end of file diff --git a/nextstep-backend/src/services/resume_service.ts b/nextstep-backend/src/services/resume_service.ts index dd33ed0..1a22f8b 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -1,104 +1,28 @@ import { config } from '../config/config'; import path from 'path'; import fs from 'fs'; -import axios from 'axios'; import { chatWithAI, streamChatWithAI } from './chat_api_service'; import mammoth from 'mammoth'; import pdfParse from 'pdf-parse'; import AdmZip from 'adm-zip'; import { DOMParser, XMLSerializer } from 'xmldom'; +import {ParsedResume, ResumeData} from 'types/resume_types'; +import {createResumeExtractionPrompt, createResumeModificationPrompt, feedbackTemplate, SYSTEM_TEMPLATE} from "../utils/resume_handlers/resume_AI_handler"; +import { parseDocument } from '../utils/resume_handlers/resume_files_handler'; +import {ResumeModel} from "../models/resume_model"; +import {Document} from 'mongoose'; -export interface ParsedResume { - aboutMe: string; - skills: string[]; - roleMatch: string; - experience:string[]; - education?: string[]; -} -const SYSTEM_TEMPLATE = `You are a very experienced ATS (Application Tracking System) bot with a deep understanding named Bob the Resume builder. -You will review resumes with or without job descriptions. -You are an expert in resume evaluation and provide constructive feedback with dynamic evaluation. -You should also provide an improvement table, taking into account: -- Content (Medium priority) -- Keyword matching (High priority) -- Hard skills (High priority) -- Soft skills (High priority) -- Overall presentation (Low priority)`; - -const feedbackTemplate = (resumeText: string, jdText: string) => ` -Resume Feedback Report -Here is the resume you provided: -${resumeText} -And the job description: -${jdText} - -Create the Improvement Table in relevance to the resume and give the consideration and suggestion for each section strictly following -the pattern as below and don't just out this guided pattern : -| Area | Consideration | Status | Suggestions | -| ------------- | --------------------------------------------------------------- | ------ | ----------- | -| Content | Measurable Results: At least 5 specific achievements or impact. | Low | | -| | Words to avoid: Negative phrases or clichés. | | | -| Keywords | Hard Skills: Presence and frequency of hard skills. | High | | -| | Soft Skills: Presence and frequency of soft skills. | | | -| Presentation | Education Match: Does the resume list a degree that matches the job requirements? | High | | - -Strengths: -List the strengths of the resume here. - -Detailed Feedback: -Provide detailed feedback on the resume's content, structure, grammar, and relevance to the job description. - -Suggestions: -Provide actionable suggestions for improvement, including specific keywords to include and skills to highlight. - -Based on your analysis, provide a numerical score between 0-100 that represents the overall quality and match of the resume. -The score should be provided at the end of your response in the format: "SCORE: X" where X is the numerical score. -`; -const FEEDBACK_ERROR_MESSAGE = 'The Chat AI feature is turned off. Could not score your resume.'; -const parseDocument = async (filePath: string): Promise => { - const ext = path.extname(filePath).toLowerCase(); - - try { - switch (ext) { - case '.pdf': - return await parsePdf(filePath); - case '.docx': - case '.doc': - return await parseWord(filePath); - case '.txt': - case '.text': - return fs.readFileSync(filePath, 'utf-8'); - default: - throw new Error(`Unsupported file format: ${ext}`); - } - } catch (error: any) { - console.error(`Error parsing document ${filePath}:`, error); - throw new Error(`Failed to parse document: ${error.message}`); - } -}; -const parsePdf = async (filePath: string): Promise => { - try { - const dataBuffer = fs.readFileSync(filePath); - const data = await pdfParse(dataBuffer); - return data.text; - } catch (error: any) { - console.error('Error parsing PDF:', error); - throw new Error('Failed to parse PDF document'); - } -}; +const FEEDBACK_ERROR_MESSAGE = 'The Chat AI feature is turned off. Could not score your resume.'; -const parseWord = async (filePath: string): Promise => { - try { - const result = await mammoth.extractRawText({ path: filePath }); - return result.value; - } catch (error: any) { - console.error('Error parsing Word document:', error); - throw new Error('Failed to parse Word document'); - } + +const resumeToResumeData = async (resume: Document & any): Promise => { + // The mongoose schema's toJSON transform already handles basic conversion + // You could add additional fields here if needed in the future + return resume.toJSON(); }; const scoreResume = async (resumePath: string, jobDescription?: string): Promise<{ score: number; feedback: string }> => { @@ -295,36 +219,11 @@ Content: ${content}`; }).join('\n\n'); // Prepare the prompt for AI to modify the content - const prompt = `You are a resume expert. Please modify the following resume content based on the feedback and job description. - -Current Resume Content: -${readableContent} - -Feedback: -${feedback} - -Job Description: -${jobDescription} - -IMPORTANT: You must return your response in the following EXACT JSON format. Do not include any other text or explanation: - -[ - { - "paragraphIndex": 0, - "text": "First paragraph content here" - } -] - -Rules: -1. Return ONLY the JSON array, nothing else -2. Each paragraph must maintain its original structure -3. The text content should be updated based on the feedback while preserving formatting -4. Maintain the same number of paragraphs as the original -5. Do not include any markdown, formatting, or additional text`; + const prompt = createResumeModificationPrompt(readableContent, feedback, jobDescription); // Get the modified content from AI const modifiedContent = await chatWithAI(SYSTEM_TEMPLATE, [prompt]); - console.log('AI Response:', modifiedContent); // Debug log + console.debug('AI Response:', modifiedContent); // Debug log let modifiedParagraphs; try { @@ -416,23 +315,11 @@ const parseResumeFields = async ( } // 2) Build the extraction prompt - const prompt = ` - Extract from this resume the following fields as JSON: - • "aboutMe": a 1–2 sentence self-summary. - • "skills": an array of technical skills. - • "roleMatch": one-sentence best-fit role suggestion. - • "experience": an array of 3–5 bullet points of key achievements. + const prompt = createResumeExtractionPrompt(text); - Resume text: - --- - ${text} - --- - Respond with a single JSON object and nothing else. The json object should begin directly with parentheses and have the following structure: {"a": "value", "b": "value", ...} - `; - - // 3) Call your Chat AI + // 3) Call Chat AI const aiResponse = await chatWithAI( - SYSTEM_TEMPLATE, // you can reuse your existing SYSTEM_TEMPLATE or define a new one + SYSTEM_TEMPLATE, [prompt] ); @@ -441,4 +328,70 @@ const parseResumeFields = async ( return parsed; }; -export { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, parseResumeFields }; \ No newline at end of file + +const getLatestResumeByUser = async (ownerId: string): Promise => { + try { + const latestResume = await ResumeModel.findOne({ owner: ownerId }) + .sort({ version: -1 }) + .exec(); + + return latestResume ? latestResume.version : 0; // Return version number or 0 if no resume exists + } catch (error) { + console.error('Error finding latest resume:', error); + throw new Error('Failed to retrieve latest resume'); + } +}; + + +const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resumeRawLink: string): Promise => { + const lastVersion = await getLatestResumeByUser(ownerId); + const newVersion = lastVersion + 1; + + const newResume = new ResumeModel({ + owner: ownerId, + version: newVersion, + rawContentLink: resumeRawLink, + parsedData: { + aboutMe: parsedData.aboutMe, + skills: parsedData.skills, + roleMatch: parsedData.roleMatch, + experience: parsedData.experience + } + }); + + const savedResume = await newResume.save(); + return resumeToResumeData(savedResume); +}; + + +const getResumeByOwner = async (ownerId: string, version?: number) => { + try { + let query: {owner: string, version?: number} = { + owner: ownerId, + }; + + // If version is specified, add it to the query + if (version !== undefined) { + query = { ...query, version }; + } + + const resume = await ResumeModel.findOne(query) + .sort(version === undefined ? { version: -1 } : {}) // Sort by version descending only if no specific version requested + .exec(); + + if (!resume) { + throw new Error(version !== undefined + ? `Resume version ${version} not found for user ${ownerId}` + : `No resume found for user ${ownerId}`); + } + + return resume; + } catch (error) { + console.error('Error retrieving resume:', error); + throw error; + } +}; + +export { scoreResume, streamScoreResume, + getResumeTemplates, generateImprovedResume, parseResumeFields, + saveParsedResume, getResumeByOwner }; \ No newline at end of file diff --git a/nextstep-backend/src/types/resume_types.ts b/nextstep-backend/src/types/resume_types.ts new file mode 100644 index 0000000..75791b3 --- /dev/null +++ b/nextstep-backend/src/types/resume_types.ts @@ -0,0 +1,31 @@ +import { Document } from 'mongoose'; + + +export interface ParsedResume { + aboutMe: string; + skills: string[]; + roleMatch: string; + experience: string[]; + education?: string[]; +} + +export interface ResumeDocument extends Document { + _id: string; + owner: string; + version: number; + rawContentLink: string; + parsedData: ParsedResume; + createdAt?: Date; + updatedAt?: Date; +} + +export interface ResumeData { + id: string; + owner: string; + version: number; + rawContentLink: string; + parsedData: ParsedResume; + createdAt?: Date; + updatedAt?: Date; +} + diff --git a/nextstep-backend/src/utils/resume_handlers/resume_AI_handler.ts b/nextstep-backend/src/utils/resume_handlers/resume_AI_handler.ts new file mode 100644 index 0000000..619a275 --- /dev/null +++ b/nextstep-backend/src/utils/resume_handlers/resume_AI_handler.ts @@ -0,0 +1,84 @@ +export const SYSTEM_TEMPLATE = `You are a very experienced ATS (Application Tracking System) bot with a deep understanding named Bob the Resume builder. +You will review resumes with or without job descriptions. +You are an expert in resume evaluation and provide constructive feedback with dynamic evaluation. +You should also provide an improvement table, taking into account: +- Content (Medium priority) +- Keyword matching (High priority) +- Hard skills (High priority) +- Soft skills (High priority) +- Overall presentation (Low priority)`; + +export const feedbackTemplate = (resumeText: string, jdText: string) => ` +Resume Feedback Report +Here is the resume you provided: +${resumeText} +And the job description: +${jdText} + +Create the Improvement Table in relevance to the resume and give the consideration and suggestion for each section strictly following +the pattern as below and don't just out this guided pattern : +| Area | Consideration | Status | Suggestions | +| ------------- | --------------------------------------------------------------- | ------ | ----------- | +| Content | Measurable Results: At least 5 specific achievements or impact. | Low | | +| | Words to avoid: Negative phrases or clichés. | | | +| Keywords | Hard Skills: Presence and frequency of hard skills. | High | | +| | Soft Skills: Presence and frequency of soft skills. | | | +| Presentation | Education Match: Does the resume list a degree that matches the job requirements? | High | | + +Strengths: +List the strengths of the resume here. + +Detailed Feedback: +Provide detailed feedback on the resume's content, structure, grammar, and relevance to the job description. + +Suggestions: +Provide actionable suggestions for improvement, including specific keywords to include and skills to highlight. + +Based on your analysis, provide a numerical score between 0-100 that represents the overall quality and match of the resume. +The score should be provided at the end of your response in the format: "SCORE: X" where X is the numerical score. +`; + +export const createResumeModificationPrompt = (resumeContent: string, feedback: string, jobDescription: string): string => { + return `You are a resume expert. Please modify the following resume content based on the feedback and job description. + +Current Resume Content: +${resumeContent} + +Feedback: +${feedback} + +Job Description: +${jobDescription} + +IMPORTANT: You must return your response in the following EXACT JSON format. Do not include any other text or explanation: + +[ + { + "paragraphIndex": 0, + "text": "First paragraph content here" + } +] + +Rules: +1. Return ONLY the JSON array, nothing else +2. Each paragraph must maintain its original structure +3. The text content should be updated based on the feedback while preserving formatting +4. Maintain the same number of paragraphs as the original +5. Do not include any markdown, formatting, or additional text`; +}; + +export const createResumeExtractionPrompt = (resumeText: string): string => { + return ` + Extract from this resume the following fields as JSON: + • "aboutMe": a 1–2 sentence self-summary. + • "skills": an array of technical skills. + • "roleMatch": one-sentence best-fit role suggestion. + • "experience": an array of 3–5 bullet points of key achievements. + + Resume text: + --- + ${resumeText} + --- + Respond with a single JSON object and nothing else. The json object should begin directly with parentheses and have the following structure: {"a": "value", "b": "value", ...} + `; +}; \ No newline at end of file diff --git a/nextstep-backend/src/utils/resume_handlers/resume_files_handler.ts b/nextstep-backend/src/utils/resume_handlers/resume_files_handler.ts new file mode 100644 index 0000000..7e6f463 --- /dev/null +++ b/nextstep-backend/src/utils/resume_handlers/resume_files_handler.ts @@ -0,0 +1,49 @@ +import path from "path"; +import fs from "fs"; +import pdfParse from "pdf-parse"; +import mammoth from "mammoth"; + +export const parseDocument = async (filePath: string): Promise => { + const ext = path.extname(filePath).toLowerCase(); + + try { + switch (ext) { + case '.pdf': + return await parsePdf(filePath); + case '.docx': + case '.doc': + return await parseWord(filePath); + case '.txt': + case '.text': + return fs.readFileSync(filePath, 'utf-8'); + default: + throw new Error(`Unsupported file format: ${ext}`); + } + } catch (error: any) { + console.error(`Error parsing document ${filePath}:`, error); + throw new Error(`Failed to parse document: ${error.message}`); + } +}; + +const parsePdf = async (filePath: string): Promise => { + try { + const dataBuffer = fs.readFileSync(filePath); + const data = await pdfParse(dataBuffer); + return data.text; + } catch (error: any) { + console.error('Error parsing PDF:', error); + throw new Error('Failed to parse PDF document'); + } +}; + +const parseWord = async (filePath: string): Promise => { + try { + const result = await mammoth.extractRawText({ path: filePath }); + return result.value; + } catch (error: any) { + console.error('Error parsing Word document:', error); + throw new Error('Failed to parse Word document'); + } +}; + + diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 45012f6..2aa9f2d 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -141,15 +141,30 @@ const MainDashboard: React.FC = () => { // Upload & parse resume const handleResumeUpload = async (e: React.ChangeEvent) => { + + const uploadResume = async (formData: FormData) => { + const response = await api.post('/resource/resume', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; + }; + const file = e.target.files?.[0]; if (!file) return; setResumeFileName(file.name); setParsing(true); const form = new FormData(); - form.append('resume', file); + form.append('file', file); try { - const res = await api.post('/resume/parseResume', form, { - headers: { 'Content-Type': 'multipart/form-data' } + const uplaodedResume = await uploadResume(form); + + const res = await api.post('/resume/parseResume', + { + resumefileName: uplaodedResume, + }, { + headers: { 'Content-Type': 'multipart/form-data' }, }); const { aboutMe: aiAbout, skills: aiSkills, roleMatch: aiRole, experience: aiExp } = res.data; setAboutMe(aiAbout);