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
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
35 changes: 25 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 All @@ -29,6 +30,7 @@ import type {
TierEngagementReason,
} from "./types.ts";
import { CASCADE_VERSION } from "./types.ts";
import { ACCOMPLISHMENT_SECTION_NAMES } from "./sections.ts";
import { computeConfidence } from "./confidence.ts";

export interface RunCascadeOptions {
Expand Down Expand Up @@ -177,7 +179,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 +197,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 +347,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: ACCOMPLISHMENT_SECTION_NAMES,
source: "regex",
},
};
const t1Duration = Date.now() - t1Start;

let parsed = heuristic.parsed;
Expand Down Expand Up @@ -383,7 +392,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 +414,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 +471,14 @@ 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: ACCOMPLISHMENT_SECTION_NAMES,
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
187 changes: 102 additions & 85 deletions src/lib/heuristics/extract/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,6 @@ interface ContactExtractionResult {
};
}

/**
* Walks the profile section (and optionally the full document as a fallback)
* gathering contact fields. Regex-driven; very high precision.
*
* `location` is intentionally NOT subject to the document-wide fallback —
* a non-header city/state is almost always an employer or school location,
* not the candidate's. Names, emails, phones, and URLs can survive being
* outside the profile (footer placement is common); location cannot.
*
* When PDF Link annotations are available, they're consulted as
* a last-chance signal for `linkedin_url`, `github_url`, `portfolio_url`,
* and `website_url` — these are the URL fields that resumes most often
* hyperlink behind a visible word ("LinkedIn" / "GitHub" / "Portfolio")
* rather than rendering as full text. Annotation hits report 0.95
* confidence, matching the text-hit confidence, because the URL is
* structurally guaranteed by the PDF.
*/
/** LinkedIn paths that are NOT a personal profile — feed, company pages, job
* posts, articles, etc. Everything else under `linkedin.com/<handle>` (the
* `/in/<handle>` canonical form AND bare-vanity hosts) is treated as a
Expand All @@ -75,80 +58,114 @@ 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,
},
};
}

/**
* Walks the profile section (and optionally the full document as a fallback)
* gathering contact fields. Regex-driven; very high precision.
*
* `location` is intentionally NOT subject to the document-wide fallback —
* a non-header city/state is almost always an employer or school location,
* not the candidate's. Names, emails, phones, and URLs can survive being
* outside the profile (footer placement is common); location cannot.
*
* When PDF Link annotations are available, they're consulted as
* a last-chance signal for `linkedin_url`, `github_url`, `portfolio_url`,
* and `website_url` — these are the URL fields that resumes most often
* hyperlink behind a visible word ("LinkedIn" / "GitHub" / "Portfolio")
* rather than rendering as full text. Annotation hits report 0.95
* confidence, matching the text-hit confidence, because the URL is
* structurally guaranteed by the PDF.
*/
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
Loading
Loading