From 603e5d7bc71fefe5ce63c0aed7534c02e36dabcb Mon Sep 17 00:00:00 2001 From: Marina Shopina Date: Tue, 9 Dec 2025 16:11:06 +0000 Subject: [PATCH 1/2] chore: cors added, parse function added --- backend/package-lock.json | 98 ++++++++++++++++++++- backend/package.json | 3 + backend/src/controllers/ai.controller.ts | 105 ++++++++++++++++++++--- backend/src/index.ts | 18 +++- package-lock.json | 6 -- 5 files changed, 205 insertions(+), 25 deletions(-) delete mode 100644 package-lock.json diff --git a/backend/package-lock.json b/backend/package-lock.json index e3f307d..45becd0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,12 +11,15 @@ "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@types/multer": "^2.0.0", + "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "jszip": "^3.10.1", "multer": "^2.0.2" }, "devDependencies": { "@ljharb/tsconfig": "^0.3.2", + "@types/cors": "^2.8.19", "@types/express": "^5.0.5", "@types/node": "^24.10.1", "copyfiles": "^2.4.1", @@ -596,6 +599,16 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", @@ -1092,9 +1105,21 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -1588,6 +1613,12 @@ "dev": true, "license": "ISC" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1697,6 +1728,57 @@ "node": ">=16" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -1974,6 +2056,12 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2036,7 +2124,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/proxy-addr": { @@ -2164,7 +2251,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/safer-buffer": { @@ -2223,6 +2309,12 @@ "node": ">= 18" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 116719b..b93b13e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,12 +17,15 @@ "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@types/multer": "^2.0.0", + "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "jszip": "^3.10.1", "multer": "^2.0.2" }, "devDependencies": { "@ljharb/tsconfig": "^0.3.2", + "@types/cors": "^2.8.19", "@types/express": "^5.0.5", "@types/node": "^24.10.1", "copyfiles": "^2.4.1", diff --git a/backend/src/controllers/ai.controller.ts b/backend/src/controllers/ai.controller.ts index eecc95d..313d0a5 100644 --- a/backend/src/controllers/ai.controller.ts +++ b/backend/src/controllers/ai.controller.ts @@ -1,4 +1,5 @@ import type { Request, Response, Express } from "express"; +import JSZip from "jszip"; import { llmService } from "../services/llm.service.js"; import type { RewriteBulletPointRequest, @@ -35,23 +36,22 @@ export const analyzeResume = async ( } const resumeText = await parseResumeFile(req.file as Express.Multer.File); + const cleanResumeText = resumeText.trim(); - if (!resumeText) { - res.status(501).json({ - error: "Resume parsing not implemented yet", - message: "Add parser to extract text from PDF/DOC/DOCX buffer.", + if (!cleanResumeText) { + res.status(400).json({ + error: "Could not extract text from resume", + message: "Unsupported or unreadable resume format", }); return; } - // TODO: when parsing is ready, call the LLM with real resume text. - // const result = await llmService.analyzeResume({ resumeText, jobDescription }); - // res.status(200).json(result); - res.status(501).json({ - error: "Analysis pending", - message: - "Resume parsing stub in place; wire to LLM after parser is added.", + const result = await llmService.analyzeResume({ + resumeText: cleanResumeText, + jobDescription, }); + + res.status(200).json(result); } catch (error) { console.error("Error in analyzeResume:", error); res.status(500).json({ @@ -61,9 +61,86 @@ export const analyzeResume = async ( } }; -const parseResumeFile = async (_file: Express.Multer.File): Promise => { - // TODO: implement parsing of PDF/DOC/DOCX from req.file.buffer - return ""; +const parseResumeFile = async (file: Express.Multer.File): Promise => { + const mime = (file.mimetype || "").toLowerCase(); + const name = file.originalname?.toLowerCase() || ""; + const buffer = file.buffer; + + if (mime.includes("pdf") || name.endsWith(".pdf")) { + return extractPdfText(buffer); + } + + if (mime.includes("wordprocessingml") || name.endsWith(".docx")) { + return await extractDocxText(buffer); + } + + if (mime.includes("msword") || name.endsWith(".doc")) { + return extractDocBinaryText(buffer); + } + + return buffer.toString("utf8"); +}; + +const extractPdfText = (buffer: Buffer): string => { + const pdfString = buffer.toString("latin1"); + const matches = pdfString.match(/\(([^()\\]*(?:\\.[^()\\]*)*)\)/g); + + if (!matches) { + return pdfString.replace(/[^\x20-\x7E\r\n]+/g, " ").replace(/\s+/g, " "); + } + + const cleaned = matches + .map((m) => m.slice(1, -1)) + .map((text) => + text + .replace(/\\([nrtbf()\\])/g, (_match, p1) => { + if (p1 === "n") return "\n"; + if (p1 === "r") return "\r"; + if (p1 === "t") return "\t"; + if (p1 === "b") return "\b"; + if (p1 === "f") return "\f"; + return p1; + }) + .replace(/\\(\d{1,3})/g, (_m, octal) => { + const code = parseInt(octal, 8); + return Number.isFinite(code) ? String.fromCharCode(code) : ""; + }), + ) + .join(" "); + + return cleaned.replace(/\s+/g, " "); +}; + +const extractDocBinaryText = (buffer: Buffer): string => { + return buffer + .toString("latin1") + .replace(/[^\x20-\x7E\r\n]+/g, " ") + .replace(/\s+/g, " "); +}; + +const extractDocxText = async (buffer: Buffer): Promise => { + try { + const zip = await JSZip.loadAsync(buffer); + const file = zip.file("word/document.xml"); + const xml = file ? await file.async("text") : ""; + + if (!xml) { + return ""; + } + + return xml + .replace(/]*>/g, "\n") + .replace(/<[^>]+>/g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, " "); + } catch (error) { + console.error("Failed to extract DOCX text:", error); + return ""; + } }; /** diff --git a/backend/src/index.ts b/backend/src/index.ts index cd34f7a..9e4d61c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,8 +1,9 @@ import "dotenv/config"; import type { Express, Request, Response } from "express"; import express from "express"; -import path from "path"; -import { fileURLToPath } from "url"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import cors from "cors"; import aiRoutes from "./routes/ai.routes.js"; @@ -11,11 +12,24 @@ const __dirname = path.dirname(__filename); const app: Express = express(); const port = process.env.PORT ?? 3000; +const allowedOrigin = + process.env.FRONTEND_ORIGIN ?? + process.env.CORS_ORIGIN ?? + "http://localhost:5173"; // Middleware app.use(express.json({ limit: "10mb" })); // Parse JSON bodies with increased limit for resumes app.use(express.urlencoded({ extended: true, limit: "10mb" })); // Parse URL-encoded bodies +app.use( + cors({ + origin: allowedOrigin, + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + credentials: true, + }), +); + // Serve static files from the 'public' directory app.use(express.static(path.join(__dirname, "public"))); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index f8d46c9..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "KeyCV-Infrastructure", - "lockfileVersion": 2, - "requires": true, - "packages": {} -} From 0014e5ea36575502a4cc7a6cf24da0897cc05269 Mon Sep 17 00:00:00 2001 From: Marina Shopina Date: Wed, 10 Dec 2025 13:06:14 +0000 Subject: [PATCH 2/2] feat: response from ai is working, connected back and front --- backend/src/controllers/ai.controller.ts | 4 ++-- backend/src/index.ts | 19 ++++++++----------- backend/src/routes/ai.routes.ts | 2 +- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/backend/src/controllers/ai.controller.ts b/backend/src/controllers/ai.controller.ts index 313d0a5..2824d21 100644 --- a/backend/src/controllers/ai.controller.ts +++ b/backend/src/controllers/ai.controller.ts @@ -16,8 +16,8 @@ export const analyzeResume = async ( ): Promise => { try { const jobDescription = - typeof req.body?.jobDescription === "string" - ? req.body.jobDescription.trim() + typeof req.body?.job_description === "string" + ? req.body.job_description.trim() : ""; const hasFile = Boolean(req.file); diff --git a/backend/src/index.ts b/backend/src/index.ts index dc5f879..ca09940 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,17 +12,7 @@ const __dirname = path.dirname(__filename); const app: Express = express(); const port = process.env.PORT ?? 3000; -const allowedOrigin = - process.env.FRONTEND_ORIGIN ?? - process.env.CORS_ORIGIN ?? - "http://localhost:5173"; - -// AI Toolkit API routes -app.use("/api/ai", aiRoutes); - -// Middleware -app.use(express.json({ limit: "10mb" })); // Parse JSON bodies with increased limit for resumes -app.use(express.urlencoded({ extended: true, limit: "10mb" })); // Parse URL-encoded bodies +const allowedOrigin = process.env.FRONTEND_ORIGIN ?? "http://localhost:5173"; app.use( cors({ @@ -33,6 +23,13 @@ app.use( }), ); +// AI Toolkit API routes +app.use("/api/ai", aiRoutes); + +// Middleware +app.use(express.json({ limit: "10mb" })); // Parse JSON bodies with increased limit for resumes +app.use(express.urlencoded({ extended: true, limit: "10mb" })); // Parse URL-encoded bodies + // Serve static files from the 'public' directory app.use(express.static(path.join(__dirname, "public"))); diff --git a/backend/src/routes/ai.routes.ts b/backend/src/routes/ai.routes.ts index 3af0dde..d8d1194 100644 --- a/backend/src/routes/ai.routes.ts +++ b/backend/src/routes/ai.routes.ts @@ -14,7 +14,7 @@ const router = Router(); * POST /api/ai/analyze-resume * Body: { resumeText: string, jobDescription: string } */ -router.post("/analyze-resume", upload.single("file"), analyzeResume); +router.post("/analyze-resume", upload.single("cv_file"), analyzeResume); /** * Feature 2: Resume Bullet Point Rewriter