- Wazivo
+
+
+
+
+
+ AI career assistant for modern hiring
+
+
+
+ Turn any resume into a clear, ATS-ready hiring story.
+
+ Wazivo analyzes resume quality, surfaces missing market skills, rewrites weak content, and helps candidates ship stronger applications without creating an account.
+
- Get Hired, Get Wazivo
-
- Lightning-fast AI resume analysis powered by Groq
-
-
-
- {error && (
-
- ❌ {error}
- Please try again or contact support
-
- )}
-
- {/* Progress bar */}
- {loading && progress > 0 && (
-
-
+
+ {highlights.map((item) => (
+ key={item}
+ className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4 text-sm text-slate-200 shadow-[0_0_0_1px_rgba(255,255,255,0.02)] backdrop-blur"
+ >
+ {item}
+
+ ))}
+
+
+
+
+
+ Why teams will trust it
+ Built like a real product MVP
+
+ - Typed APIs with safe JSON responses
+ - Groq-backed analysis with heuristic fallback
+ - Hash-based caching and anonymous rate limiting
+ - Responsive dashboard UI for analysis, rewrite, and cover letters
+
- )}
-
- {loading ? (
-
- ) : result ? (
- <>
-
-
- >
- ) : (
-
- )}
-
-
+
-
-
+
+
+
+
);
}
diff --git a/src/components/MissingSkills.tsx b/src/components/MissingSkills.tsx
new file mode 100644
index 0000000..f4021f2
--- /dev/null
+++ b/src/components/MissingSkills.tsx
@@ -0,0 +1,27 @@
+type MissingSkillsProps = {
+ skills: string[];
+};
+
+export default function MissingSkills({ skills }: MissingSkillsProps) {
+ return (
+
+
+ Missing market skills
+
+ Priority gaps
+
+
+
+ {skills.length ? (
+ skills.map((skill) => (
+
+ {skill}
+
+ ))
+ ) : (
+ No major missing skills detected for the current profile.
+ )}
+
+
+ );
+}
diff --git a/src/components/Report.tsx b/src/components/Report.tsx
new file mode 100644
index 0000000..b22db40
--- /dev/null
+++ b/src/components/Report.tsx
@@ -0,0 +1,39 @@
+import { buildReportSections } from '../lib/reportGenerator';
+import type { ResumeAnalysis } from '../lib/resumeAnalyzer';
+
+type ReportProps = {
+ analysis: ResumeAnalysis;
+};
+
+export default function Report({ analysis }: ReportProps) {
+ const sections = buildReportSections(analysis);
+
+ return (
+
+
+
+ Professional report
+ Candidate readiness snapshot
+
+
+ Score: {analysis.score}/100
+
+
+
+
+ {sections.map((section) => (
+
+ {section.title}
+
+ {section.items.map((item) => (
+ -
+ {item}
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/ResumeUpload.tsx b/src/components/ResumeUpload.tsx
new file mode 100644
index 0000000..61ac053
--- /dev/null
+++ b/src/components/ResumeUpload.tsx
@@ -0,0 +1,223 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+
+import MissingSkills from './MissingSkills';
+import Report from './Report';
+import ScoreCard from './ScoreCard';
+import SkillsList from './SkillsList';
+import type { ResumeAnalysis } from '../lib/resumeAnalyzer';
+
+type ApiError = {
+ error: string;
+};
+
+type AnalyzeResponse = {
+ data: ResumeAnalysis;
+ cached?: boolean;
+};
+
+export default function ResumeUpload() {
+ const [resumeText, setResumeText] = useState('');
+ const [jobDescription, setJobDescription] = useState('');
+ const [analysis, setAnalysis] = useState(null);
+ const [rewrittenResume, setRewrittenResume] = useState('');
+ const [coverLetter, setCoverLetter] = useState('');
+ const [loadingAction, setLoadingAction] = useState<'analyze' | 'rewrite' | 'cover-letter' | null>(null);
+ const [error, setError] = useState('');
+
+ const canAnalyze = useMemo(() => resumeText.trim().length >= 120, [resumeText]);
+ const canCreateCoverLetter = canAnalyze && jobDescription.trim().length >= 80;
+
+ async function parseJson(response: Response): Promise {
+ return (await response.json()) as T;
+ }
+
+ async function handleAnalyze() {
+ setLoadingAction('analyze');
+ setError('');
+
+ try {
+ const response = await fetch('/api/analyze', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ resumeText }),
+ });
+
+ const payload = await parseJson(response);
+
+ if (!response.ok || 'error' in payload) {
+ throw new Error('error' in payload ? payload.error : 'Unable to analyze resume.');
+ }
+
+ setAnalysis(payload.data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unable to analyze resume.');
+ } finally {
+ setLoadingAction(null);
+ }
+ }
+
+ async function handleRewrite() {
+ setLoadingAction('rewrite');
+ setError('');
+
+ try {
+ const response = await fetch('/api/rewrite', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ resumeText }),
+ });
+
+ const payload = (await response.json()) as { data?: { rewritten: string }; error?: string };
+
+ if (!response.ok || payload.error || !payload.data) {
+ throw new Error(payload.error || 'Unable to rewrite resume.');
+ }
+
+ setRewrittenResume(payload.data.rewritten);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unable to rewrite resume.');
+ } finally {
+ setLoadingAction(null);
+ }
+ }
+
+ async function handleCoverLetter() {
+ setLoadingAction('cover-letter');
+ setError('');
+
+ try {
+ const response = await fetch('/api/cover-letter', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ resumeText, jobDescription }),
+ });
+
+ const payload = (await response.json()) as { data?: { coverLetter: string }; error?: string };
+
+ if (!response.ok || payload.error || !payload.data) {
+ throw new Error(payload.error || 'Unable to generate cover letter.');
+ }
+
+ setCoverLetter(payload.data.coverLetter);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unable to generate cover letter.');
+ } finally {
+ setLoadingAction(null);
+ }
+ }
+
+ return (
+
+
+
+ Resume workspace
+ Analyze, rewrite, and tailor applications
+
+ Paste a resume to generate a hiring-ready report. Add a job description to create a customized cover letter.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {error ? {error} : null}
+
+
+
+
+ {analysis ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+
+ Results will appear here after analysis. You will get a score, detected skills, missing skill gaps, strengths, weaknesses, and a professional readiness report.
+
+ )}
+
+ {rewrittenResume ? (
+
+
+ ATS rewrite
+ Plain text
+
+
+ {rewrittenResume}
+
+
+ ) : null}
+
+ {coverLetter ? (
+
+
+ Cover letter
+ Generated
+
+
+ {coverLetter}
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/ScoreCard.tsx b/src/components/ScoreCard.tsx
new file mode 100644
index 0000000..4776cc7
--- /dev/null
+++ b/src/components/ScoreCard.tsx
@@ -0,0 +1,33 @@
+type ScoreCardProps = {
+ score: number;
+ careerLevel: string;
+ summary: string;
+};
+
+export default function ScoreCard({ score, careerLevel, summary }: ScoreCardProps) {
+ const tone = score >= 80 ? 'emerald' : score >= 60 ? 'amber' : 'rose';
+ const toneMap = {
+ emerald: 'from-emerald-400/20 to-emerald-500/5 text-emerald-200 border-emerald-400/20',
+ amber: 'from-amber-400/20 to-amber-500/5 text-amber-200 border-amber-400/20',
+ rose: 'from-rose-400/20 to-rose-500/5 text-rose-200 border-rose-400/20',
+ } as const;
+
+ return (
+
+
+
+ Resume score
+
+ {score}
+ / 100
+
+
+
+ Career level
+ {careerLevel}
+
+
+ {summary}
+
+ );
+}
diff --git a/src/components/SkillsList.tsx b/src/components/SkillsList.tsx
new file mode 100644
index 0000000..5b9013c
--- /dev/null
+++ b/src/components/SkillsList.tsx
@@ -0,0 +1,30 @@
+type SkillsListProps = {
+ skills: string[];
+};
+
+export default function SkillsList({ skills }: SkillsListProps) {
+ return (
+
+
+ Detected skills
+
+ {skills.length} total
+
+
+
+ {skills.length ? (
+ skills.map((skill) => (
+
+ {skill}
+
+ ))
+ ) : (
+ No explicit skills were extracted yet.
+ )}
+
+
+ );
+}
diff --git a/src/lib/atsScore.ts b/src/lib/atsScore.ts
new file mode 100644
index 0000000..9fe24b8
--- /dev/null
+++ b/src/lib/atsScore.ts
@@ -0,0 +1,39 @@
+type ScoreInput = {
+ skills: string[];
+ missing_skills: string[];
+ strengths: string[];
+ weaknesses: string[];
+};
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value));
+}
+
+export function calculateATSScore(resumeText: string, input: ScoreInput): number {
+ const sectionChecks = [
+ /summary|profile/i,
+ /experience|employment/i,
+ /skills/i,
+ /education/i,
+ /project/i,
+ ];
+
+ const presentSections = sectionChecks.filter((pattern) => pattern.test(resumeText)).length;
+ const metricsCount = (resumeText.match(/\b\d+%|\b\d+\+|\$\d+/g) || []).length;
+ const bulletCount = (resumeText.match(/^[\-•*]/gm) || []).length;
+ const hasEmail = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i.test(resumeText);
+ const hasLinkedIn = /linkedin\.com/i.test(resumeText);
+
+ let score = 35;
+ score += presentSections * 6;
+ score += Math.min(input.skills.length, 8) * 3;
+ score += Math.min(input.strengths.length, 4) * 2;
+ score += Math.min(metricsCount, 5) * 3;
+ score += Math.min(bulletCount, 8) * 1.5;
+ score += hasEmail ? 4 : 0;
+ score += hasLinkedIn ? 2 : 0;
+ score -= Math.min(input.missing_skills.length, 6) * 2.5;
+ score -= Math.min(input.weaknesses.length, 5) * 2;
+
+ return Math.round(clamp(score, 0, 100));
+}
diff --git a/src/lib/promptTemplates.ts b/src/lib/promptTemplates.ts
new file mode 100644
index 0000000..aa656f1
--- /dev/null
+++ b/src/lib/promptTemplates.ts
@@ -0,0 +1,62 @@
+export const AI_MODEL = process.env.GROQ_MODEL?.trim() || 'llama-3.3-70b-versatile';
+
+const analysisSchema = `Return strict JSON only with this exact shape:
+{
+ "score": number,
+ "career_level": string,
+ "skills": string[],
+ "missing_skills": string[],
+ "strengths": string[],
+ "weaknesses": string[],
+ "summary": string
+}
+Rules:
+- score must be an integer from 0 to 100.
+- career_level must be one short label such as Entry-level, Mid-level, Senior, Lead, Executive.
+- Arrays must contain concise unique strings.
+- summary must be 2-4 sentences.
+- Do not wrap JSON in markdown fences.`;
+
+export const resume_analysis_prompt = (resumeText: string) => `You are Wazivo, an expert AI resume reviewer and ATS specialist.
+Analyze the resume below for hiring readiness, ATS compatibility, transferable skills, missing market skills, and role positioning.
+Prioritize practical hiring feedback over generic advice.
+
+Resume:
+"""
+${resumeText}
+"""
+
+${analysisSchema}`;
+
+export const resume_rewrite_prompt = (resumeText: string) => `You are an elite resume writer.
+Rewrite the following resume into a cleaner, ATS-optimized version.
+Requirements:
+- Keep claims realistic and grounded in the source resume.
+- Use strong action verbs.
+- Improve structure and clarity.
+- Keep output in plain text.
+- Include these sections when the source supports them: Summary, Core Skills, Experience, Projects, Education, Certifications.
+- Use concise bullet points and measurable impact where possible.
+
+Resume:
+"""
+${resumeText}
+"""`;
+
+export const cover_letter_prompt = (resumeText: string, jobDescription: string) => `You are a professional career coach.
+Write a tailored, persuasive cover letter in plain text based on the resume and job description below.
+Requirements:
+- Keep it professional, modern, and concise.
+- Focus on fit, outcomes, and relevant strengths.
+- Use a confident but not exaggerated tone.
+- Do not invent experiences that are not supported by the resume.
+
+Resume:
+"""
+${resumeText}
+"""
+
+Job description:
+"""
+${jobDescription}
+"""`;
diff --git a/src/lib/reportGenerator.ts b/src/lib/reportGenerator.ts
new file mode 100644
index 0000000..2558fb4
--- /dev/null
+++ b/src/lib/reportGenerator.ts
@@ -0,0 +1,39 @@
+import type { ResumeAnalysis } from './resumeAnalyzer';
+
+export type ReportSection = {
+ title: string;
+ items: string[];
+};
+
+export function buildReportSections(analysis: ResumeAnalysis): ReportSection[] {
+ return [
+ {
+ title: 'Strengths',
+ items: analysis.strengths.length ? analysis.strengths : ['Solid baseline profile with clear career potential.'],
+ },
+ {
+ title: 'Missing Skills',
+ items: analysis.missing_skills.length ? analysis.missing_skills : ['No urgent market-skill gaps detected.'],
+ },
+ {
+ title: 'Weaknesses',
+ items: analysis.weaknesses.length ? analysis.weaknesses : ['No major structural weaknesses detected.'],
+ },
+ {
+ title: 'Career Insights',
+ items: [
+ `Current career level: ${analysis.career_level}.`,
+ `Resume score: ${analysis.score}/100.`,
+ analysis.summary,
+ ],
+ },
+ ];
+}
+
+export function buildReportMarkdown(analysis: ResumeAnalysis): string {
+ const sections = buildReportSections(analysis)
+ .map((section) => `## ${section.title}\n${section.items.map((item) => `- ${item}`).join('\n')}`)
+ .join('\n\n');
+
+ return `# Wazivo Report\n\n## Snapshot\n- Score: ${analysis.score}/100\n- Career level: ${analysis.career_level}\n- Skills: ${analysis.skills.join(', ') || 'N/A'}\n\n${sections}`;
+}
diff --git a/src/lib/resumeAnalyzer.ts b/src/lib/resumeAnalyzer.ts
new file mode 100644
index 0000000..af97523
--- /dev/null
+++ b/src/lib/resumeAnalyzer.ts
@@ -0,0 +1,202 @@
+import { calculateATSScore } from './atsScore';
+import {
+ cover_letter_prompt,
+ resume_analysis_prompt,
+ resume_rewrite_prompt,
+ AI_MODEL,
+} from './promptTemplates';
+import {
+ extractSkillsFromText,
+ inferCareerLevel,
+ suggestMissingSkills,
+} from './skillExtractor';
+
+export type ResumeAnalysis = {
+ score: number;
+ career_level: string;
+ skills: string[];
+ missing_skills: string[];
+ strengths: string[];
+ weaknesses: string[];
+ summary: string;
+};
+
+const GROQ_URL = 'https://api.groq.com/openai/v1/chat/completions';
+
+function uniqueStrings(values: unknown): string[] {
+ if (!Array.isArray(values)) return [];
+
+ return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))];
+}
+
+function extractJson(content: string) {
+ const cleaned = content.replace(/```json|```/gi, '').trim();
+ const firstBrace = cleaned.indexOf('{');
+ const lastBrace = cleaned.lastIndexOf('}');
+ if (firstBrace === -1 || lastBrace === -1) {
+ throw new Error('Model did not return valid JSON');
+ }
+
+ return JSON.parse(cleaned.slice(firstBrace, lastBrace + 1)) as Partial;
+}
+
+function buildFallbackAnalysis(resumeText: string): ResumeAnalysis {
+ const skills = extractSkillsFromText(resumeText);
+ const careerLevel = inferCareerLevel(resumeText);
+ const missingSkills = suggestMissingSkills(skills, careerLevel);
+
+ const strengths = [
+ skills.length
+ ? `Shows evidence of ${skills.slice(0, 3).join(', ')} capabilities.`
+ : 'Shows practical experience but could surface more explicit skill keywords.',
+ /\b(project|portfolio|product|platform)\b/i.test(resumeText)
+ ? 'Mentions hands-on project or product work.'
+ : 'Would benefit from stronger project and impact storytelling.',
+ /\b\d+%|\b\d+\+|\$\d+/i.test(resumeText)
+ ? 'Includes measurable outcomes that improve recruiter trust.'
+ : 'Could become stronger with quantified achievements.',
+ ];
+
+ const weaknesses = [
+ missingSkills.length ? `Missing or under-emphasized market skills such as ${missingSkills.slice(0, 3).join(', ')}.` : '',
+ /summary|profile/i.test(resumeText) ? '' : 'Resume lacks a clear professional summary section.',
+ /skills/i.test(resumeText) ? '' : 'Resume should include a dedicated skills section for ATS readability.',
+ ].filter(Boolean);
+
+ const score = calculateATSScore(resumeText, {
+ skills,
+ missing_skills: missingSkills,
+ strengths,
+ weaknesses,
+ });
+
+ return {
+ score,
+ career_level: careerLevel,
+ skills,
+ missing_skills: missingSkills,
+ strengths,
+ weaknesses,
+ summary:
+ 'This resume shows a usable professional baseline, but clearer positioning, stronger keyword coverage, and more quantified achievements would improve ATS performance and recruiter clarity.',
+ };
+}
+
+async function callGroq(prompt: string, temperature = 0.2) {
+ const apiKey = process.env.GROQ_API_KEY?.trim();
+
+ if (!apiKey) {
+ throw new Error('Missing GROQ_API_KEY');
+ }
+
+ const response = await fetch(GROQ_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ cache: 'no-store',
+ body: JSON.stringify({
+ model: AI_MODEL,
+ temperature,
+ messages: [
+ {
+ role: 'system',
+ content:
+ 'You are Wazivo, a precise resume analysis engine. Follow instructions exactly and keep outputs production-ready.',
+ },
+ {
+ role: 'user',
+ content: prompt,
+ },
+ ],
+ }),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Groq request failed: ${text}`);
+ }
+
+ const data = (await response.json()) as {
+ choices?: Array<{ message?: { content?: string } }>;
+ };
+
+ return data.choices?.[0]?.message?.content?.trim() || '';
+}
+
+export async function analyzeResume(resumeText: string): Promise {
+ const heuristic = buildFallbackAnalysis(resumeText);
+
+ try {
+ const content = await callGroq(resume_analysis_prompt(resumeText));
+ const parsed = extractJson(content);
+ const skills = [...new Set([...uniqueStrings(parsed.skills), ...heuristic.skills])].sort((a, b) => a.localeCompare(b));
+ const careerLevel = String(parsed.career_level || heuristic.career_level).trim() || heuristic.career_level;
+ const missingSkills = uniqueStrings(parsed.missing_skills).length
+ ? uniqueStrings(parsed.missing_skills)
+ : suggestMissingSkills(skills, careerLevel);
+ const strengths = uniqueStrings(parsed.strengths).length ? uniqueStrings(parsed.strengths) : heuristic.strengths;
+ const weaknesses = uniqueStrings(parsed.weaknesses).length ? uniqueStrings(parsed.weaknesses) : heuristic.weaknesses;
+ const score = calculateATSScore(resumeText, {
+ skills,
+ missing_skills: missingSkills,
+ strengths,
+ weaknesses,
+ });
+
+ return {
+ score,
+ career_level: careerLevel,
+ skills,
+ missing_skills: missingSkills,
+ strengths,
+ weaknesses,
+ summary: String(parsed.summary || heuristic.summary).trim(),
+ };
+ } catch {
+ return heuristic;
+ }
+}
+
+function fallbackRewrite(resumeText: string) {
+ return [
+ 'PROFESSIONAL SUMMARY',
+ 'Results-driven candidate with practical experience extracted from the source resume. Rewrite this draft further once a Groq API key is configured.',
+ '',
+ 'CORE SKILLS',
+ extractSkillsFromText(resumeText).join(', ') || 'Add technical and functional skills here.',
+ '',
+ 'EXPERIENCE',
+ '- Rewrite each experience bullet with action + impact + tools used.',
+ '- Quantify outcomes wherever possible.',
+ '',
+ 'EDUCATION',
+ '- Add degree, institution, and dates.',
+ '',
+ 'SOURCE RESUME',
+ resumeText,
+ ].join('\n');
+}
+
+export async function rewriteResumeForATS(resumeText: string) {
+ try {
+ return await callGroq(resume_rewrite_prompt(resumeText), 0.4);
+ } catch {
+ return fallbackRewrite(resumeText);
+ }
+}
+
+function inferRoleFromJobDescription(jobDescription: string) {
+ const match = jobDescription.match(/(?:for|as|role of|position of)\s+([A-Z][A-Za-z0-9 \-/]{3,60})/i);
+ return match?.[1]?.trim() || 'the role';
+}
+
+export async function generateCoverLetter(resumeText: string, jobDescription: string) {
+ try {
+ return await callGroq(cover_letter_prompt(resumeText, jobDescription), 0.5);
+ } catch {
+ const role = inferRoleFromJobDescription(jobDescription);
+ return `Dear Hiring Manager,\n\nI am excited to apply for ${role}. My background reflects hands-on experience, adaptable execution, and a strong motivation to contribute quickly. I have developed practical skills that align with modern team needs, including delivery focus, collaboration, and the ability to learn new tools fast.\n\nWhat stands out most in my experience is my ability to turn responsibilities into outcomes while staying user- and business-focused. I would welcome the opportunity to bring that mindset to your team and contribute with professionalism from day one.\n\nThank you for your time and consideration.\n\nSincerely,\nCandidate`;
+ }
+}
diff --git a/src/lib/runtime.ts b/src/lib/runtime.ts
new file mode 100644
index 0000000..bc1b5b8
--- /dev/null
+++ b/src/lib/runtime.ts
@@ -0,0 +1,141 @@
+import { createHash } from 'crypto';
+import type { NextRequest } from 'next/server';
+
+type MemoryCacheEntry = {
+ value: string;
+ expiresAt: number;
+};
+
+type MemoryRateEntry = {
+ count: number;
+ expiresAt: number;
+};
+
+declare global {
+ var __wazivoCache: Map | undefined;
+ var __wazivoRateLimit: Map | undefined;
+}
+
+const memoryCache = globalThis.__wazivoCache ?? (globalThis.__wazivoCache = new Map());
+const memoryRateLimit = globalThis.__wazivoRateLimit ?? (globalThis.__wazivoRateLimit = new Map());
+
+function getRedisConfig() {
+ const url = process.env.UPSTASH_REDIS_REST_URL?.trim();
+ const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim();
+
+ if (!url || !token) return null;
+ return { url, token };
+}
+
+async function redisCommand(args: Array) {
+ const config = getRedisConfig();
+ if (!config) return null;
+
+ const response = await fetch(config.url, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${config.token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(args),
+ cache: 'no-store',
+ });
+
+ if (!response.ok) {
+ throw new Error('Redis command failed');
+ }
+
+ const data = (await response.json()) as { result?: unknown };
+ return data.result ?? null;
+}
+
+export function hashText(value: string) {
+ return createHash('sha256').update(value).digest('hex');
+}
+
+export function normalizeResumeInput(value: string) {
+ return value.replace(/\r\n/g, '\n').replace(/\t/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
+}
+
+export function ensureTextLength(value: string, label: string, min: number, max: number) {
+ if (value.length < min) {
+ throw new Error(`${label} must be at least ${min} characters.`);
+ }
+
+ if (value.length > max) {
+ throw new Error(`${label} must be at most ${max} characters.`);
+ }
+}
+
+export function getRequesterId(request: NextRequest) {
+ const forwardedFor = request.headers.get('x-forwarded-for');
+ const realIp = request.headers.get('x-real-ip');
+ const ip = forwardedFor?.split(',')[0]?.trim() || realIp || 'anonymous';
+ return ip;
+}
+
+export async function getCachedJSON(key: string): Promise {
+ try {
+ const redisValue = await redisCommand(['GET', key]);
+ if (typeof redisValue === 'string') {
+ return JSON.parse(redisValue) as T;
+ }
+ } catch {}
+
+ const item = memoryCache.get(key);
+ if (!item) return null;
+ if (Date.now() > item.expiresAt) {
+ memoryCache.delete(key);
+ return null;
+ }
+
+ return JSON.parse(item.value) as T;
+}
+
+export async function setCachedJSON(key: string, value: T, ttlSeconds: number) {
+ const serialized = JSON.stringify(value);
+
+ try {
+ await redisCommand(['SETEX', key, ttlSeconds, serialized]);
+ return;
+ } catch {}
+
+ memoryCache.set(key, {
+ value: serialized,
+ expiresAt: Date.now() + ttlSeconds * 1000,
+ });
+}
+
+export async function rateLimit(identifier: string, scope: string, limit: number, windowSeconds: number) {
+ const now = Date.now();
+ const bucket = Math.floor(now / (windowSeconds * 1000));
+ const key = `ratelimit:${scope}:${identifier}:${bucket}`;
+
+ try {
+ const count = Number((await redisCommand(['INCR', key])) ?? 0);
+ if (count === 1) {
+ await redisCommand(['EXPIRE', key, windowSeconds]);
+ }
+
+ return {
+ allowed: count <= limit,
+ remaining: Math.max(limit - count, 0),
+ resetAt: (bucket + 1) * windowSeconds * 1000,
+ };
+ } catch {}
+
+ const record = memoryRateLimit.get(key);
+ if (!record || now > record.expiresAt) {
+ memoryRateLimit.set(key, { count: 1, expiresAt: now + windowSeconds * 1000 });
+ return { allowed: true, remaining: Math.max(limit - 1, 0), resetAt: now + windowSeconds * 1000 };
+ }
+
+ record.count += 1;
+ memoryRateLimit.set(key, record);
+
+ return {
+ allowed: record.count <= limit,
+ remaining: Math.max(limit - record.count, 0),
+ resetAt: record.expiresAt,
+ };
+}
diff --git a/src/lib/skillExtractor.ts b/src/lib/skillExtractor.ts
new file mode 100644
index 0000000..c7ded3e
--- /dev/null
+++ b/src/lib/skillExtractor.ts
@@ -0,0 +1,70 @@
+const SKILL_PATTERNS: Record = {
+ JavaScript: [/\bjavascript\b/i, /\bnode\.js\b/i, /\bnodejs\b/i],
+ TypeScript: [/\btypescript\b/i],
+ React: [/\breact\b/i, /\bnext\.js\b/i, /\bnextjs\b/i],
+ Python: [/\bpython\b/i, /\bdjango\b/i, /\bflask\b/i, /\bfastapi\b/i],
+ SQL: [/\bsql\b/i, /\bpostgres\b/i, /\bmysql\b/i, /\bsqlite\b/i],
+ NoSQL: [/\bmongodb\b/i, /\bredis\b/i, /\bfirestore\b/i],
+ Cloud: [/\baws\b/i, /\bazure\b/i, /\bgcp\b/i, /\bgoogle cloud\b/i, /\bvercel\b/i],
+ DevOps: [/\bdocker\b/i, /\bkubernetes\b/i, /\bterraform\b/i, /\bci\/cd\b/i, /\bgithub actions\b/i],
+ APIs: [/\brest\b/i, /\bgraphql\b/i, /\bapi\b/i],
+ Data Analysis: [/\bpandas\b/i, /\bnumpy\b/i, /\bpower bi\b/i, /\btableau\b/i, /\bexcel\b/i],
+ AI: [/\bllm\b/i, /\bgroq\b/i, /\bopenai\b/i, /\bhugging face\b/i, /\bmachine learning\b/i, /\bartificial intelligence\b/i],
+ Testing: [/\bjest\b/i, /\bplaywright\b/i, /\bcypress\b/i, /\bunit test/i],
+ Product: [/\broadmap\b/i, /\bstakeholder\b/i, /\bproduct management\b/i, /\buser research\b/i],
+ Leadership: [/\bled\b/i, /\bmanaged\b/i, /\bmentored\b/i, /\bteam lead\b/i],
+ Communication: [/\bpresented\b/i, /\bcollaborated\b/i, /\bclient\b/i, /\bcommunication\b/i],
+};
+
+const MARKET_BASELINE = [
+ 'SQL',
+ 'TypeScript',
+ 'Cloud',
+ 'DevOps',
+ 'APIs',
+ 'Testing',
+ 'AI',
+ 'Leadership',
+ 'Communication',
+];
+
+export function extractSkillsFromText(resumeText: string): string[] {
+ const found = new Set();
+
+ for (const [skill, patterns] of Object.entries(SKILL_PATTERNS)) {
+ if (patterns.some((pattern) => pattern.test(resumeText))) {
+ found.add(skill);
+ }
+ }
+
+ return [...found].sort((a, b) => a.localeCompare(b));
+}
+
+export function inferCareerLevel(resumeText: string): string {
+ const normalized = resumeText.toLowerCase();
+ const yearMatches = [...normalized.matchAll(/(\d{1,2})\+?\s+years?/g)].map((match) => Number(match[1]));
+ const years = yearMatches.length ? Math.max(...yearMatches) : 0;
+
+ if (/director|head of|vp|vice president|chief/i.test(resumeText)) return 'Executive';
+ if (/lead|principal|staff engineer|engineering manager/i.test(resumeText) || years >= 8) return 'Lead';
+ if (/senior|sr\.|architect/i.test(resumeText) || years >= 5) return 'Senior';
+ if (years >= 2) return 'Mid-level';
+ return 'Entry-level';
+}
+
+export function suggestMissingSkills(skills: string[], careerLevel: string): string[] {
+ const owned = new Set(skills);
+ const target = [...MARKET_BASELINE];
+
+ if (careerLevel === 'Senior' || careerLevel === 'Lead' || careerLevel === 'Executive') {
+ target.push('Product');
+ }
+
+ if (careerLevel === 'Lead' || careerLevel === 'Executive') {
+ target.push('Leadership');
+ }
+
+ return [...new Set(target)]
+ .filter((skill) => !owned.has(skill))
+ .slice(0, 6);
+}
+ Turn any resume into a clear, ATS-ready hiring story.
++ Wazivo analyzes resume quality, surfaces missing market skills, rewrites weak content, and helps candidates ship stronger applications without creating an account. +
Get Hired, Get Wazivo
-- Lightning-fast AI resume analysis powered by Groq -
- - - {error && ( -❌ {error}
-Please try again or contact support
-Why teams will trust it
+Built like a real product MVP
-
+
- Typed APIs with safe JSON responses +
- Groq-backed analysis with heuristic fallback +
- Hash-based caching and anonymous rate limiting +
- Responsive dashboard UI for analysis, rewrite, and cover letters +
Missing market skills
+ + Priority gaps + +No major missing skills detected for the current profile.
+ )} +Professional report
+Candidate readiness snapshot
+{section.title}
+-
+ {section.items.map((item) => (
+
- + {item} + + ))} +
Resume workspace
+Analyze, rewrite, and tailor applications
++ Paste a resume to generate a hiring-ready report. Add a job description to create a customized cover letter. +
+ATS rewrite
+ Plain text +
+ {rewrittenResume}
+
+ Cover letter
+ Generated +
+ {coverLetter}
+
+ Resume score
+Career level
+{careerLevel}
+{summary}
+Detected skills
+ + {skills.length} total + +No explicit skills were extracted yet.
+ )} +