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
5 changes: 5 additions & 0 deletions src/__tests__/ats-score.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ describe('calculateATSScore', () => {
);
});

it('preserves substring matching for overlapping keyword names', () => {
const result = calculateATSScore('JavaScript platform work', 'Java Java JavaScript JavaScript');
expect(result.matchedKeywords).toEqual(expect.arrayContaining(['java', 'javascript']));
});

it('filters stop words and filler words', () => {
const jd = 'the company is looking for a strong candidate with good experience';
const resume = 'the company is looking for a strong candidate with good experience';
Expand Down
64 changes: 62 additions & 2 deletions src/lib/ats-score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,65 @@ function extractBigrams(words: string[]): string[] {
return bigrams;
}

interface KeywordNode {
next: Map<string, number>;
failure: number;
outputs: string[];
}

function addKeywordNodes(nodes: KeywordNode[], keywords: Iterable<string>): void {
for (const keyword of keywords) {
let state = 0;
for (const character of keyword) {
let next = nodes[state].next.get(character);
if (next === undefined) {
next = nodes.length;
nodes[state].next.set(character, next);
nodes.push({ next: new Map(), failure: 0, outputs: [] });
}
state = next;
}
nodes[state].outputs.push(keyword);
}
}

function addKeywordFailureLinks(nodes: KeywordNode[]): void {
const queue = [...nodes[0].next.values()];
for (let offset = 0; offset < queue.length; offset += 1) {
const state = queue[offset];
for (const [character, next] of nodes[state].next) {
queue.push(next);
let failure = nodes[state].failure;
while (failure !== 0 && !nodes[failure].next.has(character)) {
failure = nodes[failure].failure;
}
nodes[next].failure = nodes[failure].next.get(character) ?? 0;
nodes[next].outputs.push(...nodes[nodes[next].failure].outputs);
}
}
}

function buildKeywordNodes(keywords: Iterable<string>): KeywordNode[] {
const nodes: KeywordNode[] = [{ next: new Map(), failure: 0, outputs: [] }];
addKeywordNodes(nodes, keywords);
addKeywordFailureLinks(nodes);
return nodes;
}

function findContainedKeywords(text: string, keywords: Iterable<string>): Set<string> {
const nodes = buildKeywordNodes(keywords);
const matches = new Set<string>();
let state = 0;
for (const character of text) {
while (state !== 0 && !nodes[state].next.has(character)) {
state = nodes[state].failure;
}
state = nodes[state].next.get(character) ?? 0;
for (const keyword of nodes[state].outputs) matches.add(keyword);
}
return matches;
}

export function calculateATSScore(resumeText: string, jdText: string): ATSResult {
if (!resumeText.trim() || !jdText.trim()) {
return { score: 0, matchedKeywords: [], missingKeywords: [], totalKeywords: 0 };
Expand Down Expand Up @@ -232,12 +291,13 @@ export function calculateATSScore(resumeText: string, jdText: string): ATSResult
const matchedImportant: string[] = [];
const matchedRegular: string[] = [];
const missingKeywords: string[] = [];
const containedKeywords = findContainedKeywords(resumeLower, [...important, ...regular]);
for (const keyword of important) {
if (resumeLower.includes(keyword)) matchedImportant.push(keyword);
if (containedKeywords.has(keyword)) matchedImportant.push(keyword);
else missingKeywords.push(keyword);
}
for (const keyword of regular) {
if (resumeLower.includes(keyword)) matchedRegular.push(keyword);
if (containedKeywords.has(keyword)) matchedRegular.push(keyword);
else missingKeywords.push(keyword);
}

Expand Down
Loading