diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
index a7f3303a5..14111efb4 100644
--- a/src/components/ui/alert-dialog.tsx
+++ b/src/components/ui/alert-dialog.tsx
@@ -112,10 +112,7 @@ function AlertDialogDescription({
return (
);
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
index 91f7f47bd..5b3a665dc 100644
--- a/src/components/ui/alert.tsx
+++ b/src/components/ui/alert.tsx
@@ -28,10 +28,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
- className,
- )}
+ className={cn("font-medium group-has-[>svg]/alert:col-start-2", className)}
{...props}
/>
);
@@ -41,10 +38,7 @@ function AlertDescription({ className, ...props }: React.ComponentProps<"div">)
return (
);
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index 8ad031ba5..244933116 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -98,10 +98,7 @@ function DialogDescription({ className, ...props }: DialogPrimitive.Description.
return (
);
diff --git a/src/integrations/ai/prompts/analyze-resume-system.md b/src/integrations/ai/prompts/analyze-resume-system.md
new file mode 100644
index 000000000..6aefedf93
--- /dev/null
+++ b/src/integrations/ai/prompts/analyze-resume-system.md
@@ -0,0 +1,65 @@
+You are a senior resume reviewer and ATS optimization specialist.
+
+Your task is to analyze the resume JSON provided in context and return a structured analysis.
+
+## Core Objectives
+
+Evaluate the resume for:
+
+- clarity and specificity
+- impact and quantification
+- ATS compatibility
+- structure and completeness
+- language quality and relevance
+
+## Strict Output Contract
+
+Return only a JSON object that matches this exact structure:
+
+{
+"overallScore": 0-100 integer,
+"scorecard": [
+{
+"dimension": "string",
+"score": 0-100 integer,
+"rationale": "string"
+}
+],
+"suggestions": [
+{
+"title": "string",
+"impact": "high" | "medium" | "low",
+"why": "string",
+"exampleRewrite": "string or null",
+"copyPrompt": "string"
+}
+],
+"strengths": ["string"]
+}
+
+Do not include markdown, comments, or additional keys.
+
+## Evaluation Rules
+
+1. Use 0-100 scoring for each dimension and overall score.
+2. Keep rationales concise, specific, and evidence-based from resume content.
+3. Suggestions must be prioritized by impact and be actionable.
+4. Never invent candidate achievements or facts.
+5. If data is missing, call it out explicitly in rationale/suggestions.
+6. Keep scorecard dimensions practical and common for resume review.
+
+## Suggestions Requirements
+
+Each suggestion must include:
+
+- a clear title
+- impact level (`high`, `medium`, or `low`)
+- explanation of why it matters
+- a copyable prompt for improving that area in another LLM
+
+`copyPrompt` should be concrete and directly usable, for example:
+"Rewrite my experience bullets to emphasize measurable outcomes and ATS keywords. Keep each bullet under 25 words and include a metric where possible. Here is my current section: "
+
+## Tone
+
+Professional, direct, and constructive. Focus on helping the user improve quickly.
diff --git a/src/integrations/drizzle/schema.ts b/src/integrations/drizzle/schema.ts
index 612e988c1..33c43de13 100644
--- a/src/integrations/drizzle/schema.ts
+++ b/src/integrations/drizzle/schema.ts
@@ -1,6 +1,7 @@
import { defineRelations } from "drizzle-orm";
import * as pg from "drizzle-orm/pg-core";
+import { type StoredResumeAnalysis } from "../../schema/resume/analysis";
import { defaultResumeData, type ResumeData } from "../../schema/resume/data";
import { generateId } from "../../utils/string";
@@ -229,6 +230,30 @@ export const resumeStatistics = pg.pgTable("resume_statistics", {
.$onUpdate(() => /* @__PURE__ */ new Date()),
});
+export const resumeAnalysis = pg.pgTable(
+ "resume_analysis",
+ {
+ id: pg
+ .uuid("id")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => generateId()),
+ analysis: pg.jsonb("analysis").notNull().$type
(),
+ resumeId: pg
+ .uuid("resume_id")
+ .unique()
+ .notNull()
+ .references(() => resume.id, { onDelete: "cascade" }),
+ createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: pg
+ .timestamp("updated_at", { withTimezone: true })
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => /* @__PURE__ */ new Date()),
+ },
+ (t) => [pg.index().on(t.resumeId)],
+);
+
export const apikey = pg.pgTable(
"apikey",
{
@@ -416,6 +441,7 @@ export const relations = defineRelations(
passkey,
resume,
resumeStatistics,
+ resumeAnalysis,
apikey,
jwks,
oauthClient,
@@ -477,6 +503,10 @@ export const relations = defineRelations(
from: r.resume.id,
to: r.resumeStatistics.resumeId,
}),
+ analysis: r.one.resumeAnalysis({
+ from: r.resume.id,
+ to: r.resumeAnalysis.resumeId,
+ }),
},
resumeStatistics: {
resume: r.one.resume({
@@ -484,6 +514,12 @@ export const relations = defineRelations(
to: r.resume.id,
}),
},
+ resumeAnalysis: {
+ resume: r.one.resume({
+ from: r.resumeAnalysis.resumeId,
+ to: r.resume.id,
+ }),
+ },
apikey: {
user: r.one.user({
from: r.apikey.referenceId,
diff --git a/src/integrations/orpc/router/ai.ts b/src/integrations/orpc/router/ai.ts
index a1172de12..089296a42 100644
--- a/src/integrations/orpc/router/ai.ts
+++ b/src/integrations/orpc/router/ai.ts
@@ -5,11 +5,13 @@ import { OllamaError } from "ai-sdk-ollama";
import z, { flattenError, ZodError } from "zod";
import { jobResultSchema } from "@/schema/jobs";
+import { resumeAnalysisSchema, storedResumeAnalysisSchema } from "@/schema/resume/analysis";
import { type ResumeData, resumeDataSchema } from "@/schema/resume/data";
import { tailorOutputSchema } from "@/schema/tailor";
import { protectedProcedure } from "../context";
import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema } from "../services/ai";
+import { resumeService } from "../services/resume";
type AIProvider = z.infer;
@@ -222,6 +224,75 @@ export const aiRouter = {
});
}
+ throw error;
+ }
+ }),
+
+ analyzeResume: protectedProcedure
+ .route({
+ method: "POST",
+ path: "/ai/analyze-resume",
+ tags: ["AI"],
+ operationId: "analyzeResume",
+ summary: "Analyze resume and persist latest analysis",
+ description:
+ "Uses AI to analyze the current resume and returns a structured analysis with scorecard, strengths, and improvement suggestions. The latest analysis is persisted and can be fetched later. Requires authentication and AI credentials.",
+ successDescription: "Structured resume analysis returned and persisted successfully.",
+ })
+ .input(
+ z.object({
+ ...aiCredentialsSchema.shape,
+ resumeId: z.string(),
+ resumeData: resumeDataSchema,
+ }),
+ )
+ .output(storedResumeAnalysisSchema)
+ .errors({
+ BAD_GATEWAY: {
+ message: "The AI provider returned an error or is unreachable.",
+ status: 502,
+ },
+ BAD_REQUEST: {
+ message: "The AI returned an improperly formatted structure.",
+ status: 400,
+ },
+ })
+ .handler(async ({ context, input }) => {
+ try {
+ const analysis = resumeAnalysisSchema.parse(
+ await aiService.analyzeResume({
+ provider: input.provider,
+ model: input.model,
+ apiKey: input.apiKey,
+ baseURL: input.baseURL,
+ resumeData: input.resumeData,
+ }),
+ );
+
+ return await resumeService.analysis.upsert({
+ id: input.resumeId,
+ userId: context.user.id,
+ analysis: {
+ ...analysis,
+ updatedAt: new Date(),
+ modelMeta: {
+ provider: input.provider,
+ model: input.model,
+ },
+ },
+ });
+ } catch (error) {
+ if (error instanceof AISDKError || error instanceof OllamaError) {
+ throw new ORPCError("BAD_GATEWAY", { message: error.message });
+ }
+
+ if (error instanceof ZodError) {
+ throw new ORPCError("BAD_REQUEST", {
+ message: "Invalid resume analysis structure",
+ cause: flattenError(error),
+ });
+ }
+
throw error;
}
}),
diff --git a/src/integrations/orpc/router/resume.ts b/src/integrations/orpc/router/resume.ts
index b598f1506..70f14e9c4 100644
--- a/src/integrations/orpc/router/resume.ts
+++ b/src/integrations/orpc/router/resume.ts
@@ -1,5 +1,6 @@
import z from "zod";
+import { storedResumeAnalysisSchema } from "@/schema/resume/analysis";
import { sampleResumeData } from "@/schema/resume/sample";
import { generateRandomName, slugify } from "@/utils/string";
@@ -59,9 +60,29 @@ const statisticsRouter = {
}),
};
+const analysisRouter = {
+ getById: protectedProcedure
+ .route({
+ method: "GET",
+ path: "/resumes/{id}/analysis",
+ tags: ["Resume Analysis"],
+ operationId: "getResumeAnalysis",
+ summary: "Get latest resume analysis",
+ description:
+ "Returns the latest persisted AI analysis for the specified resume, if one exists. Requires authentication.",
+ successDescription: "The latest persisted resume analysis, or null if no analysis has been saved yet.",
+ })
+ .input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
+ .output(storedResumeAnalysisSchema.nullable())
+ .handler(async ({ context, input }) => {
+ return await resumeService.analysis.getById({ id: input.id, userId: context.user.id });
+ }),
+};
+
export const resumeRouter = {
tags: tagsRouter,
statistics: statisticsRouter,
+ analysis: analysisRouter,
list: protectedProcedure
.route({
diff --git a/src/integrations/orpc/services/ai.ts b/src/integrations/orpc/services/ai.ts
index d1800f092..323f39145 100644
--- a/src/integrations/orpc/services/ai.ts
+++ b/src/integrations/orpc/services/ai.ts
@@ -22,6 +22,7 @@ import z, { flattenError, ZodError } from "zod";
import type { JobResult } from "@/schema/jobs";
import type { ResumeData } from "@/schema/resume/data";
+import analyzeResumeSystemPromptTemplate from "@/integrations/ai/prompts/analyze-resume-system.md?raw";
import chatSystemPromptTemplate from "@/integrations/ai/prompts/chat-system.md?raw";
import docxParserSystemPrompt from "@/integrations/ai/prompts/docx-parser-system.md?raw";
import docxParserUserPrompt from "@/integrations/ai/prompts/docx-parser-user.md?raw";
@@ -33,6 +34,7 @@ import {
patchResumeDescription,
patchResumeInputSchema,
} from "@/integrations/ai/tools/patch-resume";
+import { resumeAnalysisSchema, type ResumeAnalysis } from "@/schema/resume/analysis";
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
import { type TailorOutput, tailorOutputSchema } from "@/schema/tailor";
import { buildAiExtractionTemplate } from "@/utils/ai-template";
@@ -370,6 +372,38 @@ type TailorResumeInput = z.infer & {
job: JobResult;
};
+type AnalyzeResumeInput = z.infer & {
+ resumeData: ResumeData;
+};
+
+function buildAnalyzeResumeSystemPrompt(resumeData: ResumeData): string {
+ return analyzeResumeSystemPromptTemplate + `\n\n## Resume Data\n\n${JSON.stringify(resumeData, null, 2)}`;
+}
+
+async function analyzeResume(input: AnalyzeResumeInput): Promise {
+ const model = getModel(input);
+ const systemPrompt = buildAnalyzeResumeSystemPrompt(input.resumeData);
+
+ const result = await generateText({
+ model,
+ output: Output.object({ schema: resumeAnalysisSchema }),
+ messages: [
+ { role: "system", content: systemPrompt },
+ {
+ role: "user",
+ content:
+ "Analyze this resume and return a structured report with scorecard, overall score, strengths, and actionable suggestions.",
+ },
+ ],
+ });
+
+ if (result.output == null) {
+ throw new Error("AI returned no structured analysis output.");
+ }
+
+ return resumeAnalysisSchema.parse(result.output);
+}
+
async function tailorResume(input: TailorResumeInput): Promise {
const model = getModel(input);
const systemPrompt = buildTailorSystemPrompt(input.resumeData, input.job);
@@ -394,6 +428,7 @@ async function tailorResume(input: TailorResumeInput): Promise {
}
export const aiService = {
+ analyzeResume,
chat,
parseDocx,
parsePdf,
diff --git a/src/integrations/orpc/services/resume.ts b/src/integrations/orpc/services/resume.ts
index 5e41f51eb..a366ba802 100644
--- a/src/integrations/orpc/services/resume.ts
+++ b/src/integrations/orpc/services/resume.ts
@@ -10,6 +10,7 @@ import type { Locale } from "@/utils/locale";
import { schema } from "@/integrations/drizzle";
import { db } from "@/integrations/drizzle/client";
+import { type StoredResumeAnalysis } from "@/schema/resume/analysis";
import { defaultResumeData } from "@/schema/resume/data";
import { env } from "@/utils/env";
import { hashPassword, verifyPassword } from "@/utils/password";
@@ -83,9 +84,47 @@ const statistics = {
},
};
+const analysis = {
+ getById: async (input: { id: string; userId: string }) => {
+ const [result] = await db
+ .select({ analysis: schema.resumeAnalysis.analysis })
+ .from(schema.resume)
+ .leftJoin(schema.resumeAnalysis, eq(schema.resumeAnalysis.resumeId, schema.resume.id))
+ .where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
+
+ if (!result) throw new ORPCError("NOT_FOUND");
+ return result.analysis ?? null;
+ },
+
+ upsert: async (input: { id: string; userId: string; analysis: StoredResumeAnalysis }) => {
+ const [resume] = await db
+ .select({ id: schema.resume.id })
+ .from(schema.resume)
+ .where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
+
+ if (!resume) throw new ORPCError("NOT_FOUND");
+
+ await db
+ .insert(schema.resumeAnalysis)
+ .values({
+ resumeId: input.id,
+ analysis: input.analysis,
+ })
+ .onConflictDoUpdate({
+ target: [schema.resumeAnalysis.resumeId],
+ set: {
+ analysis: input.analysis,
+ },
+ });
+
+ return input.analysis;
+ },
+};
+
export const resumeService = {
tags,
statistics,
+ analysis,
list: async (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
return await db
diff --git a/src/routes/builder/$resumeId/-sidebar/right/index.tsx b/src/routes/builder/$resumeId/-sidebar/right/index.tsx
index 942a88e21..c9257c989 100644
--- a/src/routes/builder/$resumeId/-sidebar/right/index.tsx
+++ b/src/routes/builder/$resumeId/-sidebar/right/index.tsx
@@ -21,6 +21,7 @@ import { InformationSectionBuilder } from "./sections/information";
import { LayoutSectionBuilder } from "./sections/layout";
import { NotesSectionBuilder } from "./sections/notes";
import { PageSectionBuilder } from "./sections/page";
+import { ResumeAnalysisSectionBuilder } from "./sections/resume-analysis";
import { SharingSectionBuilder } from "./sections/sharing";
import { StatisticsSectionBuilder } from "./sections/statistics";
import { TemplateSectionBuilder } from "./sections/template";
@@ -37,6 +38,7 @@ function getSectionComponent(type: RightSidebarSection) {
.with("notes", () => )
.with("sharing", () => )
.with("statistics", () => )
+ .with("analysis", () => )
.with("export", () => )
.with("information", () => )
.exhaustive();
diff --git a/src/routes/builder/$resumeId/-sidebar/right/sections/resume-analysis.tsx b/src/routes/builder/$resumeId/-sidebar/right/sections/resume-analysis.tsx
new file mode 100644
index 000000000..9fdff07be
--- /dev/null
+++ b/src/routes/builder/$resumeId/-sidebar/right/sections/resume-analysis.tsx
@@ -0,0 +1,236 @@
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { useMemo } from "react";
+import { toast } from "sonner";
+import { match } from "ts-pattern";
+
+import { useResumeStore } from "@/components/resume/store/resume";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { useAIStore } from "@/integrations/ai/store";
+import { orpc } from "@/integrations/orpc/client";
+
+import { SectionBase } from "../shared/section-base";
+
+function impactCircleClass(impact: "high" | "medium" | "low") {
+ return match(impact)
+ .with("high", () => "bg-rose-600")
+ .with("medium", () => "bg-amber-600")
+ .with("low", () => "bg-emerald-600")
+ .exhaustive();
+}
+
+export function ResumeAnalysisSectionBuilder() {
+ const queryClient = useQueryClient();
+
+ const resume = useResumeStore((state) => state.resume);
+ const aiEnabled = useAIStore((state) => state.enabled);
+ const aiProvider = useAIStore((state) => state.provider);
+ const aiModel = useAIStore((state) => state.model);
+ const aiApiKey = useAIStore((state) => state.apiKey);
+ const aiBaseURL = useAIStore((state) => state.baseURL);
+
+ const analysisQuery = useQuery(orpc.resume.analysis.getById.queryOptions({ input: { id: resume.id } }));
+
+ const { mutate: analyzeResume, isPending } = useMutation({
+ ...orpc.ai.analyzeResume.mutationOptions(),
+ onSuccess: (analysis) => {
+ queryClient.setQueryData(orpc.resume.analysis.getById.queryKey({ input: { id: resume.id } }), analysis);
+ toast.success(t`Resume analysis complete.`);
+ },
+ onError: (error) => {
+ toast.error(t`Failed to analyze resume.`, { description: error.message });
+ },
+ });
+
+ const analysis = analysisQuery.data;
+ const score = analysis?.overallScore ?? null;
+ const analyzeLabel = isPending ? t`Analyzing...` : t`Analyze Resume`;
+
+ const scoreTone = useMemo(() => {
+ if (score == null) return "bg-muted";
+ if (score >= 80) return "bg-emerald-600";
+ if (score >= 60) return "bg-amber-600";
+ return "bg-rose-600";
+ }, [score]);
+
+ const onAnalyze = () => {
+ analyzeResume({
+ provider: aiProvider,
+ model: aiModel,
+ apiKey: aiApiKey,
+ baseURL: aiBaseURL,
+ resumeId: resume.id,
+ resumeData: resume.data,
+ });
+ };
+
+ return (
+
+ {!aiEnabled && }
+
+ {aiEnabled && (
+ <>
+
+
+
+
+
+
+ Get a review of your resume with an overall score, strengths, and actionable suggestions.
+
+
+
+
+
+
+ {analyzeLabel}
+
+
+
+
+
+ {score ?? "--"}
+
+
+
+
+ Overall Score
+
+
+ {Array.from({ length: 10 }).map((_, index) => {
+ const active = score != null && index < Math.round(score / 10);
+ return (
+
+ );
+ })}
+
+ {analysis?.updatedAt && (
+
+ Last analyzed on {new Date(analysis.updatedAt).toLocaleString()}
+
+ )}
+
+
+
+
+ {!analysis && !isPending && (
+
+
+ Run your first analysis to get a scorecard, strengths, and prioritized suggestions.
+
+
+ )}
+
+ {analysis && (
+
+
+
+
+ Scorecard
+
+
+
+ {analysis.scorecard.map((item, index) => (
+
+
+
{item.dimension}
+
{item.score}/100
+
+
{item.rationale}
+
+ ))}
+
+
+
+ {analysis.strengths.length > 0 && (
+
+
+ Strengths
+
+
+
+ {analysis.strengths.map((strength, index) => (
+
+ {strength}
+
+ ))}
+
+
+ )}
+
+ {analysis.suggestions.length > 0 && (
+
+
+ Suggestions
+
+
+
+ {analysis.suggestions.map((suggestion, index) => (
+
+
+
+
{suggestion.why}
+
+ {suggestion.exampleRewrite && (
+
+ {suggestion.exampleRewrite}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )}
+
+ >
+ )}
+
+ );
+}
+
+function DisabledState() {
+ return (
+
+
+
+
+
+ Get an in-depth AI-powered review of your resume with an overall score, key strengths, and practical
+ suggestions. To activate this feature, please update your AI settings.
+
+
+
+
+ Open AI Settings
+
+
+ }
+ />
+
+
+ );
+}
diff --git a/src/schema/resume/analysis.ts b/src/schema/resume/analysis.ts
new file mode 100644
index 000000000..52f07144e
--- /dev/null
+++ b/src/schema/resume/analysis.ts
@@ -0,0 +1,33 @@
+import z from "zod";
+
+export const analysisDimensionSchema = z.object({
+ dimension: z.string().min(1),
+ score: z.number().int().min(0).max(100),
+ rationale: z.string().min(1),
+});
+
+export const analysisSuggestionSchema = z.object({
+ title: z.string().min(1),
+ impact: z.enum(["high", "medium", "low"]),
+ why: z.string().min(1),
+ exampleRewrite: z.string().nullable(),
+ copyPrompt: z.string().min(1),
+});
+
+export const resumeAnalysisSchema = z.object({
+ overallScore: z.number().int().min(0).max(100),
+ scorecard: z.array(analysisDimensionSchema).min(1),
+ suggestions: z.array(analysisSuggestionSchema).max(10),
+ strengths: z.array(z.string().min(1)).max(10),
+});
+
+export const storedResumeAnalysisSchema = resumeAnalysisSchema.extend({
+ updatedAt: z.coerce.date(),
+ modelMeta: z.object({
+ provider: z.string().min(1),
+ model: z.string().min(1),
+ }),
+});
+
+export type ResumeAnalysis = z.infer;
+export type StoredResumeAnalysis = z.infer;
diff --git a/src/utils/resume/section.tsx b/src/utils/resume/section.tsx
index 204c544c2..a2305c834 100644
--- a/src/utils/resume/section.tsx
+++ b/src/utils/resume/section.tsx
@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import {
ArticleIcon,
+ BrainIcon,
BooksIcon,
BriefcaseIcon,
CertificateIcon,
@@ -51,6 +52,7 @@ export type RightSidebarSection =
| "notes"
| "sharing"
| "statistics"
+ | "analysis"
| "export"
| "information";
@@ -85,6 +87,7 @@ export const rightSidebarSections: RightSidebarSection[] = [
"notes",
"sharing",
"statistics",
+ "analysis",
"export",
"information",
] as const;
@@ -123,6 +126,7 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string =
.with("notes", () => t`Notes`)
.with("sharing", () => t`Sharing`)
.with("statistics", () => t`Statistics`)
+ .with("analysis", () => t`Resume Analysis`)
.with("export", () => t`Export`)
.with("information", () => t`Information`)
@@ -166,6 +170,7 @@ export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: Ic
.with("notes", () => )
.with("sharing", () => )
.with("statistics", () => )
+ .with("analysis", () => )
.with("export", () => )
.with("information", () => )