feat(ai): implement an AI chat window for agentic resume building (#3022)

This commit is contained in:
Amruth Pillai
2026-05-10 13:23:32 +02:00
committed by GitHub
parent 42e83cc676
commit 6787175a8a
91 changed files with 11894 additions and 135 deletions
+7 -9
View File
@@ -32,9 +32,7 @@ export const resumeDto = {
getById: {
input: resumeSchema.pick({ id: true }),
output: resumeSchema
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
.extend({ hasPassword: z.boolean() }),
output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
},
getBySlug: {
@@ -65,9 +63,7 @@ export const resumeDto = {
.pick({ name: true, slug: true, tags: true, data: true, isPublic: true })
.partial()
.extend({ id: z.string() }),
output: resumeSchema
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
.extend({ hasPassword: z.boolean() }),
output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
},
setLocked: {
@@ -88,14 +84,16 @@ export const resumeDto = {
patch: {
input: z.object({
id: z.string().describe("The ID of the resume to patch."),
expectedUpdatedAt: z.coerce
.date()
.optional()
.describe("If provided, the patch only applies when the resume version still matches this timestamp."),
operations: z
.array(jsonPatchOperationSchema)
.min(1)
.describe("An array of JSON Patch (RFC 6902) operations to apply to the resume data."),
}),
output: resumeSchema
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
.extend({ hasPassword: z.boolean() }),
output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
},
duplicate: {
+3 -1
View File
@@ -58,6 +58,7 @@ export const aiRouter = {
try {
return await aiService.testConnection(input);
} catch (error) {
console.error(error);
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
throw error;
@@ -137,7 +138,7 @@ export const aiRouter = {
operationId: "aiChat",
summary: "Chat with AI to modify resume",
description:
"Streams a chat response from the configured AI provider. The LLM can call the patch_resume tool to generate JSON Patch operations that modify the resume. Requires authentication and AI provider credentials.",
"Streams a chat response from the configured AI provider. The LLM can call the propose_resume_patches tool to generate JSON Patch proposals for explicit user approval. Requires authentication and AI provider credentials.",
})
.input(
type<{
@@ -147,6 +148,7 @@ export const aiRouter = {
baseURL: string;
messages: UIMessage[];
resumeData: ResumeData;
resumeUpdatedAt: Date;
}>(),
)
.use(aiRequestRateLimit)
+39
View File
@@ -6,6 +6,7 @@ import { protectedProcedure, publicProcedure } from "../context";
import { resumeDto } from "../dto/resume";
import { resumeMutationRateLimit, resumePasswordRateLimit } from "../middleware/rate-limit";
import { resumeService } from "../services/resume";
import { subscribeResumeUpdated } from "../services/resume-events";
const tagsRouter = {
list: protectedProcedure
@@ -71,10 +72,43 @@ const analysisRouter = {
}),
};
const updatesRouter = {
subscribe: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/updates",
tags: ["Resumes"],
operationId: "subscribeResumeUpdates",
summary: "Subscribe to resume updates",
description:
"Streams lightweight invalidation events when the specified resume changes. The event payload contains metadata only; clients should refetch the resume for canonical data.",
successDescription: "A stream of resume update invalidation events.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.handler(async function* ({ context, input, signal }) {
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
yield {
type: "resume.updated" as const,
resumeId: input.id,
userId: context.user.id,
updatedAt: resume.updatedAt.toISOString(),
mutation: "sync" as const,
};
yield* subscribeResumeUpdated({
resumeId: input.id,
userId: context.user.id,
...(signal ? { signal } : {}),
});
}),
};
export const resumeRouter = {
tags: tagsRouter,
statistics: statisticsRouter,
analysis: analysisRouter,
updates: updatesRouter,
list: protectedProcedure
.route({
@@ -250,12 +284,17 @@ export const resumeRouter = {
message: "The patch operations are invalid or produced an invalid resume.",
status: 400,
},
RESUME_VERSION_CONFLICT: {
message: "The resume changed after this patch was generated.",
status: 409,
},
})
.handler(async ({ context, input }) => {
return resumeService.patch({
id: input.id,
userId: context.user.id,
operations: input.operations,
...(input.expectedUpdatedAt ? { expectedUpdatedAt: input.expectedUpdatedAt } : {}),
});
}),
+85
View File
@@ -0,0 +1,85 @@
import type { UIMessage } from "ai";
import { describe, expect, it } from "vitest";
import { convertToModelMessages, modelMessageSchema } from "ai";
describe("AI chat service", () => {
it("keeps proposal tool history valid for follow-up chat messages", async () => {
const messages: UIMessage[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Add draft references." }],
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-propose_resume_patches",
toolCallId: "call-1",
state: "output-available",
input: {
proposals: [
{
title: "Add draft references",
operations: [
{
op: "replace",
path: "/sections/references/items",
value: [
{ id: "reference-1", name: "Jane Mitchell" },
{ id: "reference-2", name: "Marcus Chen" },
{ id: "reference-3", name: "Olivia Ramirez" },
],
},
],
},
],
},
output: {
proposals: [
{
id: "proposal-1",
title: "Add draft references",
baseUpdatedAt: "2026-05-10T06:38:27.093Z",
operations: [
{
op: "replace",
path: "/sections/references/items",
value: [
{ id: "reference-1", name: "Jane Mitchell" },
{ id: "reference-2", name: "Marcus Chen" },
{ id: "reference-3", name: "Olivia Ramirez" },
],
},
],
},
],
},
},
],
},
{
id: "assistant-2",
role: "assistant",
parts: [{ type: "text", text: "I prepared draft reference changes for review." }],
},
{
id: "user-2",
role: "user",
parts: [{ type: "text", text: "Reduce it down to the first two." }],
},
];
const modelMessages = await convertToModelMessages(messages);
expect(modelMessages.map((message) => message.role)).toEqual(["user", "assistant", "tool", "assistant", "user"]);
expect(JSON.stringify(modelMessages)).toContain("proposal-1");
expect(JSON.stringify(modelMessages)).toContain("/sections/references/items");
expect(JSON.stringify(modelMessages)).toContain("tool-result");
for (const message of modelMessages) {
expect(modelMessageSchema.safeParse(message).success).toBe(true);
}
});
});
+20 -8
View File
@@ -22,12 +22,13 @@ import {
import { buildAiExtractionTemplate } from "@reactive-resume/ai/resume/extraction-template";
import { sanitizeAndParseResumeJson } from "@reactive-resume/ai/resume/sanitize";
import {
executePatchResume,
patchResumeDescription,
patchResumeInputSchema,
} from "@reactive-resume/ai/tools/patch-resume";
normalizeResumePatchProposals,
resumePatchProposalToolInputSchema,
resumePatchProposalToolOutputSchema,
} from "@reactive-resume/ai/tools/patch-proposal";
import { AI_PROVIDER_DEFAULT_BASE_URLS, aiProviderSchema } from "@reactive-resume/ai/types";
import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { applyResumePatches } from "@reactive-resume/utils/resume/patch";
import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security.node";
const aiExtractionTemplate = buildAiExtractionTemplate();
@@ -209,6 +210,7 @@ function buildChatSystemPrompt(resumeData: ResumeData): string {
type ChatInput = z.infer<typeof aiCredentialsSchema> & {
messages: UIMessage[];
resumeData: ResumeData;
resumeUpdatedAt: Date;
};
async function chat(input: ChatInput) {
@@ -220,10 +222,20 @@ async function chat(input: ChatInput) {
system: systemPrompt,
messages: await convertToModelMessages(input.messages),
tools: {
patch_resume: tool({
description: patchResumeDescription,
inputSchema: patchResumeInputSchema,
execute: async ({ operations }) => executePatchResume(input.resumeData, operations),
propose_resume_patches: tool({
description:
"Return one or more cohesive resume change proposals. Each proposal must include a title, optional summary, and valid JSON Patch operations against the current resume data. The tool validates but does not apply changes.",
inputSchema: resumePatchProposalToolInputSchema,
outputSchema: resumePatchProposalToolOutputSchema,
execute: async (toolInput) => {
const proposals = normalizeResumePatchProposals(toolInput, input.resumeUpdatedAt);
for (const proposal of proposals) {
applyResumePatches(input.resumeData, proposal.operations);
}
return { proposals };
},
}),
},
stopWhen: stepCountIs(3),
@@ -0,0 +1,99 @@
import { getPool } from "@reactive-resume/db/client";
const RESUME_UPDATED_CHANNEL = "resume_updated";
type PgNotification = {
channel?: string | undefined;
payload?: string | undefined;
};
export type ResumeUpdatedEvent = {
type: "resume.updated";
resumeId: string;
userId: string;
updatedAt: string;
mutation: "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
};
type SubscribeResumeUpdatedInput = {
resumeId: string;
userId: string;
signal?: AbortSignal;
};
function isResumeUpdatedEvent(value: unknown): value is ResumeUpdatedEvent {
if (!value || typeof value !== "object") return false;
const event = value as Partial<ResumeUpdatedEvent>;
return (
event.type === "resume.updated" &&
typeof event.resumeId === "string" &&
typeof event.userId === "string" &&
typeof event.updatedAt === "string" &&
typeof event.mutation === "string"
);
}
export async function publishResumeUpdated(event: ResumeUpdatedEvent) {
await getPool().query("SELECT pg_notify($1, $2)", [RESUME_UPDATED_CHANNEL, JSON.stringify(event)]);
}
export async function* subscribeResumeUpdated({ resumeId, userId, signal }: SubscribeResumeUpdatedInput) {
const client = await getPool().connect();
const queue: ResumeUpdatedEvent[] = [];
let done = signal?.aborted ?? false;
let wake: (() => void) | undefined;
const resolveWake = () => {
wake?.();
wake = undefined;
};
const onAbort = () => {
done = true;
resolveWake();
};
const onNotification = (notification: PgNotification) => {
if (notification.channel !== RESUME_UPDATED_CHANNEL || !notification.payload) return;
try {
const event = JSON.parse(notification.payload) as unknown;
if (!isResumeUpdatedEvent(event)) return;
if (event.resumeId !== resumeId || event.userId !== userId) return;
queue.push(event);
resolveWake();
} catch {
// Ignore malformed notifications; the refetch path is invalidation-only.
}
};
signal?.addEventListener("abort", onAbort, { once: true });
client.on("notification", onNotification);
try {
await client.query(`LISTEN ${RESUME_UPDATED_CHANNEL}`);
while (!done) {
const event = queue.shift();
if (event) {
yield event;
continue;
}
await new Promise<void>((resolve) => {
wake = resolve;
});
}
} finally {
signal?.removeEventListener("abort", onAbort);
client.off("notification", onNotification);
try {
await client.query(`UNLISTEN ${RESUME_UPDATED_CHANNEL}`);
} finally {
client.release();
}
}
}
+96 -9
View File
@@ -1,7 +1,8 @@
import type { StoredResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Locale } from "@reactive-resume/utils/locale";
import type { Operation } from "fast-json-patch";
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
import type { ResumeUpdatedEvent } from "./resume-events";
import { ORPCError } from "@orpc/client";
import { compare, hash } from "bcrypt";
import { and, arrayContains, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
@@ -19,6 +20,7 @@ import {
redactResumeForViewer,
shouldCountForStatistics,
} from "../helpers/resume-access-policy";
import { publishResumeUpdated } from "./resume-events";
import { getStorageService } from "./storage";
const tags = {
@@ -149,6 +151,14 @@ function toSharedResumeResponse<TPassword extends boolean>(
};
}
async function notifyResumeUpdated(event: ResumeUpdatedEvent) {
try {
await publishResumeUpdated(event);
} catch (error) {
console.warn("Failed to publish resume.updated event:", error);
}
}
export const resumeService = {
tags,
statistics,
@@ -194,6 +204,7 @@ export const resumeService = {
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
updatedAt: schema.resume.updatedAt,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
})
.from(schema.resume)
@@ -263,6 +274,14 @@ export const resumeService = {
data,
});
await notifyResumeUpdated({
type: "resume.updated",
resumeId: id,
userId: input.userId,
updatedAt: new Date().toISOString(),
mutation: "create",
});
return id;
} catch (error) {
const constraint = get(error, "cause.constraint") as string | undefined;
@@ -319,13 +338,24 @@ export const resumeService = {
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
updatedAt: schema.resume.updatedAt,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
});
if (!resume) throw new ORPCError("NOT_FOUND");
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
userId: input.userId,
updatedAt: resume.updatedAt.toISOString(),
mutation: "update",
});
return resume;
} catch (error) {
if (error instanceof ORPCError) throw error;
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
@@ -335,14 +365,21 @@ export const resumeService = {
}
},
patch: async (input: { id: string; userId: string; operations: Operation[] }) => {
patch: async (input: { id: string; userId: string; operations: JsonPatchOperation[]; expectedUpdatedAt?: Date }) => {
const [existing] = await db
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked })
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked, updatedAt: schema.resume.updatedAt })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!existing) throw new ORPCError("NOT_FOUND");
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
if (input.expectedUpdatedAt && existing.updatedAt.getTime() !== input.expectedUpdatedAt.getTime()) {
throw new ORPCError("RESUME_VERSION_CONFLICT", {
status: 409,
message: "The resume changed after this patch was generated.",
data: { updatedAt: existing.updatedAt.toISOString() },
});
}
let patchedData: ResumeData;
@@ -377,28 +414,59 @@ export const resumeService = {
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
updatedAt: schema.resume.updatedAt,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
});
if (!resume) throw new ORPCError("NOT_FOUND");
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
userId: input.userId,
updatedAt: resume.updatedAt.toISOString(),
mutation: "patch",
});
return resume;
},
setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => {
await db
const [resume] = await db
.update(schema.resume)
.set({ isLocked: input.isLocked })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
if (!resume) return;
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
userId: input.userId,
updatedAt: resume.updatedAt.toISOString(),
mutation: "lock",
});
},
setPassword: async (input: { id: string; userId: string; password: string }) => {
const hashedPassword = await hash(input.password, 10);
await db
const [resume] = await db
.update(schema.resume)
.set({ password: hashedPassword })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
if (!resume) return;
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
userId: input.userId,
updatedAt: resume.updatedAt.toISOString(),
mutation: "password",
});
},
verifyPassword: async (input: { slug: string; username: string; password: string }) => {
@@ -427,10 +495,21 @@ export const resumeService = {
},
removePassword: async (input: { id: string; userId: string }) => {
await db
const [resume] = await db
.update(schema.resume)
.set({ password: null })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
if (!resume) return;
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
userId: input.userId,
updatedAt: resume.updatedAt.toISOString(),
mutation: "password",
});
},
delete: async (input: { id: string; userId: string }) => {
@@ -452,5 +531,13 @@ export const resumeService = {
storageService.delete(`uploads/${input.userId}/screenshots/${input.id}`),
storageService.delete(`uploads/${input.userId}/pdfs/${input.id}`),
]);
await notifyResumeUpdated({
type: "resume.updated",
resumeId: input.id,
userId: input.userId,
updatedAt: new Date().toISOString(),
mutation: "delete",
});
},
};