From 8f11017b469a58d2b0742616f8505d6beca0b6e9 Mon Sep 17 00:00:00 2001 From: Srinivas Annam Date: Tue, 23 Jun 2026 17:08:14 -0700 Subject: [PATCH 1/2] feat(score): validity-aware phone completeness credit (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumb libphonenumber isValid() from extraction to the anonymous scorer so a parsed-but-invalid phone earns half completeness credit instead of full. Plumbing changes (score.ts cannot import phone.ts — entry-chunk budget): - contact.ts: add phoneIsValid? to ContactExtractionResult and ContactScanResult; scan() sets it from phoneResult.isValid; extractContact() carries it from whichever scan (primary/fallback) supplied the phone. - types.ts: add phoneIsValid? to HeuristicParsedResume with doc comment. - openresume.ts: spread phoneIsValid into parsed when phone is present. - regex-fallback.ts: set parsed.phoneIsValid = phoneResult.isValid in the Tier 1.5 phone block. Scoring changes (score.ts): - Add phoneIsValid? to AnonymousAtsScoreInput.parsed. - Add PHONE_INVALID_CREDIT = 0.5 constant. - Split phone out of the generic contact-field loop: valid/unknown → full credit; phoneIsValid===false → passed:false, credit:0.5 (still surfaces in missing); absent/below floor → zero credit. - Bump ATS_SCORE_ALGO_VERSION 1.3 → 1.4; add changelog entry. Tests: - Four new score.test.ts cases covering all three tiers (valid, invalid half-credit, absent, below-confidence-floor). - 25 corpus expected.json re-baked: only algoVersion and the new phoneIsValid key in parsedKeys changed; no completeness scores shifted (all fixtures use valid 555-0100..0199 numbers per PII policy). Deferred edge: stale phoneIsValid when user edits phone inline (accepted; authed scorer stays presence-only, unaffected). Closes #70 --- src/lib/heuristics/extract/contact.ts | 5 ++ src/lib/heuristics/openresume.ts | 3 + src/lib/heuristics/regex-fallback.ts | 1 + src/lib/heuristics/types.ts | 4 ++ src/lib/score/score.test.ts | 55 +++++++++++++++++++ src/lib/score/score.ts | 37 +++++++++++-- ...skia-proxy-additional-skills.expected.json | 3 +- ...ogle-docs-skia-proxy-classic.expected.json | 3 +- ...ogle-docs-skia-proxy-minimal.expected.json | 3 +- ...ia-proxy-nonstandard-headers.expected.json | 3 +- ...e-docs-skia-proxy-two-column.expected.json | 3 +- .../pdfs/latex/awesome-cv-cv.expected.json | 3 +- .../latex/awesome-cv-resume.expected.json | 3 +- .../latex/deedy-resume-macfonts.expected.json | 3 +- .../deedy-resume-openfonts.expected.json | 3 +- ...er-as-name-functional-resume.expected.json | 3 +- .../chromium-asymmetric-sidebar.expected.json | 3 +- ...qualified-experience-headers.expected.json | 3 +- .../chromium-two-column-sidebar.expected.json | 3 +- .../name-set-apart-tagline.expected.json | 3 +- .../openresume-react-pdf.expected.json | 3 +- .../single-word-name-mononym.expected.json | 3 +- ...rojects-activities-singlecol.expected.json | 3 +- ...-column-achievements-sidebar.expected.json | 3 +- .../weasyprint-cairo-classic.expected.json | 3 +- .../weasyprint-cairo-minimal.expected.json | 3 +- ...nt-cairo-nonstandard-headers.expected.json | 3 +- .../weasyprint-cairo-two-column.expected.json | 3 +- ...nchal-sharma-bulleted-skills.expected.json | 3 +- .../word/chanchal-sharma-sample.expected.json | 3 +- ...enresume-laverne-word-quartz.expected.json | 2 +- 31 files changed, 148 insertions(+), 31 deletions(-) diff --git a/src/lib/heuristics/extract/contact.ts b/src/lib/heuristics/extract/contact.ts index 0aa0e391..53410256 100644 --- a/src/lib/heuristics/extract/contact.ts +++ b/src/lib/heuristics/extract/contact.ts @@ -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; @@ -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), @@ -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), diff --git a/src/lib/heuristics/openresume.ts b/src/lib/heuristics/openresume.ts index 276f08c4..219a434e 100644 --- a/src/lib/heuristics/openresume.ts +++ b/src/lib/heuristics/openresume.ts @@ -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 } : {}), diff --git a/src/lib/heuristics/regex-fallback.ts b/src/lib/heuristics/regex-fallback.ts index 0c9ee216..babbfd93 100644 --- a/src/lib/heuristics/regex-fallback.ts +++ b/src/lib/heuristics/regex-fallback.ts @@ -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"); } diff --git a/src/lib/heuristics/types.ts b/src/lib/heuristics/types.ts index f9d317f3..d4683a77 100644 --- a/src/lib/heuristics/types.ts +++ b/src/lib/heuristics/types.ts @@ -123,6 +123,10 @@ export type HeuristicParsedResume = Partial & { 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. */ diff --git a/src/lib/score/score.test.ts b/src/lib/score/score.test.ts index 835d3d10..26ad58a8 100644 --- a/src/lib/score/score.test.ts +++ b/src/lib/score/score.test.ts @@ -666,4 +666,59 @@ 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 less than or equal to a fully valid phone + expect(invalidResult.completeness.score).toBeLessThanOrEqual(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"); + }); + }); }); diff --git a/src/lib/score/score.ts b/src/lib/score/score.ts index 78fcc7ef..c7e43e25 100644 --- a/src/lib/score/score.ts +++ b/src/lib/score/score.ts @@ -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 ──────────────────────────────────────────────────── // @@ -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; @@ -565,6 +573,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 @@ -691,11 +701,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 ?? []; diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-additional-skills.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-additional-skills.expected.json index aeb90fcc..1310e809 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-additional-skills.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-additional-skills.expected.json @@ -19,6 +19,7 @@ "given_name", "location", "phone", + "phoneIsValid", "projects", "skills", "website_url" @@ -66,6 +67,6 @@ "scanned": false }, "bulletCount": 0, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json index e7fa5d84..9d2dbb84 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json @@ -20,6 +20,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "skills", "summary", "website_url" @@ -64,6 +65,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-minimal.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-minimal.expected.json index 851150a1..98c2b296 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-minimal.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-minimal.expected.json @@ -19,6 +19,7 @@ "given_name", "location", "phone", + "phoneIsValid", "skills", "website_url" ], @@ -65,6 +66,6 @@ "scanned": false }, "bulletCount": 7, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-nonstandard-headers.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-nonstandard-headers.expected.json index 02058156..87be364c 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-nonstandard-headers.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-nonstandard-headers.expected.json @@ -18,6 +18,7 @@ "full_name", "given_name", "phone", + "phoneIsValid", "projects", "skills", "summary", @@ -66,6 +67,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json index 46e19ba6..d67e0636 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json @@ -24,6 +24,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "projects", "skills", "website_url" @@ -72,6 +73,6 @@ "scanned": false }, "bulletCount": 13, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json b/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json index dfaea8ef..e07a8941 100644 --- a/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json +++ b/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json @@ -22,6 +22,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "skills", "website_url" ], @@ -67,6 +68,6 @@ "scanned": false }, "bulletCount": 52, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json b/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json index 91a34188..b5bfa520 100644 --- a/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json +++ b/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json @@ -22,6 +22,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "summary", "website_url" ], @@ -67,6 +68,6 @@ "scanned": false }, "bulletCount": 30, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json b/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json index 56e23e50..13d31b6a 100644 --- a/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json +++ b/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json @@ -24,6 +24,7 @@ "heuristic_achievements", "linkedin_url", "phone", + "phoneIsValid", "skills", "website_url" ], @@ -72,6 +73,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json b/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json index 56e23e50..13d31b6a 100644 --- a/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json +++ b/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json @@ -24,6 +24,7 @@ "heuristic_achievements", "linkedin_url", "phone", + "phoneIsValid", "skills", "website_url" ], @@ -72,6 +73,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json b/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json index 34d8a7be..ae48dbc3 100644 --- a/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json +++ b/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json @@ -20,6 +20,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "summary", "website_url" ], @@ -65,6 +66,6 @@ "scanned": false }, "bulletCount": 5, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/chromium-asymmetric-sidebar.expected.json b/tests/fixtures/pdfs/unknown/chromium-asymmetric-sidebar.expected.json index 96245c45..9fb73f88 100644 --- a/tests/fixtures/pdfs/unknown/chromium-asymmetric-sidebar.expected.json +++ b/tests/fixtures/pdfs/unknown/chromium-asymmetric-sidebar.expected.json @@ -21,6 +21,7 @@ "given_name", "location", "phone", + "phoneIsValid", "skills", "summary", "website_url" @@ -69,6 +70,6 @@ "scanned": false }, "bulletCount": 11, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/chromium-qualified-experience-headers.expected.json b/tests/fixtures/pdfs/unknown/chromium-qualified-experience-headers.expected.json index 217bb47d..e4c7692a 100644 --- a/tests/fixtures/pdfs/unknown/chromium-qualified-experience-headers.expected.json +++ b/tests/fixtures/pdfs/unknown/chromium-qualified-experience-headers.expected.json @@ -20,6 +20,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "skills", "summary", "website_url" @@ -64,6 +65,6 @@ "scanned": false }, "bulletCount": 0, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json b/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json index 41025e3d..6dbc3af2 100644 --- a/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json +++ b/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json @@ -22,6 +22,7 @@ "heuristic_achievements", "location", "phone", + "phoneIsValid", "projects", "skills", "summary", @@ -71,6 +72,6 @@ "scanned": false }, "bulletCount": 27, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/name-set-apart-tagline.expected.json b/tests/fixtures/pdfs/unknown/name-set-apart-tagline.expected.json index 6c1b92ad..ea7b1b10 100644 --- a/tests/fixtures/pdfs/unknown/name-set-apart-tagline.expected.json +++ b/tests/fixtures/pdfs/unknown/name-set-apart-tagline.expected.json @@ -19,6 +19,7 @@ "given_name", "location", "phone", + "phoneIsValid", "summary", "website_url" ], @@ -65,6 +66,6 @@ "scanned": false }, "bulletCount": 4, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json b/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json index 60a74cb2..d21aede3 100644 --- a/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json +++ b/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json @@ -20,6 +20,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "skills", "website_url" ], @@ -65,6 +66,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/single-word-name-mononym.expected.json b/tests/fixtures/pdfs/unknown/single-word-name-mononym.expected.json index 3d889828..494e8d81 100644 --- a/tests/fixtures/pdfs/unknown/single-word-name-mononym.expected.json +++ b/tests/fixtures/pdfs/unknown/single-word-name-mononym.expected.json @@ -19,6 +19,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "skills", "summary", "website_url" @@ -63,6 +64,6 @@ "scanned": false }, "bulletCount": 0, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json b/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json index 6b1b46df..9779ca8e 100644 --- a/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json +++ b/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json @@ -20,6 +20,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "projects", "skills", "website_url" @@ -66,6 +67,6 @@ "scanned": false }, "bulletCount": 19, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json b/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json index 60995068..845b7552 100644 --- a/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json +++ b/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json @@ -21,6 +21,7 @@ "heuristic_achievements", "location", "phone", + "phoneIsValid", "skills", "summary", "website_url" @@ -70,6 +71,6 @@ "scanned": false }, "bulletCount": 16, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json index 2965b9bf..f9f917ee 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json @@ -20,6 +20,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "skills", "summary", "website_url" @@ -64,6 +65,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-minimal.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-minimal.expected.json index 851150a1..98c2b296 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-minimal.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-minimal.expected.json @@ -19,6 +19,7 @@ "given_name", "location", "phone", + "phoneIsValid", "skills", "website_url" ], @@ -65,6 +66,6 @@ "scanned": false }, "bulletCount": 7, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-nonstandard-headers.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-nonstandard-headers.expected.json index 02058156..87be364c 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-nonstandard-headers.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-nonstandard-headers.expected.json @@ -18,6 +18,7 @@ "full_name", "given_name", "phone", + "phoneIsValid", "projects", "skills", "summary", @@ -66,6 +67,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json index 75ca8d51..ad201492 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json @@ -24,6 +24,7 @@ "linkedin_url", "location", "phone", + "phoneIsValid", "projects", "skills", "website_url" @@ -72,6 +73,6 @@ "scanned": false }, "bulletCount": 13, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/word/chanchal-sharma-bulleted-skills.expected.json b/tests/fixtures/pdfs/word/chanchal-sharma-bulleted-skills.expected.json index 8bbb0f2a..14ac06a7 100644 --- a/tests/fixtures/pdfs/word/chanchal-sharma-bulleted-skills.expected.json +++ b/tests/fixtures/pdfs/word/chanchal-sharma-bulleted-skills.expected.json @@ -19,6 +19,7 @@ "given_name", "location", "phone", + "phoneIsValid", "skills", "website_url" ], @@ -65,6 +66,6 @@ "scanned": false }, "bulletCount": 0, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/word/chanchal-sharma-sample.expected.json b/tests/fixtures/pdfs/word/chanchal-sharma-sample.expected.json index 5e66a3cb..9e11ea0c 100644 --- a/tests/fixtures/pdfs/word/chanchal-sharma-sample.expected.json +++ b/tests/fixtures/pdfs/word/chanchal-sharma-sample.expected.json @@ -19,6 +19,7 @@ "given_name", "location", "phone", + "phoneIsValid", "skills", "website_url" ], @@ -66,6 +67,6 @@ "scanned": false }, "bulletCount": 0, - "algoVersion": "1.3" + "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/word/openresume-laverne-word-quartz.expected.json b/tests/fixtures/pdfs/word/openresume-laverne-word-quartz.expected.json index 98a98375..a9861e40 100644 --- a/tests/fixtures/pdfs/word/openresume-laverne-word-quartz.expected.json +++ b/tests/fixtures/pdfs/word/openresume-laverne-word-quartz.expected.json @@ -66,6 +66,6 @@ "scanned": false }, "bulletCount": 6, - "algoVersion": "1.3" + "algoVersion": "1.4" } } From 1c7f5c81868a0f30e0f053881f628427a7e13f9a Mon Sep 17 00:00:00 2001 From: Srinivas Annam Date: Wed, 24 Jun 2026 10:11:42 -0700 Subject: [PATCH 2/2] fix(edit): clear stale phoneIsValid on phone override; tighten test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #157 review (rohithgollapalli): - applyOverrides now drops the stale `phoneIsValid` flag whenever the phone field is overridden or cleared. Without this, a user who fixed an invalid phone in the editor kept the old `false`, so the re-grade went on awarding half completeness credit and listing phone as missing — the correction had no effect. Mirrors the empty-string clear path. - score.test.ts half-credit case: toBeLessThanOrEqual -> toBeLessThan so the assertion fails if invalid phones are ever granted full credit (the exact direction the feature would break). - Add apply-overrides regression coverage for both clear paths. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018GAj62FwHV2WHunBTGCR9P --- src/lib/edit/apply-overrides.test.ts | 42 ++++++++++++++++++++++++++++ src/lib/edit/apply-overrides.ts | 5 ++++ src/lib/score/score.test.ts | 6 ++-- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/lib/edit/apply-overrides.test.ts b/src/lib/edit/apply-overrides.test.ts index 4af2e4bb..9da9756c 100644 --- a/src/lib/edit/apply-overrides.test.ts +++ b/src/lib/edit/apply-overrides.test.ts @@ -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( diff --git a/src/lib/edit/apply-overrides.ts b/src/lib/edit/apply-overrides.ts index f91fc2d1..615b81e7 100644 --- a/src/lib/edit/apply-overrides.ts +++ b/src/lib/edit/apply-overrides.ts @@ -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 ────────────────────────────────────────────────── diff --git a/src/lib/score/score.test.ts b/src/lib/score/score.test.ts index 26ad58a8..d07c74e4 100644 --- a/src/lib/score/score.test.ts +++ b/src/lib/score/score.test.ts @@ -707,8 +707,10 @@ describe("computeAnonymousAtsScore", () => { expect(invalidResult.completeness.missing).toContain("phone"); // But it earns more completeness score than absent phone expect(invalidResult.completeness.score).toBeGreaterThan(absentResult.completeness.score); - // And less than or equal to a fully valid phone - expect(invalidResult.completeness.score).toBeLessThanOrEqual(validResult.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", () => {