mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user