From 00277d1da795d7031ba9ffd4cf1a03b6a56879b1 Mon Sep 17 00:00:00 2001 From: Aditya Mer Date: Sun, 22 Feb 2026 13:37:06 +0530 Subject: [PATCH 1/7] feat: add Gemini-powered LaTeX resume builder --- .env.example | 1 + README.md | 29 +++ convex/functions.ts | 77 ++++++ convex/schema.ts | 15 ++ src/app/api/generate-resume-latex/route.ts | 148 +++++++++++ src/app/dashboard/page.tsx | 8 + src/app/dashboard/resume-builder/page.tsx | 243 ++++++++++++++++++ src/components/layout/index.tsx | 8 +- src/lib/contracts/api.ts | 21 ++ src/lib/convex-server.ts | 82 ++++++ src/lib/gemini.ts | 143 ++++++++++- src/lib/resume-latex.ts | 216 ++++++++++++++++ .../api/generate-resume-latex/route.test.ts | 105 ++++++++ test/lib/gemini.test.ts | 23 +- test/lib/resume-latex.test.ts | 46 ++++ 15 files changed, 1150 insertions(+), 15 deletions(-) create mode 100644 src/app/api/generate-resume-latex/route.ts create mode 100644 src/app/dashboard/resume-builder/page.tsx create mode 100644 src/lib/resume-latex.ts create mode 100644 test/app/api/generate-resume-latex/route.test.ts create mode 100644 test/lib/resume-latex.test.ts diff --git a/.env.example b/.env.example index ab9ae7a..e4f6659 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ MODEL_NAME="gemini-2.5-flash" AI_TIMEOUT_MS="30000" PDF_PARSE_TIMEOUT_MS="12000" COVER_LETTER_ROUTE_TIMEOUT_MS="35000" +RESUME_ROUTE_TIMEOUT_MS="45000" # Convex Variables NEXT_PUBLIC_CONVEX_URL="" diff --git a/README.md b/README.md index bfff794..6a8035e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ AI-powered resume analysis and cover-letter generation built with Next.js, Stack - Resume analysis with match scoring, strengths, weaknesses, skills match, and recommendations - Cover-letter generation with configurable tone and length +- Tailored LaTeX resume generation using selectable templates - PDF resume upload and parsing - Searchable history for analyses and cover letters - Auth-protected dashboard flows @@ -68,6 +69,7 @@ MODEL_NAME="gemini-2.5-flash" AI_TIMEOUT_MS="30000" PDF_PARSE_TIMEOUT_MS="12000" COVER_LETTER_ROUTE_TIMEOUT_MS="35000" +RESUME_ROUTE_TIMEOUT_MS="45000" UPSTASH_REDIS_REST_URL="" UPSTASH_REDIS_REST_TOKEN="" # Only set true for local emergency fallback; keep false/empty in production @@ -103,6 +105,7 @@ Primary user flows are under dashboard routes: - `/dashboard/analysis/[slug]` - `/dashboard/cover-letter` - `/dashboard/cover-letter/[slug]` +- `/dashboard/resume-builder` - `/dashboard/history` - `/dashboard/upload` @@ -169,6 +172,31 @@ Error response shape: - `{ code, message, details?, requestId }` with status `400 | 401 | 429 | 500 | 503 | 504` +### `POST /api/generate-resume-latex` + +Generates a job-tailored LaTeX resume using a selected template. + +Body fields: + +- `resumeText: string` (required) +- `jobDescription: string` (required) +- `templateId: "awesome-classic" | "deedy-modern" | "sb2nov-ats"` (optional, default `awesome-classic`) +- `resumeName?: string` +- `forceRegenerate?: boolean` + +Success response: + +- `latexSource: string` +- `structuredData: object` +- `templateId: string` +- `cached: boolean` +- `source?: "database"` +- `documentId?: string` + +Error response shape: + +- `{ code, message, details?, requestId }` with status `400 | 401 | 429 | 500 | 503 | 504` + ### Other API routes - `POST /api/parse-pdf` @@ -206,6 +234,7 @@ src/app/ analysis/ cover-letter/ history/ + resume-builder/ upload/ handler/[...stack]/ page.tsx diff --git a/convex/functions.ts b/convex/functions.ts index 7607d5d..12e84ce 100644 --- a/convex/functions.ts +++ b/convex/functions.ts @@ -245,6 +245,83 @@ export const getUserCoverLetters = query({ }, }); +// ─── Tailored Resume Functions ─────────────────────────────────────── + +export const saveTailoredResume = mutation({ + args: { + userId: v.string(), + resumeHash: v.string(), + jobDescriptionHash: v.string(), + templateId: v.string(), + jobTitle: v.optional(v.string()), + companyName: v.optional(v.string()), + resumeName: v.optional(v.string()), + jobDescription: v.optional(v.string()), + structuredData: v.string(), + latexSource: v.string(), + }, + handler: async (ctx, args) => { + const id = await ctx.db.insert("tailoredResumes", args); + const doc = await ctx.db.get(id); + return doc; + }, +}); + +export const getTailoredResume = query({ + args: { + userId: v.string(), + resumeHash: v.string(), + jobDescriptionHash: v.string(), + templateId: v.string(), + }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("tailoredResumes") + .filter((q) => + q.and( + q.eq(q.field("userId"), args.userId), + q.eq(q.field("resumeHash"), args.resumeHash), + q.eq(q.field("jobDescriptionHash"), args.jobDescriptionHash), + q.eq(q.field("templateId"), args.templateId), + ) + ) + .order("desc") + .first(); + return doc; + }, +}); + +export const getTailoredResumeById = query({ + args: { tailoredResumeId: v.id("tailoredResumes") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.tailoredResumeId); + }, +}); + +export const deleteTailoredResume = mutation({ + args: { tailoredResumeId: v.id("tailoredResumes") }, + handler: async (ctx, args) => { + await ctx.db.delete(args.tailoredResumeId); + return { success: true }; + }, +}); + +export const getUserTailoredResumes = query({ + args: { + userId: v.string(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = args.limit ?? 20; + const docs = await ctx.db + .query("tailoredResumes") + .withIndex("by_userId", (q) => q.eq("userId", args.userId)) + .order("desc") + .take(limit); + return docs; + }, +}); + // ─── Stats ─────────────────────────────────────────────────────────── export const getUserStats = query({ diff --git a/convex/schema.ts b/convex/schema.ts index e2ee07d..d4a1295 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -40,4 +40,19 @@ export default defineSchema({ }) .index("by_userId", ["userId"]) .index("by_lookup", ["userId", "resumeHash", "jobDescriptionHash", "tone", "length"]), + + tailoredResumes: defineTable({ + userId: v.string(), + resumeHash: v.string(), + jobDescriptionHash: v.string(), + templateId: v.string(), + jobTitle: v.optional(v.string()), + companyName: v.optional(v.string()), + resumeName: v.optional(v.string()), + jobDescription: v.optional(v.string()), + structuredData: v.string(), + latexSource: v.string(), + }) + .index("by_userId", ["userId"]) + .index("by_lookup", ["userId", "resumeHash", "jobDescriptionHash", "templateId"]), }); diff --git a/src/app/api/generate-resume-latex/route.ts b/src/app/api/generate-resume-latex/route.ts new file mode 100644 index 0000000..7e56537 --- /dev/null +++ b/src/app/api/generate-resume-latex/route.ts @@ -0,0 +1,148 @@ +import { randomUUID } from 'crypto'; +import { NextRequest } from 'next/server'; + +import { apiError, apiSuccess } from '@/lib/api-response'; +import { withTimeout } from '@/lib/async-timeout'; +import { checkRateLimit, getAuthenticatedUser } from '@/lib/auth'; +import { tailoredResumeRequestSchema } from '@/lib/contracts/api'; +import { generateHash, getTailoredResume, saveTailoredResume } from '@/lib/convex-server'; +import { generateTailoredResumeData } from '@/lib/gemini'; +import { getIdempotentResponse, setIdempotentResponse } from '@/lib/idempotency'; +import { buildLatexResume } from '@/lib/resume-latex'; + +const RESUME_ROUTE_TIMEOUT_MS = Number(process.env.RESUME_ROUTE_TIMEOUT_MS || 45000); + +export async function POST(request: NextRequest) { + const requestId = request.headers.get('x-request-id') ?? randomUUID(); + + try { + const userId = await getAuthenticatedUser(); + if (!userId) { + return apiError(requestId, 401, 'AUTH_REQUIRED', 'Authentication required'); + } + + const rateLimit = await checkRateLimit(`resume-latex-${userId}`, { windowMs: 60000, maxRequests: 12 }); + if (!rateLimit.allowed) { + return apiError( + requestId, + 429, + 'RATE_LIMITED', + `Rate limit exceeded. Try again in ${Math.ceil(rateLimit.resetIn / 1000)} seconds.`, + ); + } + + const parse = tailoredResumeRequestSchema.safeParse(await request.json()); + if (!parse.success) { + return apiError(requestId, 400, 'VALIDATION_ERROR', 'Invalid request payload', parse.error.flatten()); + } + + const payload = parse.data; + const { + resumeText, + jobDescription, + templateId, + resumeName, + forceRegenerate, + idempotencyKey, + } = payload; + + const idemHeader = request.headers.get('idempotency-key')?.trim(); + const effectiveIdempotencyKey = idempotencyKey ?? idemHeader; + + if (effectiveIdempotencyKey) { + const replay = getIdempotentResponse>(`${userId}:tailoredResume:${effectiveIdempotencyKey}`); + if (replay) { + return apiSuccess(replay.payload); + } + } + + const resumeHash = generateHash(resumeText); + const jobDescriptionHash = generateHash(jobDescription); + + if (!forceRegenerate) { + const cached = await getTailoredResume(userId, resumeHash, jobDescriptionHash, templateId); + if (cached) { + let structuredData: Record = {}; + try { + structuredData = JSON.parse(cached.structuredData) as Record; + } catch { + structuredData = {}; + } + + const response = { + latexSource: cached.latexSource, + structuredData, + templateId: cached.templateId, + cached: true, + source: 'database' as const, + documentId: cached._id, + requestId, + }; + + if (effectiveIdempotencyKey) { + setIdempotentResponse(`${userId}:tailoredResume:${effectiveIdempotencyKey}`, { status: 200, payload: response }); + } + + return apiSuccess(response); + } + } + + const structuredData = await withTimeout( + generateTailoredResumeData(resumeText, jobDescription), + RESUME_ROUTE_TIMEOUT_MS, + 'Resume generation timed out. Please try again.', + ); + + const latexSource = buildLatexResume(templateId, structuredData); + + let documentId: string | undefined; + try { + const saved = await saveTailoredResume({ + userId, + resumeHash, + jobDescriptionHash, + templateId, + jobTitle: structuredData.targetTitle, + resumeName, + jobDescription, + structuredData: JSON.stringify(structuredData), + latexSource, + }); + documentId = saved._id; + } catch (saveError) { + console.error('Error saving tailored resume', { requestId, error: saveError }); + } + + const response = { + latexSource, + structuredData, + templateId, + cached: false, + documentId, + requestId, + }; + + if (effectiveIdempotencyKey) { + setIdempotentResponse(`${userId}:tailoredResume:${effectiveIdempotencyKey}`, { status: 200, payload: response }); + } + + return apiSuccess(response); + } catch (error) { + console.error('Tailored resume generation error', { requestId, error }); + + if (error instanceof Error) { + if (error.message.includes('timed out')) { + return apiError(requestId, 504, 'RESUME_TIMEOUT', error.message); + } + if (error.message.includes('quota') || error.message.includes('rate')) { + return apiError(requestId, 429, 'UPSTREAM_RATE_LIMITED', 'AI rate limit exceeded. Please try again shortly.'); + } + if (error.message.includes('API key')) { + return apiError(requestId, 500, 'AI_CONFIG_ERROR', 'AI service configuration error. Please contact support.'); + } + return apiError(requestId, 500, 'RESUME_GENERATION_FAILED', error.message); + } + + return apiError(requestId, 500, 'RESUME_GENERATION_FAILED', 'Failed to generate tailored resume'); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 33550ca..fac2a69 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -6,6 +6,7 @@ import { BarChart3, Clock, Compass, + FileCode2, FileText, Loader2, Sparkles, @@ -45,6 +46,13 @@ const actions = [ icon: Sparkles, cta: 'Create letter', }, + { + title: 'Build LaTeX Resume', + description: 'Generate a job-tailored resume in popular LaTeX templates.', + href: '/dashboard/resume-builder', + icon: FileCode2, + cta: 'Open builder', + }, { title: 'View History', description: 'Review previous analyses and letters so you can iterate fast.', diff --git a/src/app/dashboard/resume-builder/page.tsx b/src/app/dashboard/resume-builder/page.tsx new file mode 100644 index 0000000..b84f8b3 --- /dev/null +++ b/src/app/dashboard/resume-builder/page.tsx @@ -0,0 +1,243 @@ +'use client' + +import { AlertCircle, CheckCircle, Copy, Download, FileCode2, FileText, Sparkles } from 'lucide-react' +import { useState } from 'react' + +import { ResumeSelect } from '@/components/resume/ResumeSelect' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { RESUME_TEMPLATE_OPTIONS, type ResumeTemplateId } from '@/lib/resume-latex' + +interface TailoredResumeResponse { + latexSource: string + structuredData: Record + templateId: ResumeTemplateId + cached: boolean + source?: 'database' + documentId?: string + requestId: string +} + +function downloadLatex(content: string, fileName: string) { + const blob = new Blob([content], { type: 'application/x-tex;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +} + +function safeFileName(input: string) { + const normalized = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-') + return normalized.length > 0 ? normalized.slice(0, 80) : 'tailored-resume' +} + +export default function ResumeBuilderPage() { + const [jobDescription, setJobDescription] = useState('') + const [resumeText, setResumeText] = useState(null) + const [resumeName, setResumeName] = useState(null) + const [templateId, setTemplateId] = useState('awesome-classic') + const [isGenerating, setIsGenerating] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + const [copied, setCopied] = useState(false) + + const handleGenerate = async () => { + if (!jobDescription.trim()) { + setError('Please enter a job description') + return + } + if (!resumeText) { + setError('Please select a resume first') + return + } + + setIsGenerating(true) + setError(null) + + try { + const response = await fetch('/api/generate-resume-latex', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + resumeText, + jobDescription: jobDescription.trim(), + templateId, + resumeName: resumeName || undefined, + idempotencyKey: crypto.randomUUID(), + }), + }) + + const data = await response.json() + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to generate LaTeX resume') + } + + setResult(data as TailoredResumeResponse) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to generate resume') + setResult(null) + } finally { + setIsGenerating(false) + } + } + + const handleCopy = async () => { + if (!result?.latexSource) return + await navigator.clipboard.writeText(result.latexSource) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const handleDownload = () => { + if (!result?.latexSource) return + const fileBase = safeFileName(resumeName || 'tailored-resume') + downloadLatex(result.latexSource, `${fileBase}-${templateId}.tex`) + } + + return ( +
+
+
+
+
+ +
+
+
+ + Resume Builder +
+

Generate tailored LaTeX resume

+

+ Pick a resume template, paste the job description, and generate role-tailored LaTeX source powered by Gemini. +

+
+ +
+
+
+
+ +
+
+

Job description

+

Paste complete role requirements for best tailoring.

+
+
+ +