Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default function App() {
fieldConfidence: state.result.fieldConfidence,
triggers: state.result.triggers,
rawText,
skillsSectionText: state.result.skillsSectionText,
sections: state.result.sections,
});
return { parsed, rawText, score };
}, [
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useResumeAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function useResumeAnalysis(): ResumeAnalysis {
fieldConfidence: result.fieldConfidence,
triggers: result.triggers,
rawText: result.rawText,
skillsSectionText: result.skillsSectionText,
sections: result.sections,
});

trackParseCompleted({
Expand Down
30 changes: 20 additions & 10 deletions src/lib/heuristics/cascade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import type {
CascadeResult,
HeuristicResult,
LayoutProbes,
LayoutTrigger,
PdfLinkAnnotation,
Expand Down Expand Up @@ -177,7 +178,7 @@ export async function runCascade(
// ── Confidence + escalation routing ───────────────────────────────────────

const { confidence, suggestedEscalation } = computeConfidence({
heuristic: { parsed, fieldConfidence },
heuristic: { parsed, fieldConfidence, sections: heuristic.sections },
layout,
rawCharCount: extract.rawCharCount,
extractedCharCount,
Expand All @@ -195,9 +196,7 @@ export async function runCascade(
tiers,
rawText: extract.text,
markdown,
...(heuristic.skillsSectionLines?.length
? { skillsSectionText: heuristic.skillsSectionLines.join("\n") }
: {}),
sections: heuristic.sections,
linkAnnotations: extract.linkAnnotations,
diagnostics: {
rawCharCount: extract.rawCharCount,
Expand Down Expand Up @@ -347,9 +346,18 @@ export async function runCascadeFromMarkdown(
emit(tierEngaged(userType, "1", "initial", start));
const t1Start = Date.now();
const { parseHeuristicFromMarkdown } = await import("./openresume.ts");
const heuristic = haveMarkdown
const heuristic: HeuristicResult = haveMarkdown
? parseHeuristicFromMarkdown(markdown as string, rawText)
: { parsed: emptyParsed(), fieldConfidence: {} };
: {
parsed: emptyParsed(),
fieldConfidence: {},
// No Tier 1 ran (no markdown) — empty section view, inert (#132).
sections: {
byName: new Map(),
accomplishmentSections: [],
source: "regex",
},
};
const t1Duration = Date.now() - t1Start;

let parsed = heuristic.parsed;
Expand Down Expand Up @@ -383,7 +391,7 @@ export async function runCascadeFromMarkdown(
};

const { confidence, suggestedEscalation } = computeConfidence({
heuristic: { parsed, fieldConfidence },
heuristic: { parsed, fieldConfidence, sections: heuristic.sections },
layout,
// We pass rawCharCount=0 so the "low extraction ratio" hard-fail can't
// fire — DOCX text extraction from mammoth is effectively complete by
Expand All @@ -405,9 +413,7 @@ export async function runCascadeFromMarkdown(
tiers,
rawText,
markdown,
...(heuristic.skillsSectionLines?.length
? { skillsSectionText: heuristic.skillsSectionLines.join("\n") }
: {}),
sections: heuristic.sections,
// DOCX cascade has no PDF annotations.
linkAnnotations: [],
diagnostics: {
Expand Down Expand Up @@ -464,6 +470,10 @@ function buildScannedResult(
suggestedEscalation: "ocr",
tiers: ["t0_layout"],
rawText: extract.text,
// Scanned-abandon path: no Tier 1 ran, so there are no detected sections.
// An empty view yields `byName.get("skills") === undefined`, exactly the
// inert behaviour the absent `skillsSectionText` gave here before (#132).
sections: { byName: new Map(), accomplishmentSections: [], source: "regex" },
linkAnnotations,
diagnostics: {
rawCharCount: extract.rawCharCount,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/heuristics/confidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ function mkHeuristic(
skills: 0.7,
...fcOverrides,
},
// computeConfidence does not read sections; an empty view satisfies the
// required field on HeuristicResult without affecting the result (#132).
sections: { byName: new Map(), accomplishmentSections: [], source: "regex" },
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/lib/heuristics/corpus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe("corpus snapshots", () => {
fieldConfidence: cascade.fieldConfidence,
triggers: cascade.triggers,
rawText: cascade.rawText,
skillsSectionText: cascade.skillsSectionText,
sections: cascade.sections,
});

const snapshot = {
Expand Down
154 changes: 86 additions & 68 deletions src/lib/heuristics/extract/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,79 +75,97 @@ function normalizeUrl(raw: string | undefined): string | undefined {
return `https://${trimmed}`;
}

export function extractContact(
profile: PdfSection,
allLines: PdfLine[],
annotations: PdfLinkAnnotation[] = [],
): ContactExtractionResult {
const scan = (lines: PdfLine[], joined: string): ContactExtractionResult => {
const email = firstMatch(EMAIL_RE, joined);
/**
* Scans `lines` for a candidate location string, checking US patterns first
* then international. Returns the first match found, or `undefined`.
*
* `location` is intentionally not subject to the document-wide fallback —
* see the doc-comment on `extractContact` for the reasoning.
*/
function extractLocation(lines: PdfLine[]): string | undefined {
for (const line of lines) {
const us = US_LOCATION_RE.exec(line.text);
if (us) return us[0];
}
for (const line of lines) {
const intl = INTL_LOCATION_RE.exec(line.text);
if (intl && !/@/.test(intl[0])) return intl[0];
}
return undefined;
}

// Extract location BEFORE phone so we can derive the parse region.
// `location` is intentionally not subject to the document-wide fallback —
// see the doc-comment on `extractContact` for the reasoning.
let location: string | undefined;
for (const line of lines) {
const us = US_LOCATION_RE.exec(line.text);
if (us) {
location = us[0];
break;
}
}
if (!location) {
for (const line of lines) {
const intl = INTL_LOCATION_RE.exec(line.text);
if (intl && !/@/.test(intl[0])) {
location = intl[0];
break;
}
}
}
/**
* Collects URLs from `joined` that are not LinkedIn or GitHub links and
* splits them into `portfolio` and `website` buckets.
*
* "Other URLs" are those whose lowercased form does not include `linkedin.com`
* or `github.com`. Portfolio wins if the URL matches a portfolio-indicator
* pattern; the first remaining URL becomes the website candidate.
*/
function extractOtherUrls(joined: string): {
portfolio: string | undefined;
website: string | undefined;
} {
const others = allMatches(URL_RE, joined).filter((u) => {
const lower = u.toLowerCase();
return !lower.includes("linkedin.com") && !lower.includes("github.com");
});
const portfolio = others.find((u) =>
/(portfolio|\.me\b|\.io\b|\.dev\b|behance|dribbble|medium)/i.test(u),
);
const websiteCandidates = others.filter((u) => u !== portfolio);
return { portfolio, website: websiteCandidates[0] };
}

// Derive the phone parse region from the extracted location; fall back to
// "US" when the location is absent or the country is not in our mapping.
const phoneRegion = regionFromLocation(location) ?? "US";
const phoneResult = findFirstPhone(joined, phoneRegion);
const phone = phoneResult?.formatted;
// LinkedIn profile URLs are usually `/in/<handle>` (LINKEDIN_RE), but some
// resumes link a bare vanity host (`linkedin.com/<handle>`). Fall back to
// any linkedin.com URL that is a profile (not /company, /jobs, … sections)
// so a hyperlinked "LinkedIn" anchor resolves regardless of the path shape.
const linkedin =
firstMatch(LINKEDIN_RE, joined) ??
allMatches(URL_RE, joined).find(isLinkedinProfileUrl);
const github = firstMatch(GITHUB_RE, joined);
function scan(lines: PdfLine[], joined: string): ContactExtractionResult {
const email = firstMatch(EMAIL_RE, joined);

// Other URLs that aren't linkedin/github → portfolio/website bucket.
const others = allMatches(URL_RE, joined).filter((u) => {
const lower = u.toLowerCase();
return !lower.includes("linkedin.com") && !lower.includes("github.com");
});
const portfolio = others.find((u) =>
/(portfolio|\.me\b|\.io\b|\.dev\b|behance|dribbble|medium)/i.test(u),
);
const websiteCandidates = others.filter((u) => u !== portfolio);
const website = websiteCandidates[0];
// Extract location BEFORE phone so we can derive the parse region.
const location = extractLocation(lines);

// Derive the phone parse region from the extracted location; fall back to
// "US" when the location is absent or the country is not in our mapping.
const phoneRegion = regionFromLocation(location) ?? "US";
const phoneResult = findFirstPhone(joined, phoneRegion);
const phone = phoneResult?.formatted;

// LinkedIn profile URLs are usually `/in/<handle>` (LINKEDIN_RE), but some
// resumes link a bare vanity host (`linkedin.com/<handle>`). Fall back to
// any linkedin.com URL that is a profile (not /company, /jobs, … sections)
// so a hyperlinked "LinkedIn" anchor resolves regardless of the path shape.
const linkedin =
firstMatch(LINKEDIN_RE, joined) ??
allMatches(URL_RE, joined).find(isLinkedinProfileUrl);
const github = firstMatch(GITHUB_RE, joined);

return {
email,
phone,
linkedin_url: normalizeUrl(linkedin),
github_url: normalizeUrl(github),
portfolio_url: normalizeUrl(portfolio),
website_url: normalizeUrl(website),
location,
confidence: {
email: email ? 0.98 : 0,
phone: phone ? 0.85 : 0,
linkedin_url: linkedin ? 0.95 : 0,
github_url: github ? 0.95 : 0,
portfolio_url: portfolio ? 0.6 : 0,
website_url: website ? 0.55 : 0,
location: location ? 0.75 : 0,
},
};
// Other URLs that aren't linkedin/github → portfolio/website bucket.
const { portfolio, website } = extractOtherUrls(joined);

return {
email,
phone,
linkedin_url: normalizeUrl(linkedin),
github_url: normalizeUrl(github),
portfolio_url: normalizeUrl(portfolio),
website_url: normalizeUrl(website),
location,
confidence: {
email: email ? 0.98 : 0,
phone: phone ? 0.85 : 0,
linkedin_url: linkedin ? 0.95 : 0,
github_url: github ? 0.95 : 0,
portfolio_url: portfolio ? 0.6 : 0,
website_url: website ? 0.55 : 0,
location: location ? 0.75 : 0,
},
};
}

export function extractContact(
profile: PdfSection,
allLines: PdfLine[],
annotations: PdfLinkAnnotation[] = [],
): ContactExtractionResult {

const profileText = profile.lines.map((l) => l.text).join(" ");
const primary = scan(profile.lines, profileText);
Expand Down
38 changes: 24 additions & 14 deletions src/lib/heuristics/extract/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ function splitColumnCells(line: { text: string; items: PdfTextItem[] }): string[
.filter((t) => t.length > 0);
}

/**
* Tokenizes a single column cell into valid skill tokens and adds them to
* `out`. Drops the cell entirely when it looks like a contact/profile link —
* this must happen before `SKILL_SPLIT_RE` (which splits on `/`) would shred
* the URL and leave its path segment as a spurious token.
*/
function tokenizeCell(cell: string, out: Set<string>): void {
const clean = stripBullet(cell).replace(/^[A-Z][A-Za-z ]+:\s*/, "");
// A whole cell that is a profile link ("github.com/janesmith") must be
// dropped before SKILL_SPLIT_RE — which splits on "/" (for "HTML/CSS") —
// shreds the URL and leaves its path segment ("janesmith") as a token.
if (looksLikeContactLink(clean)) return;
for (const raw of clean.split(SKILL_SPLIT_RE)) {
// Strip trailing sentence punctuation that can appear at line-end (e.g.
// "Python, JavaScript, Git, SQL, Linux, AWS." → the period is a list
// terminator, not part of the skill name).
const tok = raw.trim().replace(/[.!?,;]+$/, "");
if (isSkillToken(tok)) {
out.add(tok);
}
}
}

export function extractSkills(
skills: PdfSection | undefined,
): { value: string[]; confidence: number } {
Expand All @@ -103,20 +126,7 @@ export function extractSkills(
const tokens = new Set<string>();
for (const line of skills.lines) {
for (const cell of splitColumnCells(line)) {
const clean = stripBullet(cell).replace(/^[A-Z][A-Za-z ]+:\s*/, "");
// A whole cell that is a profile link ("github.com/janesmith") must be
// dropped before SKILL_SPLIT_RE — which splits on "/" (for "HTML/CSS") —
// shreds the URL and leaves its path segment ("janesmith") as a token.
if (looksLikeContactLink(clean)) continue;
for (const raw of clean.split(SKILL_SPLIT_RE)) {
// Strip trailing sentence punctuation that can appear at line-end (e.g.
// "Python, JavaScript, Git, SQL, Linux, AWS." → the period is a list
// terminator, not part of the skill name).
const tok = raw.trim().replace(/[.!?,;]+$/, "");
if (isSkillToken(tok)) {
tokens.add(tok);
}
}
tokenizeCell(cell, tokens);
}
}
const value = [...tokens];
Expand Down
9 changes: 2 additions & 7 deletions src/lib/heuristics/openresume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
groupIntoLines,
splitIntoSections,
splitIntoSectionsWithMarkdown,
toSectionedResume,
type PdfLine,
type PdfSection,
} from "./sections.ts";
Expand Down Expand Up @@ -291,17 +292,11 @@ function buildHeuristicResult(
achievements: achievements.confidence,
};

const skillsSectionLines = skillsSection?.lines
.map((l) => l.text.trim())
.filter((t) => t.length > 0);

return {
parsed,
fieldConfidence,
sectionSource,
...(skillsSectionLines && skillsSectionLines.length > 0
? { skillsSectionLines }
: {}),
sections: toSectionedResume(sections, sectionSource),
};
}

Expand Down
Loading
Loading