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
25 changes: 1 addition & 24 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@fontsource/poppins": "^5.2.7",
"@mlc-ai/web-llm": "0.2.84",
"jszip": "^3.10.1",
"libphonenumber-js": "^1.13.6",
"mammoth": "^1.12.0",
"pdfjs-dist": "^4.10.38",
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default function App() {
fieldConfidence: state.result.fieldConfidence,
triggers: state.result.triggers,
rawText,
skillsSectionText: state.result.skillsSectionText,
});
return { parsed, rawText, score };
}, [
Expand Down
7 changes: 5 additions & 2 deletions src/components/features/AtsScoreReadout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,12 @@ export function AtsScoreReadout({ score }: AtsScoreReadoutProps) {
const specificityHint = `${score.specificity.metricBullets}/${score.specificity.totalBullets} bullets carry a metric`;
const structureHint = `${score.structure.goodBullets}/${score.structure.totalBullets} bullets within 8–30 words`;
const completenessHint =
score.completeness.missing.length === 0
(score.completeness.missing.length === 0
? "All expected fields present"
: `Missing: ${score.completeness.missing.join(", ")}`;
: `Missing: ${score.completeness.missing.join(", ")}`) +
(score.completeness.redactedDates
? " · Dates appear redacted — use 4-digit years for best results."
: "");

const dimensions: VerdictDimension[] = [
{
Expand Down
9 changes: 5 additions & 4 deletions src/components/features/ContactCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
/**
* ContactCard — displays extracted contact fields as a chip strip.
*
* Detected fields show the value with a success chip; undetected fields
* show a warning chip with a "not detected" label. Always renders all 5
* fields so the reader can spot gaps at a glance.
* Detected fields show the value with a success chip; undetected required
* fields show a warning chip with a "not detected" label so the reader can
* spot gaps at a glance. Optional fields (e.g. GitHub) render only when
* detected — see `buildContactFields`.
*
* Edit mode (#58): when `overrides` and `onFieldChange` are provided, each
* field chip gains an inline EditableField affordance. Edited values replace
Expand Down Expand Up @@ -74,7 +75,7 @@ export function ContactCard({
return (
<Card id="contact" className="scroll-mt-6">
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-content-muted">
Contact — {detectedCount} of 5 detected
Contact — {detectedCount} of {displayFields.length} detected
</h2>
<div className="flex flex-wrap gap-2">
{displayFields.map((field) => {
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useResumeAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function useResumeAnalysis(): ResumeAnalysis {
fieldConfidence: result.fieldConfidence,
triggers: result.triggers,
rawText: result.rawText,
skillsSectionText: result.skillsSectionText,
});

trackParseCompleted({
Expand Down
38 changes: 36 additions & 2 deletions src/lib/contact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function makeCascade(
}

describe("buildContactFields", () => {
it("returns 5 rows in the correct order", () => {
it("returns the 5 required rows (no GitHub) when GitHub is absent", () => {
const fields = buildContactFields(makeCascade());
expect(fields).toHaveLength(5);
expect(fields.map((f) => f.key)).toEqual([
Expand All @@ -37,6 +37,37 @@ describe("buildContactFields", () => {
]);
});

it("includes the GitHub row only when it is confidently detected", () => {
const fields = buildContactFields(
makeCascade(
{ github_url: "https://github.com/jane" },
{ github_url: 0.95 },
),
);
expect(fields.map((f) => f.key)).toEqual([
"full_name",
"email",
"phone",
"linkedin_url",
"github_url",
"location",
]);
const gh = fields.find((f) => f.key === "github_url")!;
expect(gh.gated).toBe(false);
expect(gh.value).toBe("https://github.com/jane");
});

it("omits the GitHub row when present but below the confidence floor", () => {
const fields = buildContactFields(
makeCascade(
{ github_url: "https://github.com/jane" },
{ github_url: CONTACT_DISPLAY_CONFIDENCE_FLOOR - 0.01 },
),
);
expect(fields.some((f) => f.key === "github_url")).toBe(false);
expect(fields).toHaveLength(5);
});

it("shows a field (gated=false) when value is present and confidence is above the floor", () => {
const fields = buildContactFields(
makeCascade(
Expand Down Expand Up @@ -69,21 +100,23 @@ describe("buildContactFields", () => {
expect(phoneField.value).toBe("");
});

it("shows all five fields when all are present and above the confidence floor", () => {
it("shows all six fields when all are present and above the confidence floor", () => {
const fields = buildContactFields(
makeCascade(
{
full_name: "Jane Doe",
email: "jane@example.com",
phone: "555-0100",
linkedin_url: "https://linkedin.com/in/jane",
github_url: "https://github.com/jane",
location: "San Francisco, CA",
},
{
full_name: 0.9,
email: 0.95,
phone: 0.85,
linkedin_url: 0.8,
github_url: 0.8,
location: 0.75,
},
),
Expand All @@ -94,6 +127,7 @@ describe("buildContactFields", () => {
"jane@example.com",
"555-0100",
"https://linkedin.com/in/jane",
"https://github.com/jane",
"San Francisco, CA",
]);
});
Expand Down
60 changes: 36 additions & 24 deletions src/lib/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,51 +24,63 @@ export interface ContactDisplayField {
reason?: "absent" | "low_confidence";
}

const CONTACT_ROWS: readonly { key: keyof typeof FIELD_KEYS; label: string }[] =
[
{ key: "full_name", label: "Name" },
{ key: "email", label: "Email" },
{ key: "phone", label: "Phone" },
{ key: "linkedin_url", label: "LinkedIn" },
{ key: "location", label: "Location" },
];
const CONTACT_ROWS: readonly {
key: keyof typeof FIELD_KEYS;
label: string;
/** Optional rows surface only when actually detected. Not every candidate
* keeps a GitHub profile, so its absence is not a gap — an optional row
* never renders a "not detected" chip nor counts against the detected/total
* ratio. Required rows (the rest) always render so the reader can spot a
* missing email/phone/etc. at a glance. */
optional?: boolean;
}[] = [
{ key: "full_name", label: "Name" },
{ key: "email", label: "Email" },
{ key: "phone", label: "Phone" },
{ key: "linkedin_url", label: "LinkedIn" },
{ key: "github_url", label: "GitHub", optional: true },
{ key: "location", label: "Location" },
];

// TypeScript trick: enumerate the valid keys for indexing `parsed`.
const FIELD_KEYS = {
full_name: true,
email: true,
phone: true,
linkedin_url: true,
github_url: true,
location: true,
} as const;

/**
* Build the ordered contact display rows from a `CascadeResult`.
*
* Always returns exactly 5 rows in the order: Name, Email, Phone, LinkedIn,
* Location. A row is `gated` when its value is absent or its confidence is
* below `CONTACT_DISPLAY_CONFIDENCE_FLOOR`.
* Returns the required rows in order — Name, Email, Phone, LinkedIn, Location —
* each always present (and `gated` when absent / below
* `CONTACT_DISPLAY_CONFIDENCE_FLOOR`). Optional rows (GitHub) are included only
* when confidently detected, so a candidate without a GitHub profile sees no
* "GitHub not detected" gap and no penalty in the detected/total ratio.
*/
export function buildContactFields(
cascade: Pick<CascadeResult, "parsed" | "fieldConfidence">,
): ContactDisplayField[] {
return CONTACT_ROWS.map(({ key, label }) => {
const rows: ContactDisplayField[] = [];
for (const { key, label, optional } of CONTACT_ROWS) {
const raw = cascade.parsed[key as keyof typeof FIELD_KEYS];
const value = typeof raw === "string" ? raw : "";
const conf = cascade.fieldConfidence[key as keyof typeof FIELD_KEYS] ?? 0;
const detected = Boolean(value) && conf >= CONTACT_DISPLAY_CONFIDENCE_FLOOR;

// An optional field is shown only when detected — its absence is not a gap.
if (optional && !detected) continue;

if (!value) {
return { key, label, value: "", gated: true, reason: "absent" as const };
}
if (conf < CONTACT_DISPLAY_CONFIDENCE_FLOOR) {
return {
key,
label,
value: "",
gated: true,
reason: "low_confidence" as const,
};
rows.push({ key, label, value: "", gated: true, reason: "absent" });
} else if (conf < CONTACT_DISPLAY_CONFIDENCE_FLOOR) {
rows.push({ key, label, value: "", gated: true, reason: "low_confidence" });
} else {
rows.push({ key, label, value, gated: false });
}
return { key, label, value, gated: false };
});
}
return rows;
}
6 changes: 6 additions & 0 deletions src/lib/heuristics/cascade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ export async function runCascade(
tiers,
rawText: extract.text,
markdown,
...(heuristic.skillsSectionLines?.length
? { skillsSectionText: heuristic.skillsSectionLines.join("\n") }
: {}),
linkAnnotations: extract.linkAnnotations,
diagnostics: {
rawCharCount: extract.rawCharCount,
Expand Down Expand Up @@ -402,6 +405,9 @@ export async function runCascadeFromMarkdown(
tiers,
rawText,
markdown,
...(heuristic.skillsSectionLines?.length
? { skillsSectionText: heuristic.skillsSectionLines.join("\n") }
: {}),
// DOCX cascade has no PDF annotations.
linkAnnotations: [],
diagnostics: {
Expand Down
1 change: 1 addition & 0 deletions src/lib/heuristics/corpus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ describe("corpus snapshots", () => {
fieldConfidence: cascade.fieldConfidence,
triggers: cascade.triggers,
rawText: cascade.rawText,
skillsSectionText: cascade.skillsSectionText,
});

const snapshot = {
Expand Down
Loading
Loading