diff --git a/packages/ai/src/prompts/ats-review-system.md b/packages/ai/src/prompts/ats-review-system.md index 1fbf1044c..1e2e5e823 100644 --- a/packages/ai/src/prompts/ats-review-system.md +++ b/packages/ai/src/prompts/ats-review-system.md @@ -7,6 +7,7 @@ Your job is the part software cannot do: judge the writing. - Read the extracted text as the reader of the resume would. - Point out weak phrasing, vague claims, missing impact, and bullets that describe duties rather than outcomes. - Where you propose a rewrite, rewrite only what is already in the text. +- If passages are supplied, prefer suggestions about them. A rewrite of a passage replaces the whole passage, so it must stand on its own and keep every fact the passage states. - Note genuine strengths. Do not manufacture them. - If a job description is supplied, say how well the candidate's actual experience lines up with what the role asks for — as a judgement about substance, not a keyword count. @@ -28,6 +29,7 @@ Return only a JSON object matching this structure. No markdown fences, no commen "suggestions": [ { "section": "string or null — the section the suggestion applies to", +"passageId": "string or null — when passages are supplied and this rewrites one of them, its id", "issue": "string — what is weak, concretely", "rewrite": "string or null — a stronger version of the same claim", "impact": "high" | "medium" | "low" diff --git a/packages/ai/src/prompts/ats-review-user.md b/packages/ai/src/prompts/ats-review-user.md index c0955928a..3c0ecf22f 100644 --- a/packages/ai/src/prompts/ats-review-user.md +++ b/packages/ai/src/prompts/ats-review-user.md @@ -10,3 +10,4 @@ Review the resume below and return the JSON object described in your instruction {{FINDINGS}} {{JOB_DESCRIPTION_SECTION}} +{{PASSAGES_SECTION}} diff --git a/packages/api/src/dto/resume.ts b/packages/api/src/dto/resume.ts index 1185cf3b7..83624dd34 100644 --- a/packages/api/src/dto/resume.ts +++ b/packages/api/src/dto/resume.ts @@ -49,7 +49,10 @@ export const resumeDto = { getById: { input: resumeSchema.pick({ id: true }), - output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }), + output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ + hasPassword: z.boolean(), + applicationId: z.string().nullable().describe("The job application this resume was made for, if any."), + }), }, getBySlug: { diff --git a/packages/api/src/features/ai/ats-review.test.ts b/packages/api/src/features/ai/ats-review.test.ts index 61baa5c0b..1594f7469 100644 --- a/packages/api/src/features/ai/ats-review.test.ts +++ b/packages/api/src/features/ai/ats-review.test.ts @@ -6,6 +6,7 @@ const { buildUserPrompt, renderFindings } = __testables; const baseInput = { extractedText: "Ada Lovelace\nPrincipal Engineer\nBuilt the note-taking programme.", findings: [{ code: "NO_PHONE", severity: "warning", message: "No phone number was found." }], + passages: [], provider: "openai" as const, model: "gpt-4o-mini", apiKey: "sk-test", @@ -112,4 +113,35 @@ describe("buildUserPrompt", () => { it("says so plainly when nothing was flagged", () => { expect(renderFindings([])).toBe("None reported."); }); + + it("lists the passages a rewrite may replace, by id, and omits the section without them", () => { + const prompt = buildUserPrompt({ + ...baseInput, + passages: [{ id: "p1", where: "Experience · Lumen · bullet 1", text: "Responsible for\n design tasks" }], + }); + + expect(prompt).toContain("[p1] Experience · Lumen · bullet 1: Responsible for design tasks"); + expect(prompt).toContain("<<>>"); + expect(buildUserPrompt(baseInput)).not.toContain("PASSAGES"); + }); + + it("leaves placeholder-like text inside the resume alone", () => { + const prompt = buildUserPrompt({ ...baseInput, extractedText: "Skills: {{FINDINGS}}" }); + + expect(prompt).toContain("Skills: {{FINDINGS}}"); + }); +}); + +describe("passages", () => { + it("defaults to none and keeps the passage a suggestion rewrites", () => { + expect(atsReviewInputSchema.parse({ extractedText: "Ada" }).passages).toEqual([]); + + const parsed = atsReviewOutputSchema.parse({ + summary: "", + suggestions: [{ section: "Experience", passageId: "p1", issue: "Vague.", rewrite: "Sharper.", impact: "high" }], + strengths: [], + jdAlignment: null, + }); + expect(parsed.suggestions[0]?.passageId).toBe("p1"); + }); }); diff --git a/packages/api/src/features/ai/ats-review.ts b/packages/api/src/features/ai/ats-review.ts index bb599d2f0..1c5a30ff3 100644 --- a/packages/api/src/features/ai/ats-review.ts +++ b/packages/api/src/features/ai/ats-review.ts @@ -8,6 +8,8 @@ const MAX_EXTRACTED_TEXT_CHARS = 50_000; /** Matches the cap the applications feature already uses for a pasted posting. */ const MAX_JOB_DESCRIPTION_CHARS = 20_000; const MAX_FINDINGS = 120; +/** A long resume's bullets and paragraphs. */ +const MAX_PASSAGES = 120; export const atsReviewInputSchema = z.object({ aiProviderId: z.string().optional(), @@ -23,6 +25,19 @@ export const atsReviewInputSchema = z.object({ .max(MAX_FINDINGS) .default([]), jobDescription: z.string().trim().max(MAX_JOB_DESCRIPTION_CHARS).optional(), + passages: z + .array( + z.object({ + id: z.string().trim().min(1).max(16).describe("The client's id for the passage, echoed back as `passageId`."), + where: z.string().max(200).describe("Where the passage sits, e.g. “Experience · Lumen · bullet 2”."), + text: z.string().max(1_000), + }), + ) + .max(MAX_PASSAGES) + .default([]) + .describe( + "Bullets and paragraphs the review may rewrite. A suggestion that rewrites one names it in `passageId`, with the whole new passage in `rewrite`.", + ), }); type AtsReviewInput = z.infer; @@ -42,6 +57,7 @@ export const atsReviewOutputSchema = z.object({ .array( z.object({ section: z.string().nullable().catch(null), + passageId: z.string().nullable().catch(null), issue: z.string().catch(""), rewrite: z.string().nullable().catch(null), impact: impactSchema, @@ -96,11 +112,34 @@ function renderJobDescriptionSection(jobDescription: string | undefined): string ].join("\n"); } +function renderPassagesSection(passages: AtsReviewInput["passages"]): string { + if (passages.length === 0) return ""; + + return [ + "", + "## Passages you may rewrite", + "", + "Each line is `[id] where: text`. When a suggestion rewrites one of these, set `passageId` to its id and `rewrite` to the whole new passage.", + "", + "<<>>", + ...passages.map((passage) => `[${passage.id}] ${passage.where}: ${passage.text.replace(/\s+/g, " ")}`), + "<<>>", + ].join("\n"); +} + function buildUserPrompt(input: AtsReviewServiceInput): string { - return atsReviewUserPromptTemplate - .replaceAll("{{EXTRACTED_TEXT}}", input.extractedText) - .replaceAll("{{FINDINGS}}", renderFindings(input.findings)) - .replaceAll("{{JOB_DESCRIPTION_SECTION}}", renderJobDescriptionSection(input.jobDescription)); + // One pass over the template, so text inside the resume that happens to look like a placeholder stays as it is. + const sections: Record = { + EXTRACTED_TEXT: input.extractedText, + FINDINGS: renderFindings(input.findings), + JOB_DESCRIPTION_SECTION: renderJobDescriptionSection(input.jobDescription), + PASSAGES_SECTION: renderPassagesSection(input.passages ?? []), + }; + + return atsReviewUserPromptTemplate.replace( + /\{\{([A-Z_]+)\}\}/g, + (placeholder, name: string) => sections[name] ?? placeholder, + ); } /** Qualitative review of the writing. Never returns a score — see {@link atsReviewOutputSchema}. */ diff --git a/packages/api/src/features/resume/service.ts b/packages/api/src/features/resume/service.ts index 8c6642a9c..b01570763 100644 --- a/packages/api/src/features/resume/service.ts +++ b/packages/api/src/features/resume/service.ts @@ -442,6 +442,7 @@ export const resumeService = { showDownloadButtons: schema.resume.showDownloadButtons, updatedAt: schema.resume.updatedAt, hasPassword: sql`${schema.resume.password} IS NOT NULL`, + applicationId: schema.resume.applicationId, }) .from(schema.resume) .where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));