feat(api): passage rewrites in the writing review, and a resume's linked application

- ai.atsReview takes optional passages (id, where, text). A suggestion that
  rewrites one names it in passageId with the whole new passage, which the
  editor offers as a proposal to accept or reject.
- The review prompt fills its placeholders in one pass, so resume text that
  looks like a placeholder is sent as it is.
- resume.getById returns applicationId, the job application a resume was made
  for, so Check's job match can read its posting.
This commit is contained in:
Amruth Pillai
2026-09-28 21:33:18 +02:00
parent e26ce08fc5
commit 1608d56903
6 changed files with 83 additions and 5 deletions
+4 -1
View File
@@ -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: {
@@ -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("<<<PASSAGES_START>>>");
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");
});
});
+43 -4
View File
@@ -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<typeof atsReviewInputSchema>;
@@ -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_START>>>",
...passages.map((passage) => `[${passage.id}] ${passage.where}: ${passage.text.replace(/\s+/g, " ")}`),
"<<<PASSAGES_END>>>",
].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<string, string> = {
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}. */
@@ -442,6 +442,7 @@ export const resumeService = {
showDownloadButtons: schema.resume.showDownloadButtons,
updatedAt: schema.resume.updatedAt,
hasPassword: sql<boolean>`${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)));