From 1e117ab123d4349e24ec748ba8eeb4c4abe1435f Mon Sep 17 00:00:00 2001 From: Tal Jacob Date: Fri, 11 Jul 2025 05:18:32 +0300 Subject: [PATCH 1/7] Remove Resume Template Usage, And Resume Generation Signed-off-by: Tal Jacob --- nextstep-backend/src/config/config.ts | 1 - .../src/controllers/resume_controller.ts | 29 +- nextstep-backend/src/routes/resume_routes.ts | 4 - .../src/services/resume_service.ts | 221 +----- nextstep-frontend/src/pages/Resume.tsx | 631 +++--------------- 5 files changed, 85 insertions(+), 801 deletions(-) diff --git a/nextstep-backend/src/config/config.ts b/nextstep-backend/src/config/config.ts index d985d65..536cb3f 100644 --- a/nextstep-backend/src/config/config.ts +++ b/nextstep-backend/src/config/config.ts @@ -21,7 +21,6 @@ export const config = { resumeMaxSize: () => 5 * 1024 * 1024 // Max file size: 5MB }, assets: { - resumeTemplatesDirectoryPath: () => 'assets/resume-templates', jobQuizzesJobHuntHtmlPath: () => 'assets/job-quizzes/jobhunt/מאגר שאלות מראיונות עבודה.html', jobQuizzesTheWorkerHtmlDirectoryPath: () => 'assets/job-quizzes/theworker/interviews', }, diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts index 27dee76..258f8b6 100644 --- a/nextstep-backend/src/controllers/resume_controller.ts +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -2,7 +2,7 @@ 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, parseResumeFields } from '../services/resume_service'; import multer from 'multer'; import { CustomRequest } from "types/customRequest"; import { handleError } from "../utils/handle_error"; @@ -69,31 +69,6 @@ const getStreamResumeScore = async (req: Request, res: Response) => { } }; -const getTemplates = async (req: Request, res: Response) => { - try { - const templates = await getResumeTemplates(); - return res.status(200).json(templates); - } catch (error) { - handleError(error, res); - } -}; - -const generateResume = async (req: Request, res: Response) => { - try { - const { feedback, jobDescription, templateName } = req.body; - - if (!feedback || !jobDescription || !templateName) { - return res.status(400).json({ error: 'Missing required fields' }); - } - - const result = await generateImprovedResume(feedback, jobDescription, templateName); - return res.status(200).json(result); - } catch (error) { - handleError(error, res); - } -}; - - const parseResume = async (req: Request, res: Response) => { try { if (!req.file) { @@ -107,4 +82,4 @@ const parseResume = async (req: Request, res: Response) => { } }; -export default { parseResume, getResumeScore, getStreamResumeScore, getTemplates, generateResume }; \ No newline at end of file +export default { parseResume, getResumeScore, getStreamResumeScore }; \ 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..3e123f4 100644 --- a/nextstep-backend/src/routes/resume_routes.ts +++ b/nextstep-backend/src/routes/resume_routes.ts @@ -11,10 +11,6 @@ router.get('/score/:filename', Resume.getResumeScore); router.get('/streamScore/:filename', Resume.getStreamResumeScore); -router.get('/templates', Resume.getTemplates); - -router.post('/generate', Resume.generateResume); - router.post('/parseResume', upload.single('resume'), Resume.parseResume); export default router; \ 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..ccbfdc1 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -176,225 +176,6 @@ const streamScoreResume = async ( } }; -const getResumeTemplates = async (): Promise<{ name: string; content: string; type: string }[]> => { - try { - const templatesDir = config.assets.resumeTemplatesDirectoryPath(); - if (!fs.existsSync(templatesDir)) { - return []; - } - - const files = fs.readdirSync(templatesDir); - const templates = await Promise.all( - files - .filter(file => { - const ext = path.extname(file).toLowerCase(); - return ['.pdf', '.doc', '.docx'].includes(ext); - }) - .map(async file => { - const filePath = path.join(templatesDir, file); - const content = fs.readFileSync(filePath); - const base64Content = content.toString('base64'); - const mimeType = { - '.pdf': 'application/pdf', - '.doc': 'application/msword', - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - }[path.extname(file).toLowerCase()] || 'application/octet-stream'; - - return { - name: path.basename(file), - content: base64Content, - type: mimeType - }; - }) - ); - - return templates; - } catch (error) { - console.error('Error reading resume templates:', error); - return []; - } -}; - -// Helper to split a string into N parts -function splitString(str: string, parts: number): string[] { - const len = Math.ceil(str.length / parts); - return Array.from({ length: parts }, (_, i) => str.slice(i * len, (i + 1) * len)); -} - -const generateImprovedResume = async ( - feedback: string, - jobDescription: string, - templateName: string -): Promise<{ content: string; type: string }> => { - try { - const templatesDir = config.assets.resumeTemplatesDirectoryPath(); - const templatePath = path.join(templatesDir, templateName); - - if (!fs.existsSync(templatePath)) { - throw new Error(`Template ${templateName} not found`); - } - - // Only handle DOCX files for now - if (!templateName.toLowerCase().endsWith('.docx')) { - throw new Error('Only DOCX templates are currently supported'); - } - - // Read and unzip the DOCX template - const zip = new AdmZip(templatePath); - const documentXml = zip.getEntry('word/document.xml'); - if (!documentXml) { - throw new Error('Could not find document.xml in the template'); - } - - // Parse the XML content - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(documentXml.getData().toString(), 'text/xml'); - - // Extract the content structure with full XML context - const paragraphs = xmlDoc.getElementsByTagName('w:p'); - const contentStructure = []; - - for (let i = 0; i < paragraphs.length; i++) { - const paragraph = paragraphs[i]; - const runs = paragraph.getElementsByTagName('w:r'); - const paragraphContent = []; - - // Get paragraph properties - const pPr = paragraph.getElementsByTagName('w:pPr')[0]; - const paragraphStyle = pPr ? pPr.toString().replace(/"/g, '\\"') : ''; - - for (let j = 0; j < runs.length; j++) { - const run = runs[j]; - const text = run.getElementsByTagName('w:t')[0]; - if (text) { - // Get run properties - const rPr = run.getElementsByTagName('w:rPr')[0]; - const runStyle = rPr ? rPr.toString().replace(/"/g, '\\"') : ''; - - paragraphContent.push({ - text: text.textContent, - style: runStyle - }); - } - } - - if (paragraphContent.length > 0) { - contentStructure.push({ - type: 'paragraph', - content: paragraphContent, - style: paragraphStyle - }); - } - } - - // Convert structure to readable text for AI while preserving context - const readableContent = contentStructure.map((para, index) => { - const content = para.content.map(run => run.text).join(''); - return `[Paragraph ${index + 1}] -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`; - - // Get the modified content from AI - const modifiedContent = await chatWithAI(SYSTEM_TEMPLATE, [prompt]); - console.log('AI Response:', modifiedContent); // Debug log - - let modifiedParagraphs; - try { - // Clean the response to ensure it's valid JSON - const cleanedResponse = modifiedContent.trim() - .replace(/^```json\s*/, '') - .replace(/```\s*$/, '') - .replace(/^\[/, '[') - .replace(/\]$/, ']') - .replace(/\n/g, ' ') // Remove newlines that might break JSON - .replace(/\r/g, '') // Remove carriage returns - .replace(/\t/g, ' ') // Replace tabs with spaces - .replace(/\s+/g, ' '); // Normalize whitespace - - modifiedParagraphs = JSON.parse(cleanedResponse); - - // Validate the structure - if (!Array.isArray(modifiedParagraphs)) { - throw new Error('Response is not an array'); - } - - for (const para of modifiedParagraphs) { - if (!para.text || typeof para.text !== 'string') { - throw new Error('Invalid paragraph structure: missing or invalid text property'); - } - } - - } catch (error: any) { - console.error('Error parsing AI response:', error); - console.error('Raw AI response:', modifiedContent); - throw new Error(`Failed to parse AI response: ${error.message}`); - } - - // Update the document with modified content while preserving structure - for (let i = 0; i < paragraphs.length && i < modifiedParagraphs.length; i++) { - const paragraph = paragraphs[i]; - const modifiedParagraph = modifiedParagraphs[i]; - const runs = paragraph.getElementsByTagName('w:r'); - - // Update the first run's text content while preserving its style - if (runs.length > 0) { - const firstRun = runs[0]; - const text = firstRun.getElementsByTagName('w:t')[0]; - if (text) { - text.textContent = modifiedParagraph.text; - } - } - } - - // Serialize the modified XML - const serializer = new XMLSerializer(); - const modifiedXml = serializer.serializeToString(xmlDoc); - - // Update the document.xml in the zip - zip.updateFile('word/document.xml', Buffer.from(modifiedXml)); - - // Get the modified DOCX as a buffer - const modifiedDocxBuffer = zip.toBuffer(); - - return { - content: modifiedDocxBuffer.toString('base64'), - type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - }; - } catch (error: any) { - console.error('Error generating improved resume:', error); - throw error; - } -}; - - /** * Extracts raw text from the uploaded resume buffer, * prompts the AI to return { aboutMe, skills[], roleMatch, experience[] } as JSON. @@ -441,4 +222,4 @@ const parseResumeFields = async ( return parsed; }; -export { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, parseResumeFields }; \ No newline at end of file +export { scoreResume, streamScoreResume, parseResumeFields }; \ No newline at end of file diff --git a/nextstep-frontend/src/pages/Resume.tsx b/nextstep-frontend/src/pages/Resume.tsx index 9ff670e..0c2b486 100644 --- a/nextstep-frontend/src/pages/Resume.tsx +++ b/nextstep-frontend/src/pages/Resume.tsx @@ -4,13 +4,7 @@ import { Button, CircularProgress, Typography, - TextField, - Stepper, - Step, - StepLabel, - Card, - CardMedia, - CardContent, + TextField } from '@mui/material'; import { styled } from '@mui/material/styles'; import { config } from '../config'; @@ -18,11 +12,8 @@ import api from '../serverApi'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import ScoreGauge from '../components/ScoreGauge'; -import Carousel from 'react-material-ui-carousel'; import './Resume.css'; -const steps = ['Score your resume', 'Choose a template', 'Generate matching resume']; - const UploadBox = styled(Box)(({ theme }) => ({ border: '2px dashed #ccc', borderRadius: '8px', @@ -87,110 +78,13 @@ const FeedbackContainer = styled(Box)(({ theme }) => ({ }, })); -const TemplateCard = styled(Card)(({ theme }) => ({ - height: '100%', - display: 'flex', - flexDirection: 'column', - position: 'relative', - width: '100%', - maxWidth: '1200px', - margin: '0 auto', - '& .MuiCardMedia-root': { - height: 'calc(100vh - 300px)', - minHeight: '600px', - backgroundSize: 'contain', - backgroundPosition: 'center', - backgroundColor: theme.palette.grey[100], - width: '100%', - }, -})); - -const NavigationContainer = styled(Box)(({ theme }) => ({ - top: 0, - zIndex: 1000, - backgroundColor: theme.palette.background.default, - padding: theme.spacing(2), - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - borderBottom: `1px solid ${theme.palette.divider}`, - marginBottom: theme.spacing(2), -})); - -const StepperContainer = styled(Box)(({ theme }) => ({ - top: 64, - zIndex: 999, - backgroundColor: theme.palette.background.default, - padding: theme.spacing(2, 0), - borderBottom: `1px solid ${theme.palette.divider}`, - marginBottom: theme.spacing(2), -})); - -const GeneratedWordPreview: React.FC<{ base64Content: string }> = ({ base64Content }) => { - const [previewUrl, setPreviewUrl] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - const uploadAndPreview = async () => { - setLoading(true); - setError(null); - try { - // Convert base64 to blob - const byteCharacters = atob(base64Content); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - const blob = new Blob([byteArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); - const formData = new FormData(); - formData.append('file', blob, 'generated.docx'); - const response = await fetch('https://tmpfiles.org/api/v1/upload', { - method: 'POST', - body: formData - }); - if (!response.ok) throw new Error('Failed to upload file'); - const data = await response.json(); - const downloadUrl = data.data.url.replace('https://tmpfiles.org/', 'https://tmpfiles.org/dl/'); - const officeUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(downloadUrl)}`; - setPreviewUrl(officeUrl); - } catch (err: any) { - setError('Failed to prepare document preview'); - } finally { - setLoading(false); - } - }; - uploadAndPreview(); - }, [base64Content]); - - if (loading) return ; - if (error) return {error}; - if (!previewUrl) return null; - return ( - -