From 2fbf3fcbe1f5ad317bee4dc5c2c9cd5bd6b96e98 Mon Sep 17 00:00:00 2001 From: frank-mendez Date: Thu, 27 Aug 2026 10:41:28 +0800 Subject: [PATCH] fix(automations): support uploaded resumes for matching --- __tests__/defaultResume.actions.spec.ts | 47 +++++++++++++++ __tests__/greenhouse-runner.spec.ts | 32 ++++++++++ __tests__/read-resume-file.spec.ts | 59 +++++++++++++++++++ src/actions/profile/resume.ts | 10 +++- src/app/dashboard/automations/[id]/page.tsx | 27 +++++---- src/app/dashboard/automations/page.tsx | 1 + .../automations/AutomationWizard.tsx | 7 ++- src/lib/ai/import/read-resume-file.ts | 29 +++++++++ src/lib/scraper/runner.ts | 40 ++++++++++++- 9 files changed, 235 insertions(+), 17 deletions(-) create mode 100644 __tests__/read-resume-file.spec.ts create mode 100644 src/lib/ai/import/read-resume-file.ts diff --git a/__tests__/defaultResume.actions.spec.ts b/__tests__/defaultResume.actions.spec.ts index 5d60f397..57d96259 100644 --- a/__tests__/defaultResume.actions.spec.ts +++ b/__tests__/defaultResume.actions.spec.ts @@ -271,5 +271,52 @@ describe("Default Resume Actions", () => { }), ); }); + + it("includes file-backed resumes when requested for automation matching", async () => { + (prisma.user.findUnique as any).mockResolvedValue({ + defaultResumeId: null, + }); + (prisma.resume.findMany as any).mockResolvedValue([ + { + id: "structured-resume", + FileId: null, + _count: { Job: 0, ResumeSections: 2 }, + }, + { + id: "file-backed-resume", + FileId: "file-1", + _count: { Job: 0, ResumeSections: 0 }, + }, + { + id: "empty-resume", + FileId: null, + _count: { Job: 0, ResumeSections: 1 }, + }, + ]); + + const result = await getResumeList(1, 100, 2, true); + + expect(result.data.map((r: any) => r.id)).toEqual([ + "structured-resume", + "file-backed-resume", + ]); + }); + + it("keeps file-backed resumes excluded by the default structured-only filter", async () => { + (prisma.user.findUnique as any).mockResolvedValue({ + defaultResumeId: null, + }); + (prisma.resume.findMany as any).mockResolvedValue([ + { + id: "file-backed-resume", + FileId: "file-1", + _count: { Job: 0, ResumeSections: 0 }, + }, + ]); + + const result = await getResumeList(1, 100, 2); + + expect(result.data).toEqual([]); + }); }); }); diff --git a/__tests__/greenhouse-runner.spec.ts b/__tests__/greenhouse-runner.spec.ts index 9d4d3222..3dbe0f03 100644 --- a/__tests__/greenhouse-runner.spec.ts +++ b/__tests__/greenhouse-runner.spec.ts @@ -51,12 +51,17 @@ vi.mock("@/lib/ai", async (orig) => { return { ...actual, getModel: vi.fn().mockResolvedValue({}) }; }); +vi.mock("@/lib/ai/import/read-resume-file", () => ({ + extractAttachedResumeText: vi.fn(), +})); + import { runAutomation } from "@/lib/scraper/runner"; import { searchGreenhouseJobs } from "@/lib/scraper/greenhouse"; import { searchJSearchJobs } from "@/lib/scraper/jsearch"; import { generateText } from "ai"; import type { Automation } from "@/models/automation.model"; import { AiProvider } from "@/models/ai.model"; +import { extractAttachedResumeText } from "@/lib/ai/import/read-resume-file"; // Each call to generateText returns a promise you resolve/reject manually, // in whatever order the test wants — lets you simulate out-of-order @@ -202,6 +207,33 @@ describe("runAutomation (greenhouse)", () => { expect((generateText as any).mock.calls.length).toBe(0); }); + it("uses attached resume text when structured sections are absent", async () => { + (prisma.resume.findUnique as any).mockResolvedValueOnce({ + id: "resume1", + title: "Uploaded Resume", + File: { filePath: "/data/files/resumes/uploaded.pdf" }, + ContactInfo: null, + ResumeSections: [], + }); + (extractAttachedResumeText as any).mockResolvedValue( + "Senior frontend engineer with React and TypeScript experience.", + ); + (searchGreenhouseJobs as any).mockResolvedValue({ + jobs: [makeJob("Frontend Engineer", "React")], + errors: [], + }); + + const result = await runAutomation(automation); + + expect(result.status).toBe("completed"); + expect(extractAttachedResumeText).toHaveBeenCalledWith( + "/data/files/resumes/uploaded.pdf", + ); + expect((generateText as any).mock.calls[0][0].prompt).toContain( + "Senior frontend engineer with React and TypeScript experience.", + ); + }); + // Exercises getExistingJobDedupeMap's wiring of real DB row shape (JobTitle/ // Company/Location relations) into jobDedupeKey, not just the pure // dedupeJobs unit tested in scraper-utils.spec.ts. diff --git a/__tests__/read-resume-file.spec.ts b/__tests__/read-resume-file.spec.ts new file mode 100644 index 00000000..45f74d49 --- /dev/null +++ b/__tests__/read-resume-file.spec.ts @@ -0,0 +1,59 @@ +import path from "path"; +import { extractAttachedResumeText } from "@/lib/ai/import/read-resume-file"; +import { APP_CONSTANTS } from "@/lib/constants"; + +const mockReadFile = vi.hoisted(() => vi.fn()); +const mockExtractText = vi.hoisted(() => vi.fn()); + +vi.mock("fs/promises", () => ({ + readFile: mockReadFile, + default: { readFile: mockReadFile }, +})); + +vi.mock("@/lib/ai/import/extract-text", () => ({ + extractText: mockExtractText, +})); + +describe("extractAttachedResumeText", () => { + const filePath = path.join( + APP_CONSTANTS.UPLOADS_DIR, + "files", + "resumes", + "resume.pdf", + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("extracts text from a file inside the uploads directory", async () => { + mockReadFile.mockResolvedValue(Buffer.from("pdf")); + mockExtractText.mockResolvedValue({ + success: true, + data: { text: "Senior engineer", truncated: false }, + }); + + await expect(extractAttachedResumeText(filePath)).resolves.toBe( + "Senior engineer", + ); + expect(mockReadFile).toHaveBeenCalledWith(path.resolve(filePath)); + expect(mockExtractText).toHaveBeenCalledWith(Buffer.from("pdf")); + }); + + it("does not read paths outside the uploads directory", async () => { + await expect( + extractAttachedResumeText("/tmp/not-a-resume.pdf"), + ).resolves.toBeNull(); + expect(mockReadFile).not.toHaveBeenCalled(); + }); + + it("returns null when extraction fails", async () => { + mockReadFile.mockResolvedValue(Buffer.from("pdf")); + mockExtractText.mockResolvedValue({ + success: false, + error: { code: "NO_TEXT", message: "No text" }, + }); + + await expect(extractAttachedResumeText(filePath)).resolves.toBeNull(); + }); +}); diff --git a/src/actions/profile/resume.ts b/src/actions/profile/resume.ts index 9cdcf109..35312a81 100644 --- a/src/actions/profile/resume.ts +++ b/src/actions/profile/resume.ts @@ -10,6 +10,10 @@ export const getResumeList = async ( page: number = 1, limit: number = APP_CONSTANTS.RECORDS_PER_PAGE, minSections: number = 0, + // File-backed resumes can be matched by consumers that extract their file + // text (currently automation); other consumers keep the structured-only + // behavior by default. + includeFileBacked: boolean = false, ): Promise => { try { const user = await requireUser(); @@ -82,7 +86,11 @@ export const getResumeList = async ( const data = minSections > 0 - ? rawData.filter((r) => r._count.ResumeSections >= minSections) + ? rawData.filter( + (r) => + r._count.ResumeSections >= minSections || + (includeFileBacked && Boolean(r.FileId)), + ) : rawData; return { data, total, success: true }; diff --git a/src/app/dashboard/automations/[id]/page.tsx b/src/app/dashboard/automations/[id]/page.tsx index 957c891c..7770001c 100644 --- a/src/app/dashboard/automations/[id]/page.tsx +++ b/src/app/dashboard/automations/[id]/page.tsx @@ -222,18 +222,21 @@ export default function AutomationDetailPage() { }, [loadData]); useEffect(() => { - getResumeList(1, 100, APP_CONSTANTS.MIN_RESUME_SECTIONS_FOR_SELECTION).then( - (result) => { - if (result?.data) { - setResumes( - result.data.map((r: { id: string; title: string }) => ({ - id: r.id, - title: r.title, - })), - ); - } - }, - ); + getResumeList( + 1, + 100, + APP_CONSTANTS.MIN_RESUME_SECTIONS_FOR_SELECTION, + true, + ).then((result) => { + if (result?.data) { + setResumes( + result.data.map((r: { id: string; title: string }) => ({ + id: r.id, + title: r.title, + })), + ); + } + }); getAutomationsList().then((result) => { if (result?.data) setAllAutomations(result.data); }); diff --git a/src/app/dashboard/automations/page.tsx b/src/app/dashboard/automations/page.tsx index 88279e2a..8b951607 100644 --- a/src/app/dashboard/automations/page.tsx +++ b/src/app/dashboard/automations/page.tsx @@ -7,6 +7,7 @@ export default async function AutomationsPage() { 1, 100, APP_CONSTANTS.MIN_RESUME_SECTIONS_FOR_SELECTION, + true, ); const resumes = resumeResult?.data?.map((r: { id: string; title: string }) => ({ diff --git a/src/components/automations/AutomationWizard.tsx b/src/components/automations/AutomationWizard.tsx index d8343ccf..3efa3275 100644 --- a/src/components/automations/AutomationWizard.tsx +++ b/src/components/automations/AutomationWizard.tsx @@ -383,7 +383,8 @@ export function AutomationWizard({ - Jobs will be matched against this resume + Jobs will be matched against this resume's structured content + or attached document @@ -391,8 +392,8 @@ export function AutomationWizard({ /> {resumes.length === 0 && (

- No resumes found. Please create a resume with enough content in - your profile first. + No matchable resumes found. Add at least 2 sections or attach a + PDF/DOCX resume in your profile first.

)} diff --git a/src/lib/ai/import/read-resume-file.ts b/src/lib/ai/import/read-resume-file.ts new file mode 100644 index 00000000..89a37279 --- /dev/null +++ b/src/lib/ai/import/read-resume-file.ts @@ -0,0 +1,29 @@ +import { readFile } from "fs/promises"; +import path from "path"; +import { APP_CONSTANTS } from "@/lib/constants"; +import { extractText } from "./extract-text"; + +// Attached resume paths come from the database, but still stay inside the +// configured uploads directory before they are read. This keeps the runner's +// file fallback subject to the same boundary as the import route. +export async function extractAttachedResumeText( + filePath: string | null | undefined, +): Promise { + if (!filePath) return null; + + const resolvedPath = path.resolve(filePath); + const uploadsDir = path.resolve(APP_CONSTANTS.UPLOADS_DIR); + if ( + !resolvedPath.startsWith(`${uploadsDir}${path.sep}`) && + resolvedPath !== uploadsDir + ) { + return null; + } + + try { + const result = await extractText(await readFile(resolvedPath)); + return result.success ? result.data.text : null; + } catch { + return null; + } +} diff --git a/src/lib/scraper/runner.ts b/src/lib/scraper/runner.ts index 02672c5f..0cf08b64 100644 --- a/src/lib/scraper/runner.ts +++ b/src/lib/scraper/runner.ts @@ -42,6 +42,7 @@ import { import { resolveApiKey } from "@/lib/api-key-resolver"; import { PROVIDER_VERIFIERS } from "@/lib/ai/provider-registry.server"; import { getOllamaBaseUrl } from "@/actions/apiKey.actions"; +import { extractAttachedResumeText } from "@/lib/ai/import/read-resume-file"; const MAX_JOBS_PER_RUN = APP_CONSTANTS.MAX_JOBS_PER_RUN; @@ -111,6 +112,7 @@ export interface RunnerResult { } interface ResumeWithSections extends PrismaResume { + File: { filePath: string } | null; ContactInfo: { firstName: string; lastName: string; @@ -271,6 +273,7 @@ export async function runAutomation( const resume = await db.resume.findUnique({ where: { id: automation.resumeId }, include: { + File: { select: { filePath: true } }, ContactInfo: true, ResumeSections: { include: { @@ -1133,7 +1136,7 @@ async function matchJobToResume( signal?: AbortSignal, ): Promise { try { - const resumeText = await convertResumeForMatch(resume); + const resumeText = await getResumeMatchText(resume); const jobText = ` Title: ${job.title} Company: ${job.company} @@ -1193,10 +1196,28 @@ ${removeHtmlTags(job.description)} } } +// A run can match several jobs concurrently. Cache the extracted/structured +// input by resume object so a file-backed resume is read and parsed once per +// run rather than once per job. +const resumeMatchTextCache = new WeakMap< + ResumeWithSections, + Promise +>(); + +function getResumeMatchText(resume: ResumeWithSections): Promise { + const cached = resumeMatchTextCache.get(resume); + if (cached) return cached; + + const text = convertResumeForMatch(resume); + resumeMatchTextCache.set(resume, text); + return text; +} + async function convertResumeForMatch( resume: ResumeWithSections, ): Promise { const parts: string[] = [`# ${resume.title}`]; + let hasStructuredContent = false; if (resume.ContactInfo) { const contact = resume.ContactInfo; @@ -1212,6 +1233,7 @@ async function convertResumeForMatch( for (const section of resume.ResumeSections) { if (section.sectionType === "summary" && section.summary?.content) { parts.push("## SUMMARY", removeHtmlTags(section.summary.content)); + hasStructuredContent = true; } if ( @@ -1219,6 +1241,7 @@ async function convertResumeForMatch( section.workExperiences.length > 0 ) { parts.push("## EXPERIENCE"); + hasStructuredContent = true; for (const exp of section.workExperiences) { parts.push( `Company: ${exp.Company.label}`, @@ -1232,6 +1255,7 @@ async function convertResumeForMatch( if (section.sectionType === "education" && section.educations.length > 0) { parts.push("## EDUCATION"); + hasStructuredContent = true; for (const edu of section.educations) { parts.push( `Institution: ${edu.institution}`, @@ -1249,6 +1273,7 @@ async function convertResumeForMatch( section.licenseOrCertifications.length > 0 ) { parts.push(`## ${section.sectionType.toUpperCase()}S`); + hasStructuredContent = true; for (const cert of section.licenseOrCertifications) { parts.push( `Title: ${cert.title}`, @@ -1273,6 +1298,7 @@ async function convertResumeForMatch( grouped.get(key)!.push(s); } parts.push("## SKILLS"); + hasStructuredContent = true; for (const [cat, items] of grouped.entries()) { const labels = items.map((s) => s.Tag.label).join(", "); parts.push(cat ? `${cat}: ${labels}` : labels); @@ -1281,6 +1307,18 @@ async function convertResumeForMatch( } } + // A newly uploaded resume may not have structured sections yet. Use the + // original document as the match input in that case; structured sections + // remain authoritative once the user has imported/edited them. + if (!hasStructuredContent && resume.File?.filePath) { + const attachedText = await extractAttachedResumeText(resume.File.filePath); + if (attachedText?.trim()) { + return [`# ${resume.title}`, "## ATTACHED RESUME", attachedText].join( + "\n", + ); + } + } + return parts.filter(Boolean).join("\n"); }