Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 95 additions & 3 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
109 changes: 93 additions & 16 deletions backend/src/controllers/ai.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,8 +16,8 @@ export const analyzeResume = async (
): Promise<void> => {
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);

Expand All @@ -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({
Expand All @@ -61,9 +61,86 @@ export const analyzeResume = async (
}
};

const parseResumeFile = async (_file: Express.Multer.File): Promise<string> => {
// TODO: implement parsing of PDF/DOC/DOCX from req.file.buffer
return "";
const parseResumeFile = async (file: Express.Multer.File): Promise<string> => {
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<string> => {
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(/<w:p[^>]*>/g, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/\s+/g, " ");
} catch (error) {
console.error("Failed to extract DOCX text:", error);
return "";
}
};

/**
Expand Down
15 changes: 13 additions & 2 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -11,6 +12,16 @@ const __dirname = path.dirname(__filename);

const app: Express = express();
const port = process.env.PORT ?? 3000;
const allowedOrigin = process.env.FRONTEND_ORIGIN ?? "http://localhost:5173";

app.use(
cors({
origin: allowedOrigin,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
);

// AI Toolkit API routes
app.use("/api/ai", aiRoutes);
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/ai.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions package-lock.json

This file was deleted.