roleready-interview-prep.vercel.app
Paste a job description (a pre-filled example is loaded for you) and drop in a resume PDF. RoleReady extracts skills from both, retrieves grounded interview questions from a pgvector corpus, and streams a personalized skill-gap breakdown and 3-week prep plan back to the browser live over SSE as each pipeline stage completes — watch the processing screen advance in real time.
![]() |
![]() |
|---|---|
| Landing page / sign in | Resume + job description upload |
![]() |
![]() |
|---|---|
| Live SSE progress as the analysis runs | Skill gap breakdown and generated prep plan |
RoleReady is an AI-powered interview preparation platform: paste a job description, upload your resume, and it maps the gap between what the role needs and what you already have, then builds a personalized prep plan grounded in a curated corpus of real interview questions rather than freeform LLM guesses. Under the hood it's an event-driven pipeline — job description submission returns immediately while a Kafka consumer does the actual work (skill extraction, RAG retrieval, plan generation) and pushes progress to the browser over Server-Sent Events, so the UI never blocks on a slow AI call.
This is an original project built to explore a Kafka-backed, RAG-grounded generation pipeline end to end, not a tutorial clone.
The central design choice is decoupling the request from the AI pipeline: POST /api/analysis writes an analyses row, publishes a jd-submitted event to Kafka, and returns 202 Accepted immediately. A Kafka consumer does the actual (slow, multi-step) work asynchronously and pushes live progress to the browser over SSE.
Client (React)
↓ POST /api/analysis { jdText, resumeId }
Spring Boot API → analyses table (status: processing)
↓ publish jd-submitted
Kafka (Confluent Cloud, SASL_SSL)
↓ consume
JdAnalysisConsumer
│ SSE: kafka_published
├─ skill extraction (Gemini Flash, or reuse parsed resume skills)
│ SSE: skills_extracted
├─ RAG retrieval — embed each skill (Gemini), hybrid search over pgvector
│ SSE: rag_retrieved
└─ prep plan generation (Gemini Flash: match score, skill gaps, questions, study plan)
SSE: plan_generated
↓
PostgreSQL (Supabase) ←────────────→ Browser (EventSource, /api/events/{analysisId})
Backend package layout — a modular monolith, not microservices. Everything runs in one Spring Boot process, but the code is organized into feature packages with a single direction of dependency (no package reaches back into another's internals):
| Package | Responsibility |
|---|---|
auth |
Email/password signup+login (BCrypt + JWT), GitHub OAuth callback, Spring Security config |
user |
User entity, resume upload, PDF text extraction (PDFBox), Gemini-based skill parsing |
jdanalysis |
JD submission endpoint, Kafka producer + consumer, on-demand skill extraction, analysis history |
rag |
Gemini embeddings, pgvector hybrid search, interview-question corpus ingestion |
prepplan |
Gemini Flash prep plan generation (match score, skill gaps, questions, 3-week study plan) |
notification |
In-memory SseEmitterRegistry + /api/events/{analysisId} SSE endpoint |
common |
JWT utilities, JWT auth filter, Kafka client config, health check |
A monolith was chosen deliberately: the pipeline is a single logical workflow (submit → extract → retrieve → generate) with one shared database, and splitting it into services would trade in-process method calls for network calls without buying independent scaling — nothing here has a load profile that justifies it yet. Kafka still sits in the middle so the request and the work are decoupled even though they run in the same process.
Why Kafka instead of an in-process async task. The pipeline makes 1 skill-extraction LLM call, up to 10 embedding calls (one per extracted skill), and 1 plan-generation LLM call — several seconds of sequential I/O. Publishing to Kafka and returning 202 immediately means the HTTP thread is never held open for that; the consumer picks up the message on its own schedule, and the JD payload durably survives a server restart mid-processing, which a @Async method or a raw thread pool would not.
RAG retrieval. For each extracted skill, GeminiEmbeddingService embeds the skill string and QuestionRepository runs a hybrid search: a topic/difficulty-filtered pgvector query (ORDER BY embedding <=> :embedding, an HNSW cosine index) first, falling back to a pure similarity search when the filtered query returns nothing. Results across all skills are deduplicated and capped at the top 10 — so the questions handed to the plan-generation prompt are always real corpus entries, not hallucinated ones.
| Feature | Description | Tech |
|---|---|---|
| JD + resume analysis | Upload a resume PDF and paste a JD; both are parsed into independent skill lists | PDFBox text extraction, Gemini Flash skill extraction |
| Live skill auto-detect | JD skill chips re-extract automatically 1s after you stop typing, with a loading state | Debounced POST /api/skills/extract |
| Skill gap detection | Resume skills vs. JD-required skills, ranked strong / growing / gap | PrepPlanService + Gemini Flash |
| RAG-grounded prep plan | Interview questions retrieved from a real corpus, not generated from scratch | Gemini embeddings (768-dim), pgvector HNSW hybrid search |
| Real-time progress tracking | 5-stage pipeline (Kafka published → skills → RAG → plan → push) updates the processing screen live | Kafka consumer → SseEmitterRegistry → browser EventSource |
| GitHub OAuth + email auth | Sign in with GitHub or email/password — both issue the same JWT | Spring Security, JJWT, GitHub OAuth Apps, BCrypt |
| Analysis history | Every past analysis listed with its status and a link back to its generated prep plan | GET /api/analysis/history |
| Client | Server | |
|---|---|---|
| Language | TypeScript 5.6 | Java 21 |
| Framework | React 18, Vite 5.4 | Spring Boot 3.3 (modular monolith) |
| Styling | Tailwind CSS 3.4 | — |
| Routing | React Router 6 | Spring MVC |
| Auth | JWT in localStorage |
Spring Security, JJWT, BCrypt, GitHub OAuth |
| Database | — | PostgreSQL (Supabase) + pgvector, Flyway migrations |
| Messaging | — | Apache Kafka (Confluent Cloud, SASL_SSL / PLAIN) |
| AI | — | gemini-embedding-001 (768-dim), Gemini Flash |
| Real-time | Browser EventSource |
Spring SseEmitter |
| File parsing | — | Apache PDFBox |
| Deployment | Vercel | Railway |
Why 768-dimensional embeddings instead of Gemini's native 3072. gemini-embedding-001 supports Matryoshka representation learning, so requesting outputDimensionality: 768 returns a truncated-but-still-meaningful vector rather than a degraded one. 768 dims keeps the pgvector HNSW index smaller and queries faster, at a negligible recall cost for a corpus of interview questions that doesn't need maximum embedding fidelity to begin with.
Why the Supabase session pooler, not the transaction pooler. Supabase's transaction-mode pooler (PgBouncer, port 6543) doesn't support session-level features — prepared statement caching and SET commands — that Hibernate relies on. DATABASE_URL points at the session pooler (port 5432) instead, giving full Postgres wire-protocol compatibility for JPA while still pooling connections outside the app.
Why Confluent Cloud for Kafka. A managed, free-tier-friendly Kafka cluster reachable over SASL_SSL means the async pipeline doesn't need a self-hosted broker for a project this size — KafkaConfig just authenticates with PLAIN SASL and connects.
roleready-interview-prep/
├── client/ # Vite + React + TypeScript frontend
│ └── src/
│ ├── api/ # auth.ts, analysis.ts, sse.ts — fetch/EventSource wrappers
│ ├── components/ # NavBar, Avatar, Logo
│ └── pages/ # Landing, Dashboard, Processing, Results, History, AuthCallback
└── server/ # Spring Boot modular monolith
└── src/main/
├── java/com/roleready/
│ ├── auth/ # signup/login, GitHub OAuth, Spring Security config
│ ├── user/ # User + Resume entities, PDF parsing, resume skill extraction
│ ├── jdanalysis/ # JD submission, Kafka producer/consumer, skill extraction, history
│ ├── rag/ # Gemini embeddings, pgvector hybrid search, corpus ingestion
│ ├── prepplan/ # Gemini Flash prep plan generation
│ ├── notification/ # SSE emitter registry + /api/events endpoint
│ └── common/ # JWT utils, Kafka client config, health check
└── resources/
├── application.yml
└── db/migration/ # Flyway migrations (pgvector extension, schema)
# Prerequisites: Java 21, Node.js 18+, a Postgres instance with the pgvector
# extension available (e.g. a free Supabase project), a Kafka cluster
# (e.g. a free Confluent Cloud cluster), and a Gemini API key.
# 1. Clone
git clone git@github.com:vsingh2201/roleready-interview-prep.git
cd roleready-interview-prep
# 2. Server setup — create server/.env with the variables below
# (spring-dotenv loads it automatically; no extra config needed)
cd server
./mvnw spring-boot:run
# 3. Client setup (new terminal) — create client/.env with the variables below
cd client
npm install
npm run dev
# 4. Open http://localhost:5173The server runs Flyway migrations on startup (enabling pgvector, creating tables), so the database just needs to exist and be reachable — no manual schema setup required. Locally, the Vite dev server proxies /api/** to localhost:8080, so VITE_API_URL can stay blank.
server/.env
| Variable | Description | Example |
|---|---|---|
DATABASE_URL |
Supabase Postgres connection string — session pooler, port 5432 | jdbc:postgresql://aws-0-us-east-1.pooler.supabase.com:5432/postgres |
DATABASE_USERNAME |
Postgres username | postgres.xxxxxxxxxxxx |
DATABASE_PASSWORD |
Postgres password | — |
JWT_SECRET |
HMAC-SHA256 signing secret for issued JWTs | 32+ char random string |
GITHUB_CLIENT_ID |
GitHub OAuth App client ID | Ov23ctXXXXXXXXXXXX |
GITHUB_CLIENT_SECRET |
GitHub OAuth App client secret | — |
GEMINI_API_KEY |
Google AI Studio API key — used for embeddings and Flash generation | AIzaSy... |
KAFKA_BOOTSTRAP_SERVERS |
Confluent Cloud bootstrap server | pkc-xxxxx.us-east1.gcp.confluent.cloud:9092 |
KAFKA_USERNAME |
Confluent Cloud API key | — |
KAFKA_PASSWORD |
Confluent Cloud API secret | — |
CORS_ALLOWED_ORIGINS |
Comma-separated allowed origins (default http://localhost:5173) |
https://roleready-interview-prep.vercel.app |
FRONTEND_BASE_URL |
Where the GitHub OAuth callback redirects with the JWT (default http://localhost:5173) |
https://roleready-interview-prep.vercel.app |
client/.env
| Variable | Description | Example |
|---|---|---|
VITE_GITHUB_CLIENT_ID |
Same GitHub OAuth App client ID as the server | Ov23ctXXXXXXXXXXXX |
VITE_API_URL |
Backend base URL. Leave blank locally — the Vite dev server proxies /api to localhost:8080 |
https://your-app.up.railway.app |
There's no committed .env.example yet — copy the tables above into server/.env and client/.env to get started.



