karthik.dev
Back to blogAI Engineering

JobPilot AI: Building a Full-Stack AI Job Copilot with BullMQ and Gemini

2026-07-20 · 5 min read

The Job Search Is a Pipeline Problem

Job searching has a predictable multi-stage pipeline:

  1. Discover relevant jobs (signal from noise)
  2. Prioritize by match score (don't apply blindly)
  3. Tailor your application (resume + cover letter for each role)
  4. Track status (don't lose track of where you applied)
  5. Prepare for interviews (role-specific, company-specific)

Each stage is doable manually — but doing all five across 20+ active applications is full-time work. JobPilot AI automates stages 1, 2, 3, and 5 while you manage stage 4 through the tracking dashboard.

Why BullMQ?

The core architectural decision: AI operations are slow. Gemini match scoring for 50 jobs takes 30-60 seconds. If you do this synchronously in a Next.js API route, you'll hit timeouts, block the response, and create terrible UX.

The solution: async background jobs with BullMQ.

// src/lib/queue.ts
import { Queue, Worker } from "bullmq";

export const jobScoringQueue = new Queue("job-scoring", {
  connection: redis,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: "exponential", delay: 1000 },
    removeOnComplete: { count: 100 },
    removeOnFail: { count: 50 },
  },
});

// When user triggers a job search:
await jobScoringQueue.add("score-jobs", {
  userId: session.userId,
  jobIds: discoveredJobIds,
  candidateProfile: profile,
});

// In the worker (runs in background):
const worker = new Worker(
  "job-scoring",
  async (job) => {
    const { userId, jobIds, candidateProfile } = job.data;
    
    for (const jobId of jobIds) {
      const jobListing = await db.job.findUnique({ where: { id: jobId } });
      const score = await scoreJobMatch(jobListing, candidateProfile);
      
      await db.scoredJob.upsert({
        where: { userId_jobId: { userId, jobId } },
        update: { score, scoredAt: new Date() },
        create: { userId, jobId, score },
      });
      
      // Update progress
      await job.updateProgress((jobIds.indexOf(jobId) / jobIds.length) * 100);
    }
  },
  { connection: redis, concurrency: 5 },
);

The user sees a progress indicator on the frontend, polling the job status endpoint. When scoring finishes, the UI updates automatically.

Gemini Match Scoring

async function scoreJobMatch(
  job: JobListing,
  profile: CandidateProfile,
): Promise<JobMatchScore> {
  const prompt = `
    Score this candidate-job match on a 0-100 scale.
    
    Candidate:
    Skills: ${profile.skills.join(", ")}
    Experience: ${profile.yearsExperience} years
    Current Role: ${profile.currentRole}
    
    Job:
    Title: ${job.title} at ${job.company}
    Required Skills: ${job.requiredSkills.join(", ")}
    Preferred Skills: ${job.preferredSkills.join(", ")}
    Experience Required: ${job.requiredYears} years
    
    Return JSON: {
      overallScore: number,
      skillCoverageScore: number,
      experienceScore: number,
      matchingSkills: string[],
      missingRequiredSkills: string[],
      missingPreferredSkills: string[],
      verdict: "strong_fit" | "good_fit" | "stretch" | "not_recommended",
      oneLineSummary: string
    }
  `;
  
  const response = await gemini.generateContent({
    contents: [{ role: "user", parts: [{ text: prompt }] }],
    generationConfig: {
      responseMimeType: "application/json",
      temperature: 0,  // deterministic scoring
    },
  });
  
  return JSON.parse(response.response.text());
}

temperature: 0 is critical here — you want the same profile + job combination to always produce the same score, not random variation.

Redis Caching

Match scores are expensive to compute. For the same candidate profile and job description, the score should be identical. Cache it:

async function getCachedScore(
  jobId: string,
  profileHash: string,
): Promise<JobMatchScore | null> {
  const cacheKey = `score:${jobId}:${profileHash}`;
  const cached = await redis.get(cacheKey);
  return cached ? JSON.parse(cached) : null;
}

async function setCachedScore(
  jobId: string,
  profileHash: string,
  score: JobMatchScore,
): Promise<void> {
  const cacheKey = `score:${jobId}:${profileHash}`;
  await redis.setex(cacheKey, 60 * 60 * 24, JSON.stringify(score));  // 24h TTL
}

The profile hash is computed from the profile's skills, experience, and target roles — if the profile doesn't change, the cache is valid.

Application Generation

async function generateApplication(
  job: JobListing,
  profile: CandidateProfile,
  matchScore: JobMatchScore,
): Promise<Application> {
  const prompt = `
    Write a tailored resume bullet-point set and cover letter for:
    Job: ${job.title} at ${job.company}
    
    Candidate strengths for this role: ${matchScore.matchingSkills.join(", ")}
    Skills to address: ${matchScore.missingPreferredSkills.join(", ")}
    
    Current resume bullets (for context):
    ${profile.resumeBullets.join("\n")}
    
    Generate:
    1. 5 tailored resume bullets that emphasize the matching skills
    2. A 200-word cover letter opening paragraph
    
    Return JSON: { bullets: string[], coverLetterOpening: string }
  `;
  // ...
}

PostgreSQL Schema

CREATE TABLE scored_jobs (
    user_id UUID REFERENCES users(id),
    job_id UUID REFERENCES job_listings(id),
    score INTEGER CHECK (score BETWEEN 0 AND 100),
    verdict TEXT,
    matching_skills TEXT[],
    missing_skills TEXT[],
    scored_at TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (user_id, job_id)
);

CREATE INDEX ON scored_jobs (user_id, score DESC);  -- sorted by match score

Lessons from Two Sprint Cycles

Sprint 1 uncovered: auth session management issues, Docker networking failures in the job worker, and Gemini API rate limit handling. Sprint 2 hardened all three.

The key insight: async AI processing needs different reliability engineering than sync APIs. Retries, idempotency, progress tracking, and dead-letter queues are table stakes — not optional.

GitHub: github.com/karthikrshet/jobpilot-ai