diff --git a/src/__tests__/ats-score.test.ts b/src/__tests__/ats-score.test.ts index 166366f..5c371f2 100644 --- a/src/__tests__/ats-score.test.ts +++ b/src/__tests__/ats-score.test.ts @@ -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'; diff --git a/src/lib/ats-score.ts b/src/lib/ats-score.ts index af480d5..a04a290 100644 --- a/src/lib/ats-score.ts +++ b/src/lib/ats-score.ts @@ -182,6 +182,65 @@ function extractBigrams(words: string[]): string[] { return bigrams; } +interface KeywordNode { + next: Map; + failure: number; + outputs: string[]; +} + +function addKeywordNodes(nodes: KeywordNode[], keywords: Iterable): 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): KeywordNode[] { + const nodes: KeywordNode[] = [{ next: new Map(), failure: 0, outputs: [] }]; + addKeywordNodes(nodes, keywords); + addKeywordFailureLinks(nodes); + return nodes; +} + +function findContainedKeywords(text: string, keywords: Iterable): Set { + const nodes = buildKeywordNodes(keywords); + const matches = new Set(); + 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 }; @@ -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); }