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
42 changes: 42 additions & 0 deletions src/lib/edit/apply-overrides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,48 @@ describe("applyOverrides", () => {
expect(out.full_name).toBeUndefined();
});

it("clears a stale phoneIsValid flag when the phone is overridden (#70 review)", () => {
const parsed: HeuristicParsedResume = {
...baseParsed(),
phone: "555-invalid",
phoneIsValid: false,
};
// User fixes the number → the old `false` must not survive, else the
// scorer keeps awarding half credit on the corrected phone.
const { parsed: out } = applyOverrides(
parsed,
"raw",
makeSections(),
{ phone: "(312) 555-0123" },
{},
{},
[],
);
expect(out.phone).toBe("(312) 555-0123");
expect(out.phoneIsValid).toBeUndefined();
// Original untouched.
expect(parsed.phoneIsValid).toBe(false);
});

it("clears a stale phoneIsValid flag when the phone is cleared (#70 review)", () => {
const parsed: HeuristicParsedResume = {
...baseParsed(),
phone: "555-invalid",
phoneIsValid: false,
};
const { parsed: out } = applyOverrides(
parsed,
"raw",
makeSections(),
{ phone: "" },
{},
{},
[],
);
expect(out.phone).toBeUndefined();
expect(out.phoneIsValid).toBeUndefined();
});

it("replaces experience header fields by index", () => {
const parsed = baseParsed();
const { parsed: out } = applyOverrides(
Expand Down
5 changes: 5 additions & 0 deletions src/lib/edit/apply-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ export function applyOverrides(
} else {
nextParsed[key] = ov;
}
// The original `phoneIsValid` flag is now stale — it described the parsed
// phone, not the user-supplied one. Drop it so the scorer re-grades the
// edited number as validity-unknown (backward-compatible full credit)
// instead of carrying the old false → permanent half credit. (#70 review)
if (key === "phone") delete nextParsed.phoneIsValid;
}

// ── Experience headers ──────────────────────────────────────────────────
Expand Down
5 changes: 5 additions & 0 deletions src/lib/heuristics/extract/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { firstMatch, allMatches } from "./shared.ts";
export interface ContactExtractionResult {
email?: string;
phone?: string;
/** libphonenumber isValid() result for the extracted phone. Undefined when
* no phone was found or the caller did not supply validity signal. */
phoneIsValid?: boolean;
linkedin_url?: string;
github_url?: string;
portfolio_url?: string;
Expand Down Expand Up @@ -233,6 +236,7 @@ function scan(lines: PdfLine[], joined: string): ContactScanResult {
return {
email,
phone,
...(phone !== undefined ? { phoneIsValid: phoneResult?.isValid } : {}),
linkedin_url: normalizeUrl(linkedin),
github_url: normalizeUrl(github),
portfolio_url: normalizeUrl(portfolio),
Expand Down Expand Up @@ -387,6 +391,7 @@ export function extractContact(
return {
email: primary.email ?? fallback.email,
phone: primary.phone ?? fallback.phone,
phoneIsValid: primary.phone ? primary.phoneIsValid : fallback.phoneIsValid,
linkedin_url: normalizeUrl(linkedin.value),
github_url: normalizeUrl(github.value),
portfolio_url: normalizeUrl(portfolio.value),
Expand Down
3 changes: 3 additions & 0 deletions src/lib/heuristics/openresume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ function buildHeuristicResult(
...splitGivenFamilyName(name.value),
...(contact.email ? { email: contact.email } : {}),
...(contact.phone ? { phone: contact.phone } : {}),
...(contact.phone && contact.phoneIsValid !== undefined
? { phoneIsValid: contact.phoneIsValid }
: {}),
...(contact.location ? { location: contact.location } : {}),
...(contact.linkedin_url ? { linkedin_url: contact.linkedin_url } : {}),
...(contact.github_url ? { github_url: contact.github_url } : {}),
Expand Down
1 change: 1 addition & 0 deletions src/lib/heuristics/regex-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function runRegexFallback(
const phoneResult = findFirstPhone(rawText, region);
if (phoneResult) {
parsed.phone = phoneResult.formatted;
parsed.phoneIsValid = phoneResult.isValid;
fieldConfidence.phone = 0.6;
fieldsFilled.push("phone");
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/heuristics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export type HeuristicParsedResume = Partial<ParsedResume> & {
skills: string[];
experience: ResumeExperience[];
education: ResumeEducation[];
/** libphonenumber isValid() result for the extracted phone — plumbed from
* the extraction layer so the scorer can apply validity-aware credit without
* importing libphonenumber-js (which would break the entry-chunk budget). */
phoneIsValid?: boolean;
};

/** Confidence per extracted field (0..1). Fields not in the map default to 0. */
Expand Down
57 changes: 57 additions & 0 deletions src/lib/score/score.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,4 +666,61 @@ describe("computeAnonymousAtsScore", () => {
expect(result.completeness.redactedDates).toBeFalsy();
});
});

describe("validity-aware phone completeness (#70)", () => {
it("awards full credit for a phone that is present, confident, and valid", () => {
const result = computeAnonymousAtsScore(
makeAnonInput({
parsed: { ...makeAnonInput().parsed, phone: "(312) 555-0123", phoneIsValid: true },
fieldConfidence: { ...makeAnonInput().fieldConfidence, phone: 0.9 },
}),
);
expect(result.completeness.missing).not.toContain("phone");
});

it("awards full credit when phoneIsValid is absent (backward-compatible)", () => {
const result = computeAnonymousAtsScore(
makeAnonInput({
parsed: { ...makeAnonInput().parsed, phone: "(312) 555-0123", phoneIsValid: undefined },
fieldConfidence: { ...makeAnonInput().fieldConfidence, phone: 0.9 },
}),
);
expect(result.completeness.missing).not.toContain("phone");
});

it("awards half credit for a phone that is present but invalid (phoneIsValid===false)", () => {
const withPhone = makeAnonInput();
const withoutPhone = makeAnonInput({
parsed: { ...makeAnonInput().parsed, phone: undefined },
fieldConfidence: { ...makeAnonInput().fieldConfidence, phone: 0 },
});
const invalidPhone = makeAnonInput({
parsed: { ...makeAnonInput().parsed, phone: "555-invalid", phoneIsValid: false },
fieldConfidence: { ...makeAnonInput().fieldConfidence, phone: 0.85 },
});
// Half credit: invalid phone scores above absent (0) but below valid (1)
expect(invalidPhone.parsed.phoneIsValid).toBe(false);
const invalidResult = computeAnonymousAtsScore(invalidPhone);
const absentResult = computeAnonymousAtsScore(withoutPhone);
const validResult = computeAnonymousAtsScore(withPhone);
// Invalid phone is still in missing (passed: false)
expect(invalidResult.completeness.missing).toContain("phone");
// But it earns more completeness score than absent phone
expect(invalidResult.completeness.score).toBeGreaterThan(absentResult.completeness.score);
// And strictly less than a fully valid phone — a <= bound would stay
// green even if invalid phones were granted full credit (the exact way
// the feature would break). (#70 review)
expect(invalidResult.completeness.score).toBeLessThan(validResult.completeness.score);
});

it("does not credit phone when confidence is below the floor", () => {
const result = computeAnonymousAtsScore(
makeAnonInput({
parsed: { ...makeAnonInput().parsed, phone: "(312) 555-0123", phoneIsValid: true },
fieldConfidence: { ...makeAnonInput().fieldConfidence, phone: 0.3 },
}),
);
expect(result.completeness.missing).toContain("phone");
});
});
});
37 changes: 31 additions & 6 deletions src/lib/score/score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,15 @@ export const WEIGHTS = {
* section), so the retired "pool everything, subtract skills" side-channel is
* gone. The experience-completeness check now asks "is there a non-empty
* experience section?" rather than "did we see any bullet anywhere?".
* - 1.4 (2026-06-23): validity-aware phone completeness credit (#70) — a
* parsed-but-invalid phone (libphonenumber isValid===false) earns half
* completeness credit instead of full; valid phones unchanged, absent
* unchanged.
*/
// Internal-only: surfaced to the UI via the `algoVersion` score field, not
// imported by name anywhere — so it stays unexported to satisfy the dead-code
// gate (fallow flags exported symbols with no external consumer).
const ATS_SCORE_ALGO_VERSION = "1.3";
const ATS_SCORE_ALGO_VERSION = "1.4";

// ── Shared scoring rules ────────────────────────────────────────────────────
//
Expand Down Expand Up @@ -523,6 +527,10 @@ export interface AnonymousAtsScoreInput {
full_name?: string;
email?: string;
phone?: string;
/** libphonenumber isValid() result plumbed from extraction (#70). When
* present and false, the phone earns half completeness credit. When absent
* or undefined with a present phone, backward-compatible full credit. */
phoneIsValid?: boolean;
location?: string;
linkedin_url?: string;
summary?: string;
Expand Down Expand Up @@ -568,6 +576,8 @@ export interface AnonymousAtsScoreInput {
}

const ANON_CONTACT_CONFIDENCE_FLOOR = 0.5;
/** Completeness credit for a phone that parsed but failed libphonenumber isValid(). */
const PHONE_INVALID_CREDIT = 0.5;
const ANON_MIN_BULLETS_TO_GRADE = 3;
/** Word-count floor for section bullet extraction. Set to 1 so the displayed
* bullet count matches what the user can see in the PDF — every line that
Expand Down Expand Up @@ -726,11 +736,26 @@ export function computeAnonymousAtsScore(
for (const f of ANON_CONTACT_FIELDS) {
const value = input.parsed[f.key];
const conf = input.fieldConfidence[f.key] ?? 0;
completenessChecks.push({
key: `contact.${f.key}`,
passed: Boolean(value) && conf >= ANON_CONTACT_CONFIDENCE_FLOOR,
label: f.label,
});
const present = Boolean(value) && conf >= ANON_CONTACT_CONFIDENCE_FLOOR;
if (f.key === "phone") {
// Validity-aware phone credit (#70):
// present + valid (or validity unknown) → full credit (passed: true)
// present + explicitly invalid → half credit (passed: false, credit: 0.5)
// absent or below conf floor → zero credit (passed: false)
const phoneInvalid = present && input.parsed.phoneIsValid === false;
completenessChecks.push({
key: `contact.${f.key}`,
passed: present && !phoneInvalid,
label: f.label,
...(phoneInvalid ? { credit: PHONE_INVALID_CREDIT } : {}),
});
} else {
completenessChecks.push({
key: `contact.${f.key}`,
passed: present,
label: f.label,
});
}
}
const expEntries = input.parsed.experience ?? [];
const eduEntries = input.parsed.education ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"given_name",
"location",
"phone",
"phoneIsValid",
"projects",
"skills",
"website_url"
Expand Down Expand Up @@ -66,6 +67,6 @@
"scanned": false
},
"bulletCount": 0,
"algoVersion": "1.3"
"algoVersion": "1.4"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"linkedin_url",
"location",
"phone",
"phoneIsValid",
"skills",
"summary",
"website_url"
Expand Down Expand Up @@ -64,6 +65,6 @@
"scanned": false
},
"bulletCount": 8,
"algoVersion": "1.3"
"algoVersion": "1.4"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"given_name",
"location",
"phone",
"phoneIsValid",
"skills",
"website_url"
],
Expand Down Expand Up @@ -65,6 +66,6 @@
"scanned": false
},
"bulletCount": 7,
"algoVersion": "1.3"
"algoVersion": "1.4"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"full_name",
"given_name",
"phone",
"phoneIsValid",
"projects",
"skills",
"summary",
Expand Down Expand Up @@ -66,6 +67,6 @@
"scanned": false
},
"bulletCount": 8,
"algoVersion": "1.3"
"algoVersion": "1.4"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"linkedin_url",
"location",
"phone",
"phoneIsValid",
"projects",
"skills",
"website_url"
Expand Down Expand Up @@ -72,6 +73,6 @@
"scanned": false
},
"bulletCount": 13,
"algoVersion": "1.3"
"algoVersion": "1.4"
}
}
3 changes: 2 additions & 1 deletion tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"linkedin_url",
"location",
"phone",
"phoneIsValid",
"skills",
"website_url"
],
Expand Down Expand Up @@ -67,6 +68,6 @@
"scanned": false
},
"bulletCount": 52,
"algoVersion": "1.3"
"algoVersion": "1.4"
}
}
3 changes: 2 additions & 1 deletion tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"linkedin_url",
"location",
"phone",
"phoneIsValid",
"summary",
"website_url"
],
Expand Down Expand Up @@ -67,6 +68,6 @@
"scanned": false
},
"bulletCount": 30,
"algoVersion": "1.3"
"algoVersion": "1.4"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"heuristic_achievements",
"linkedin_url",
"phone",
"phoneIsValid",
"skills",
"website_url"
],
Expand Down Expand Up @@ -72,6 +73,6 @@
"scanned": false
},
"bulletCount": 8,
"algoVersion": "1.3"
"algoVersion": "1.4"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"heuristic_achievements",
"linkedin_url",
"phone",
"phoneIsValid",
"skills",
"website_url"
],
Expand Down Expand Up @@ -72,6 +73,6 @@
"scanned": false
},
"bulletCount": 8,
"algoVersion": "1.3"
"algoVersion": "1.4"
}
}
Loading
Loading