-
Notifications
You must be signed in to change notification settings - Fork 4
refactor(heuristics): split extract-fields.ts + segmentation spike (#126, #127) #136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // Copyright 2026 The resumelint Authors | ||
|
|
||
| import type { HeuristicAchievement } from "../../score/types.ts"; | ||
| import type { PdfSection } from "../sections.ts"; | ||
| import { parseEntryBlocks } from "../entry-blocks.ts"; | ||
| import type { EntryBlock } from "../entry-blocks.ts"; | ||
| import { YEAR_RE } from "../regex.ts"; | ||
| import { firstMatch, avgScore } from "./shared.ts"; | ||
| import { liftHeaderLabel } from "./projects.ts"; | ||
|
|
||
| // ── Achievements ────────────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * Extract an Achievements / Accomplishments / Awards / Activities section into | ||
| * `HeuristicAchievement[]`. | ||
| * | ||
| * Thin caller of the shared `parseEntryBlocks` primitive — the SAME extractor | ||
| * shape as `extractProjects`, deliberately not a third bespoke implementation | ||
| * (#96). Achievement items are name-led and often single-line, so we anchor on | ||
| * `"first_line"` (anchoring on a date would drop the common date-less award); | ||
| * `collectBody: true` so any bullets under an item become its description and | ||
| * pool with experience/project bullets in the scorer. | ||
| * | ||
| * Each block becomes one achievement: `headerLines[0]` is the item title (a URL | ||
| * on the header is lifted into `url`), any date the header carried is reduced to | ||
| * a single lead `year` (achievements show a year, not a range), and the bullet | ||
| * body is the description. | ||
| * | ||
| * Honest-by-construction (#96, option (a)): we emit only what a regex parser can | ||
| * truthfully assert — a title, an optional year/url, and a bullet body. We do | ||
| * NOT guess an `AchievementType`; the structured `Achievement[]` is the LLM | ||
| * path's job. | ||
| */ | ||
| export function extractAchievements( | ||
| achievements: PdfSection | undefined, | ||
| ): { value: HeuristicAchievement[]; confidence: number } { | ||
| const blocks = parseEntryBlocks(achievements, { | ||
| anchor: "first_line", | ||
| collectBody: true, | ||
| }); | ||
| if (blocks.length === 0) return { value: [], confidence: 0 }; | ||
| const built = blocks.map(achievementFromBlock); | ||
| return { | ||
| value: built.map((b) => b.entry), | ||
| confidence: avgScore(built.map((b) => b.score)), | ||
| }; | ||
| } | ||
|
|
||
| /** Map one entry block to a `HeuristicAchievement` and its confidence score. | ||
| * Extracted from `extractAchievements` to keep each function below the | ||
| * complexity threshold; mirrors `projectFromBlock`. */ | ||
| function achievementFromBlock(block: EntryBlock): { | ||
| entry: HeuristicAchievement; | ||
| score: number; | ||
| } { | ||
| const { dates } = block; | ||
| const { label: title, url } = liftHeaderLabel(block.headerLines); | ||
|
|
||
| // Reduce any date range the header carried to a single lead year. | ||
| const year = dates.start_date | ||
| ? firstMatch(YEAR_RE, dates.start_date) | ||
| : undefined; | ||
| const description = block.body; | ||
|
|
||
| // Score the entry: a title (0.5) and at least one bullet (0.5). Achievements | ||
| // have no company/title axis and the year is optional, so they don't earn a | ||
| // date weight — a named, bulleted item is a fully-formed entry. | ||
| let score = 0; | ||
| if (title) score += 0.5; | ||
| if (block.bulletCount >= 1) score += 0.5; | ||
|
|
||
| return { | ||
| entry: { | ||
| title, | ||
| ...(year ? { year } : {}), | ||
| ...(url ? { url } : {}), | ||
| ...(description ? { description } : {}), | ||
| }, | ||
| score: Math.min(score, 1), | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // Copyright 2026 The resumelint Authors | ||
|
|
||
| import type { PdfLine, PdfSection } from "../sections.ts"; | ||
| import type { PdfLinkAnnotation } from "../types.ts"; | ||
| import { | ||
| EMAIL_RE, | ||
| LINKEDIN_RE, | ||
| GITHUB_RE, | ||
| URL_RE, | ||
| US_LOCATION_RE, | ||
| INTL_LOCATION_RE, | ||
| } from "../regex.ts"; | ||
| import { findFirstPhone, regionFromLocation } from "../phone.ts"; | ||
| import { firstMatch, allMatches } from "./shared.ts"; | ||
|
|
||
| // ── Contact (email, phone, urls, location) ────────────────────────────────── | ||
|
|
||
| interface ContactExtractionResult { | ||
| email?: string; | ||
| phone?: string; | ||
| linkedin_url?: string; | ||
| github_url?: string; | ||
| portfolio_url?: string; | ||
| website_url?: string; | ||
| location?: string; | ||
| /** Per-field confidence for the cascade's field-confidence map. */ | ||
| confidence: { | ||
| email: number; | ||
| phone: number; | ||
| linkedin_url: number; | ||
| github_url: number; | ||
| portfolio_url: number; | ||
| website_url: number; | ||
| location: number; | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * 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 | ||
| * profile, mirroring GitHub's "any `github.com/<user>`" rule. */ | ||
| const LINKEDIN_NONPROFILE_RE = | ||
| /linkedin\.com\/(company|jobs|feed|school|learning|pulse|posts|groups|showcase|games|events|help|legal|search|signup|login|home)\b/i; | ||
|
|
||
| /** True when `u` is a LinkedIn personal-profile URL. Accepts the canonical | ||
| * `linkedin.com/in/<handle>` and `linkedin.com/pub/...` as well as a vanity | ||
| * `linkedin.com/<handle>` that omits `/in/` — the latter is what makes a | ||
| * hyperlinked "LinkedIn" anchor resolve even when the target drops `/in/`. */ | ||
| function isLinkedinProfileUrl(u: string): boolean { | ||
| return /linkedin\.com\/[A-Za-z0-9]/i.test(u) && !LINKEDIN_NONPROFILE_RE.test(u); | ||
| } | ||
|
|
||
| function normalizeUrl(raw: string | undefined): string | undefined { | ||
| if (!raw) return undefined; | ||
| const trimmed = raw.replace(/[,;.)]$/, "").trim(); | ||
| if (/^https?:\/\//i.test(trimmed)) return trimmed; | ||
| 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); | ||
|
|
||
| // 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; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 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); | ||
|
|
||
| // 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]; | ||
|
|
||
| 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, | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| const profileText = profile.lines.map((l) => l.text).join(" "); | ||
| const primary = scan(profile.lines, profileText); | ||
|
|
||
| // Fallback: sometimes contact info is scattered outside the profile header | ||
| // (e.g. footer). Fill any missing field from the full-document scan — | ||
| // EXCEPT location, which is bug-prone outside the profile band. | ||
| const fullText = allLines.map((l) => l.text).join(" "); | ||
| const fallback = scan(allLines, fullText); | ||
|
|
||
| // Annotation fallback: URLs hyperlinked behind a visible word | ||
| // ("LinkedIn", "GitHub") only show up here. LinkedIn/GitHub are matched | ||
| // document-wide (see `anywhereOnDoc` below); only the looser-but-still-bounded | ||
| // portfolio/website lookup keeps a Y-band. | ||
| // Portfolio/website use a header-region band — some design templates | ||
| // place the portfolio link in a sidebar or under the name block. "Top | ||
| // third of page 1" approximated with a fixed PDF-points cutoff that | ||
| // works for both Letter (792pt) and A4 (842pt). | ||
| const inHeaderRegion = (ann: PdfLinkAnnotation) => | ||
| ann.page === 1 && ann.yTop < 280; | ||
| // LinkedIn / GitHub identity links are commonly hyperlinked behind an icon | ||
| // placed in a footer or a "Links"/"Contact" block below a later heading | ||
| // (e.g. after Skills), so the profile-band filter dropped them. The | ||
| // `linkedin.com/in/<user>` / `github.com/<user>` predicates are specific | ||
| // enough that a document-wide match is safe — a stray profile link in a | ||
| // project description is rare and low-stakes vs. silently losing the link. | ||
| const anywhereOnDoc = () => true; | ||
|
|
||
| const findAnnotationUrl = ( | ||
| predicate: (url: string) => boolean, | ||
| band: (ann: PdfLinkAnnotation) => boolean, | ||
| ): string | undefined => { | ||
| for (const ann of annotations) { | ||
| if (!band(ann)) continue; | ||
| if (predicate(ann.url)) return ann.url; | ||
| } | ||
| return undefined; | ||
| }; | ||
|
|
||
| const isLinkedinUrl = isLinkedinProfileUrl; | ||
| const isGithubUrl = (u: string) => | ||
| /github\.com\//i.test(u) && !/github\.com\/(orgs|topics|search)/i.test(u); | ||
| const isPortfolioUrl = (u: string) => | ||
| /(portfolio|\.me\b|\.io\b|\.dev\b|behance|dribbble|medium|framer\.website)/i.test( | ||
| u, | ||
| ); | ||
|
|
||
| const pickUrl = <K extends "linkedin_url" | "github_url" | "portfolio_url" | "website_url">( | ||
| key: K, | ||
| annotationPredicate: (url: string) => boolean, | ||
| band: (ann: PdfLinkAnnotation) => boolean, | ||
| ): { value: string | undefined; confidence: number } => { | ||
| if (primary[key]) | ||
| return { value: primary[key], confidence: primary.confidence[key] }; | ||
| if (fallback[key]) | ||
| return { | ||
| value: fallback[key], | ||
| confidence: fallback.confidence[key] * 0.9, | ||
| }; | ||
| const annUrl = findAnnotationUrl(annotationPredicate, band); | ||
| if (annUrl) return { value: annUrl, confidence: 0.95 }; | ||
| return { value: undefined, confidence: 0 }; | ||
| }; | ||
|
|
||
| const linkedin = pickUrl("linkedin_url", isLinkedinUrl, anywhereOnDoc); | ||
| const github = pickUrl("github_url", isGithubUrl, anywhereOnDoc); | ||
| const portfolio = pickUrl( | ||
| "portfolio_url", | ||
| (u) => isPortfolioUrl(u) && !isLinkedinUrl(u) && !isGithubUrl(u), | ||
| inHeaderRegion, | ||
| ); | ||
| // Website is the catch-all: any remaining annotation URL not already | ||
| // claimed by linkedin/github/portfolio. | ||
| const claimedUrls = new Set( | ||
| [linkedin.value, github.value, portfolio.value].filter(Boolean) as string[], | ||
| ); | ||
| const website = pickUrl( | ||
| "website_url", | ||
| (u) => | ||
| !claimedUrls.has(u) && | ||
| !isLinkedinUrl(u) && | ||
| !isGithubUrl(u) && | ||
| !u.startsWith("mailto:") && | ||
| !u.startsWith("tel:"), | ||
| inHeaderRegion, | ||
| ); | ||
|
|
||
| return { | ||
| email: primary.email ?? fallback.email, | ||
| phone: primary.phone ?? fallback.phone, | ||
| linkedin_url: normalizeUrl(linkedin.value), | ||
| github_url: normalizeUrl(github.value), | ||
| portfolio_url: normalizeUrl(portfolio.value), | ||
| website_url: normalizeUrl(website.value), | ||
| // No fallback for location — see comment above. | ||
| location: primary.location, | ||
| confidence: { | ||
| email: Math.max(primary.confidence.email, fallback.confidence.email * 0.8), | ||
| phone: Math.max(primary.confidence.phone, fallback.confidence.phone * 0.8), | ||
| linkedin_url: linkedin.confidence, | ||
| github_url: github.confidence, | ||
| portfolio_url: portfolio.confidence, | ||
| website_url: website.confidence, | ||
| location: primary.confidence.location, | ||
| }, | ||
| }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.