A technical case study from Pedersen Consulting
Published: December 2025
TL;DR
- Timeline: 3 weeks from concept to production
- Stack: Nuxt 4, Prisma, PostgreSQL, Claude 4.5 Sonnet (Anthropic)
- Scale: 500+ CV evaluations, 23 languages, 100+ job categories
- Key Innovation: AI-powered CV evaluation with GDPR-compliant data management
The Challenge
A recruitment agency approached us with a problem: their manual CV screening process was taking 30-45 minutes per candidate, and they were missing "hidden gem" candidates who didn't fit traditional criteria.
They needed a system that could:
- Evaluate CVs against job descriptions using AI - not just keyword matching
- Process multiple languages - French, German, Spanish, Arabic, etc.
- Maintain GDPR compliance - automatic data deletion, export, user control
- Scale from zero to production - handle real users immediately
- Ship fast - they needed it running in weeks, not months
Why Speed Mattered
The client was bleeding time and money on manual screening. Every week of development delay meant:
- 40+ hours of manual CV review per recruiter
- Lost candidates to competitors with faster processes
- Missed revenue from unfilled positions
We had to ship something usable fast, then iterate based on real user feedback.
Technical Architecture Decisions
1. Framework: Nuxt 4 (Vue 3 + Server-Side)
Why Nuxt:
- Full-stack in one codebase (no separate backend/frontend repos)
- Server-side API routes for security-sensitive operations
- Built-in SSR for SEO (critical for their public job pages)
- Fast development with file-based routing
Alternative considered: Next.js - rejected because team had Vue expertise
// Example: API route with built-in auth and database access
export default defineEventHandler(async (event) => {
const user = await serverSupabaseUser(event)
const candidate = await prisma.candidate.findUnique({
where: { id: params.id }
})
return { candidate }
})
2. Database: PostgreSQL + Prisma
Why Prisma:
- Type-safe database queries (caught bugs at compile time)
- Schema migrations handled automatically
- Supports complex relations without writing SQL
Schema highlight - GDPR compliance built in:
model PublicEvaluation {
id String @id @default(cuid())
createdAt DateTime @default(now())
expiresAt DateTime // Auto-delete after 30 days
// GDPR fields
gdprConsent Boolean
gdprConsentAt DateTime
accessToken String // For data export/deletion
accessTokenExpiresAt DateTime
// Evaluation data
jobTitle String
aggregateScore Float
recommendation String
// ... other fields
}
3. AI: Claude 4.0 Sonnet (Anthropic)
Why Claude over GPT-4:
- Better at structured output (JSON with specific schema)
- 200K token context window (can fit entire CVs + job descriptions)
- Stronger reasoning for nuanced evaluation
- More consistent scoring across evaluations
- Superior multilingual understanding (critical for our 23-language support)
Prompt engineering approach:
const prompt = `
You are evaluating a candidate's CV against a job description.
Score the candidate on 7 dimensions (1-10):
1. Qualification Match - Education, certifications, years of experience
2. Capability & Confidence - Technical skills, demonstrated expertise
3. Situational Stability - Job tenure, career trajectory
4. Reward Expectations - Salary alignment, benefits fit
5. Culture Fit - Work style, values alignment
6. Career Trajectory - Growth potential, ambition alignment
7. Compensation Expectations - Salary range compatibility
Return JSON with this exact structure:
{
"scores": { "qualification": 8, ... },
"summary": "...",
"recommendation": "strong_yes|yes|maybe|no|strong_no",
"confidence_level": "high|medium|low"
}
`
Result: 95%+ consistency in scoring, with detailed reasoning for every decision.
4. File Processing: PDF/DOCX â Text Extraction
Challenge: Users upload CVs in various formats (PDF, DOCX, scanned images)
Solution: Multi-stage extraction pipeline
async function extractTextFromCV(buffer: Buffer, mimeType: string) {
if (mimeType === 'application/pdf') {
// Try pdf-parse first (works for text-based PDFs)
const text = await pdfParse(buffer)
// Fallback: OCR for scanned PDFs (future enhancement)
if (text.length < 100) {
throw new Error('CV appears to be image-based or encrypted')
}
return text
} else if (mimeType === 'application/docx') {
// Use mammoth.js for DOCX extraction
const result = await mammoth.extractRawText({ buffer })
return result.value
}
}
Learning: 12% of CVs failed initial extraction due to encryption or image-only PDFs. We added clear error messages guiding users to re-save files.
Key Features Built in Week 1
Free CV Checker (Public Tool)
Goal: Marketing funnel + product validation
Implementation:
- No authentication required
- Rate limited by IP (30 evaluations/day)
- Automatic data deletion after 30 days
- GDPR-compliant consent flow
Code snippet - Rate limiting:
function hashIP(ip: string): string {
return crypto.createHash('sha256')
.update(ip + 'salt_tanova_2024')
.digest('hex')
}
async function checkRateLimit(ipHash: string) {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
const count = await prisma.publicEvaluation.count({
where: {
ipAddress: ipHash,
createdAt: { gte: oneDayAgo }
}
})
return count < 30
}
Result: 500+ evaluations in first 3 weeks, zero spam/abuse.
Agency Dashboard (Authenticated)
Features:
- Upload multiple CVs
- Bulk evaluation (process 10+ candidates at once)
- Share evaluation links with clients
- Export to CSV for further analysis
Technical challenge: Handling concurrent AI requests without rate limits
Solution: Queue system with concurrency control
const queue = new PQueue({ concurrency: 3 })
const results = await Promise.all(
candidates.map(candidate =>
queue.add(() => evaluateCandidate(candidate))
)
)
Key Features Built in Week 2
Public Job Application Pages
Goal: Let recruiters post jobs publicly, candidates apply with instant AI screening
Implementation:
- Job posting with public slug:
model Job {
id Int @id @default(autoincrement())
title String
isPublic Boolean @default(false)
publicSlug String? @unique // e.g., "senior-developer-startup-berlin"
// ...
}
- Public application flow:
Candidate visits: /apply/senior-developer-startup-berlin
â
Uploads CV (no account required)
â
AI evaluates CV vs job requirements
â
Results emailed with GDPR management link
â
Recruiter sees application in dashboard
- Email automation with GDPR controls:
Every applicant receives:
- Evaluation results
- Downloadable data (JSON export)
- One-click deletion link
- Automatic expiration notice (30 days)
Multi-language Support
Challenge: Initial prompt was English-only, failed on French/German CVs
Solution: Language-agnostic prompting
const prompt = `
Evaluate this CV against the job description.
Both may be in different languages (English, French, German, Spanish, etc.).
Provide your analysis in the SAME LANGUAGE as the job description.
If job description is in French, respond in French.
If job description is in English, respond in English.
[... rest of prompt ...]
`
Result: 89.9/100 score for French electrician CV, 78/100 for German product manager - AI understood context across languages.
Key Features Built in Week 3
Hidden Gem Detection
Insight from data: 15% of high-scoring candidates would be auto-rejected by traditional ATS
Implementation: Secondary AI analysis for "Maybe" candidates
if (recommendation === 'maybe' && aggregateScore > 65) {
const hiddenGemAnalysis = await analyzeHiddenGemPotential({
cvText,
jobDescription,
scores
})
// Flags: "rapid learner", "unconventional background",
// "unique skill combination", "high growth trajectory"
}
Real example:
- Junior dev with 1.5 years â Score: 35/100 for CTO role
- Flagged: "Exceptional product thinking, rapid technical growth"
- Recommendation: Not CTO, but excellent for Senior IC role
Rejection Email Templates with AI Personalization
Problem: Recruiters spend 10-15 minutes writing rejection emails
Solution: Templates + AI personalization
const template = `
Dear {{candidateName}},
Thank you for applying to {{jobTitle}} at {{companyName}}.
After careful review, we've decided to move forward with other candidates
whose experience more closely matches our current needs.
{{ai_personalized_feedback}}
We wish you the best in your job search.
`
// AI fills in personalized feedback based on evaluation
const personalizedFeedback = await generateRejectionFeedback({
candidateStrengths,
jobRequirements,
gapAreas
})
Result: Rejection emails sent in 30 seconds vs. 10 minutes, with better candidate experience.
GDPR Compliance (Built Throughout)
This wasn't an afterthought - we built it into every feature:
1. Data Minimization
// Anonymous analytics (no PII)
await prisma.anonymousEvaluation.create({
data: {
aggregateScore: 85,
jobCategory: 'Software Engineer', // Generalized
experienceLevel: 'mid', // No specific years
industry: 'Technology', // No company names
// NO: email, name, IP address, CV text
}
})
2. Automatic Deletion
// Cron job runs daily
export async function deleteExpiredEvaluations() {
const deleted = await prisma.publicEvaluation.deleteMany({
where: {
expiresAt: { lte: new Date() }
}
})
console.log(`Deleted ${deleted.count} expired evaluations`)
}
3. User Data Export (Article 20)
export default defineEventHandler(async (event) => {
const { token } = getQuery(event)
const evaluation = await prisma.publicEvaluation.findFirst({
where: { accessToken: token }
})
// Export everything in JSON format
return {
personal_data: {
email: evaluation.candidateEmail,
name: evaluation.candidateName,
uploaded_at: evaluation.createdAt
},
evaluation: {
scores: { ... },
summary: evaluation.summary,
recommendation: evaluation.recommendation
}
}
})
4. Right to Erasure (Article 17)
// One-click deletion via emailed link
export default defineEventHandler(async (event) => {
const { token } = getQuery(event)
await prisma.publicEvaluation.delete({
where: { accessToken: token }
})
// Send confirmation email
await sendDeletionConfirmation(evaluation.candidateEmail)
return { deleted: true }
})
Performance Optimizations
Problem 1: AI Evaluation Taking 45-60 Seconds
Initial approach: Synchronous API call to Claude
Issue: User waiting on blank screen for 60 seconds = bad UX
Solution: Streaming response with progress indicators
export default defineEventHandler(async (event) => {
const stream = createEventStream(event)
stream.push({ stage: 'extracting_cv', progress: 10 })
const cvText = await extractTextFromCV(cvFile)
stream.push({ stage: 'analyzing', progress: 40 })
const evaluation = await evaluateWithAI(cvText, jobDescription)
stream.push({ stage: 'generating_report', progress: 80 })
await saveEvaluation(evaluation)
stream.push({ stage: 'complete', progress: 100, data: evaluation })
})
Result: Users see progress, perceived wait time reduced 40%
Problem 2: Database Queries on Every Page Load
Issue: Fetching user + agency + credits + evaluations on dashboard = 4 queries
Solution: Prisma includes + caching
// Before: 4 separate queries
const user = await prisma.user.findUnique({ where: { id } })
const agency = await prisma.agency.findUnique({ where: { id: user.agencyId } })
const credits = await getAgencyCredits(agency.id)
const evaluations = await prisma.evaluation.findMany({ where: { agencyId: agency.id } })
// After: 1 query with includes
const user = await prisma.user.findUnique({
where: { id },
include: {
agency: {
include: {
evaluations: { take: 20, orderBy: { createdAt: 'desc' } },
_count: { select: { evaluations: true } }
}
}
}
})
Result: Dashboard load time: 2.3s â 0.4s
What We Learned
1. AI Consistency Requires Structured Prompts
Early prompts were conversational. Results varied wildly:
- Same CV evaluated 3 times â scores: 72, 84, 79
- Recommendation logic inconsistent
Fix: JSON schema enforcement + examples
const prompt = `
Return EXACTLY this JSON structure (no additional text):
{
"scores": {
"qualification": <1-10>,
"capability_confidence": <1-10>,
...
},
"recommendation": "<strong_yes|yes|maybe|no|strong_no>",
"confidence_level": "<high|medium|low>"
}
Example output:
{
"scores": { "qualification": 8, ... },
"recommendation": "yes",
"confidence_level": "high"
}
`
Result: Score variance reduced to ±2 points on repeat evaluations
2. GDPR Should Be Built In, Not Bolted On
We designed data deletion from day 1:
- Every table has
expiresAtordeletedAt - No hard-coded references to personal data
- All PII behind access tokens
Benefit: When GDPR deletion request comes, it's one DELETE query, not a multi-week audit.
3. Users Will Test Your Limits
- One user submitted 23 CVs for "waitress" role in 8 days (testing/iteration)
- Someone uploaded their CV as BOTH the job description and application (confused)
- Multiple users tried to bypass rate limits with VPNs (we added rate limit messaging)
Learning: Build for abuse from day 1. Every input is adversarial.
4. Multilingual AI Is Production-Ready
We were skeptical Claude could evaluate French/German/Spanish CVs accurately.
Reality: It worked perfectly. No translation layer needed.
Example: French CV for electrician in Congo scored 89.9/100 with detailed technical analysis in French.
The Results After 3 Weeks
Product Metrics
- â 500+ evaluations processed
- â 23 languages evaluated (English, French, German, Spanish, Arabic, Italian, etc.)
- â Average evaluation time: 42 seconds (vs. 30 minutes manual)
- â Score consistency: ±2 points on repeat evaluations
- â Zero GDPR complaints (built-in compliance worked)
Business Impact
- â Client onboarded 3 agencies in first 2 weeks
- â 97% time savings on CV screening
- â 15% more "hidden gem" candidates identified vs. manual screening
- â 1 enterprise trial signed (500+ candidates/month)
Technical Debt
- â ïž No automated testing (shipped fast, paying for it now)
- â ïž Manual deployment (need CI/CD pipeline)
- â ïž Basic error monitoring (need better observability)
Next sprint: Testing infrastructure, deployment automation, error tracking (Sentry).
Tech Stack Summary
| Layer | Technology | Why |
|---|---|---|
| Frontend | Vue 3 + Nuxt 4 | Full-stack framework, SSR for SEO |
| Backend | Nuxt Server API | Same codebase as frontend, type-safe |
| Database | PostgreSQL + Prisma | Relational data, type-safe queries |
| AI | Claude 4.0 Sonnet (Anthropic) | Best reasoning, 200K context, structured output |
| Auth | Supabase Auth | OAuth (Google, LinkedIn), magic links |
| Storage | AWS S3 | CV files stored in secure S3 buckets |
| Deployment | Railway | Fast deploys, PostgreSQL included, $20/month |
| Nodemailer + SMTP | Transactional emails (GDPR notifications) |
Would We Do It Differently?
What Worked
â
Nuxt 4 - Single codebase = fast iteration
â
Prisma - Type safety caught 20+ bugs before production
â
Claude 4.0 - Consistently better than GPT-4 for structured tasks
â
GDPR-first design - Saved weeks of retrofitting
â
Ship fast, iterate - Real users beat internal testing
What We'd Change
â Add tests from day 1 - Now retrofitting 50+ files
â Use feature flags - Hard to test in production without them
â Better error tracking - Lost time debugging production issues
â Document AI prompts - Multiple versions in git history now
Key Takeaways for Your Next AI Project
- Structure your prompts like API contracts - Define exact JSON schemas
- AI consistency > AI creativity - For production apps, repeatability matters more than cleverness
- GDPR is easier if you don't collect data - Minimize PII from day 1
- Test edge cases with real users - Internal QA won't find the weird stuff
- Ship fast, but not sloppy - We got away with no tests for 3 weeks. Don't push it to 6.
Interested in AI-Powered Development?
At Pedersen Consulting, we build production AI applications in weeks, not months. Our approach:
- AI-first architecture - Design for LLM integration from day 1
- Rapid iteration - Ship MVPs in 2-4 weeks, iterate based on real usage
- GDPR compliance built in - No retrofitting privacy controls
- Modern stack - Nuxt, Next.js, Prisma, PostgreSQL, Claude/GPT-4
Building something with AI? Let's talk: sune@pedersendev.com
This case study was written by Sune Pedersen, founder of Pedersen Consulting and Tanova. Code examples simplified for clarity.
