ResumeRadar docs
A complete guide to ResumeRadar — how to use it and how it works end to end. ResumeRadar matches a resume against a job description and reports a real, explainable fit score through a deterministic, LLM-optional analysis pipeline.
Overview
ResumeRadar compares a resume against a job description and reports your real fit: the matched skills, the gaps, and optional AI suggestions to close them. It is built around a strict authority boundary — scoring is deterministic and reproducible, so the same inputs always produce the same score, matches, and explanations. Large language models are an optional aid for extraction and guidance and can never change a result.
The system is a small distributed application: a Next.js frontend, a FastAPI REST API, a Celery worker for off-request processing, PostgreSQL for durable state, Redis for queuing and rate limiting, and S3-compatible object storage for private files.
Getting started
- 1
Create an account
Register with an email and password, or sign in with Google. The first account created on a fresh deployment is automatically promoted to admin so the skill library can be seeded.
- 2
Upload a resume
From the dashboard, upload a PDF or DOCX. The file goes to private object storage via a presigned URL; a background worker then extracts the text and your skills.
- 3
Add a job description
Paste the posting text or upload a TXT, PDF, or DOCX file. Requirements are parsed and skill-extracted the same way as your resume.
- 4
Run an analysis
Pair a resume with a job description and start an analysis. The job is queued and processed asynchronously, so the page reflects each stage as it advances.
- 5
Read your score
Open the result for the overall match, the required/preferred breakdown, every matched and missing skill with a plain-language explanation, and optional AI suggestions.
Understanding your results
- Match score
- An overall fit from 0–100, computed as matched weight ÷ total weight × 100, rounded to two decimals.
- Required vs preferred
- Every job skill is bucketed as required or preferred, each with a matched/total count. Required skills carry 3× the weight of preferred.
- Per-skill matches
- One row per job skill: whether it matched, the method used, the weight, and a human-readable explanation.
- Matched & missing skills
- The skills found in both documents, and the required ones absent from your resume — the concrete gaps to close.
- AI suggestions
- Optional and off by default: gap explanations and resume-rewrite hints. These are advisory and can never change the score.
Supported files & limits
| Document | Accepted formats | Size |
|---|---|---|
| Resume | PDF (.pdf), Word (.docx) | Up to 10 MB |
| Job description | Text (.txt), PDF (.pdf), Word (.docx) — or paste text directly | Up to 10 MB |
Files are validated on both ends: the browser filters by type, and the backend re-checks the declared type, size, SHA-256, and the file's magic-byte signature before accepting the upload.
Architecture & tech stack
Components
- Next.js app
- Server-rendered React UI. Edge middleware gates routes by an access-token cookie before a page renders.
- FastAPI API
- Stateless REST API under /api. Validates input, enforces owner-scoped authorization, and enqueues background work.
- Celery worker
- Consumes the Redis queue and runs document parsing and the multi-stage analysis pipeline.
- PostgreSQL
- System of record for users, documents, the skill taxonomy, jobs, results, and the full extraction audit trail.
- Redis
- Celery broker + result backend, and the store backing API rate limiting.
- MinIO / S3
- Private blob storage for uploaded files; the API never proxies file bytes — clients use presigned URLs.
Stack
- Frontend
- Next.js 16 (App Router) · React 19 · TypeScript · TanStack Query (server state) · Zustand (auth) · react-hook-form + Zod (forms/validation) · Axios (HTTP). Tested with Vitest, Testing Library, and MSW.
- Backend
- FastAPI + Uvicorn (ASGI) · SQLAlchemy 2 ORM · Alembic migrations · Pydantic settings. Tested with pytest.
- Database
- PostgreSQL 16, fronted by PgBouncer (transaction pooling). Heavy use of partial unique indexes and check constraints to push invariants into the schema.
- Async work
- Celery workers with Redis as both broker and result backend; long-running parsing and analysis run off the request path.
- Object storage
- MinIO (S3-compatible) for private resume / job-description files, accessed only through short-lived presigned URLs.
- SMTP for password-reset and notification mail; Mailpit captures it in local development.
- LLM (optional)
- Pluggable OpenAI / Gemini / Groq providers behind one interface. Disabled by default and never authoritative.
Upload & parsing flow
Files never pass through the API server. Instead, the client gets a short-lived presigned URL and uploads straight to object storage; the backend only brokers and verifies. From request to ready text:
- 1 · Create an intentThe client calls upload-intents with the filename, content type, and size. The backend validates them against the allow-list and size cap, creates the Resume/JobDescription row in PENDING storage status, and returns a short-lived presigned PUT URL plus the object key.
- 2 · Upload directly to storageThe browser PUTs the raw bytes straight to MinIO/S3 using the presigned URL. The API never receives or proxies the file contents, which keeps request handlers fast and stateless.
- 3 · Complete & verifyThe client calls complete-upload. The backend inspects the stored object and verifies its byte size, its SHA-256 digest, and its magic-byte signature (e.g. %PDF- for PDF, PK\x03\x04 for DOCX). Any mismatch rejects the upload — a declared PDF that isn't really a PDF never proceeds.
- 4 · Queue parsingOn success the document is marked UPLOADED with parsing_status QUEUED, and a parse_document task is enqueued on Redis for a Celery worker to pick up.
- 5 · Parse to textThe worker downloads the object and extracts text (pypdf for PDF, python-docx for DOCX; OCR is a fallback only when enabled). It records the parser name/version, page count, detected structure, and a parse-quality score, then sets parsing_status to READY.
Because each step is recorded as a status on the document (storage_status and parsing_status), the UI can show progress and a failed verification or parse leaves a clear, safe error rather than a half-uploaded record.
Analysis pipeline
An analysis runs as a Celery task that advances through a guarded state machine. Each row below is one status transition:
- QUEUED → PARSINGThe worker claims the job atomically (so a retry never double-runs) and verifies both documents have usable extracted text.
- EXTRACTINGDeterministic extraction (plus optional LLM proposals) produces ExtractionCandidate rows, each with evidence offsets and several confidence signals.
- NORMALIZINGCandidates are validated and mapped to canonical skills; accepted ones are materialized into ExtractedSkill rows stamped with a skill_id.
- SCORINGThe DeterministicSkillScorer compares job vs resume skills, writes one match row per job skill, and computes the 0–100 score.
- GENERATING_SUGGESTIONSOptionally, the LLM writes a fit summary and prioritized suggestions. The result row is finalized regardless.
- COMPLETED / FAILEDOn success the job is marked complete; permanent errors mark it failed with a safe error code, while transient errors trigger a Celery retry.
The orchestrator commits after every transition, so a worker restart resumes from the last completed stage rather than re-running the whole job. Extraction is hybrid: the deterministic extractor always runs, and the LLM extractor is layered on only when configured — if it errors or is disabled, the job continues on deterministic results alone and is annotated as such, never failing for a missing LLM.
How skills are extracted
The deterministic extractor (SkillExtractionService, version skill-extraction-v1) turns raw document text into candidate skills with exact evidence. It is pure string processing — no model required — and runs the same way every time:
- Build a matcher from the taxonomyEvery active skill and alias is loaded, sorted longest-first, and compiled into a boundary-aware regular expression — so "Go" matches the language but not "Google", and "C++" / "C#" survive punctuation.
- Segment the documentText is walked line by line. Headings such as "Skills", "Requirements", or "Preferred" set the active section and requirement context; remaining lines are split into sentence-level segments that inherit that context.
- Find known skillsEach compiled pattern is run over every segment. A hit becomes a candidate carrying its evidence text and a confidence of 0.92 for a canonical name or 0.86 for an alias.
- Propose unknown skillsSegments are also split on separators (commas, bullets, "and/or"), with leading context phrases ("experience with", "proficient in") stripped. Stopwords and non-skill-looking tokens are filtered by heuristics (contains . # + /, ALL-CAPS, camelCase, or sits in a skills/required section); survivors are kept at ~0.54–0.62 confidence.
- Classify requirement typeFor job descriptions, each segment is labelled REQUIRED or PREFERRED from nearby marker words ("must have", "minimum" vs "nice to have", "bonus"), defaulting to REQUIRED.
- Deduplicate & persistCandidates are merged per skill (or per normalized text when unknown), keeping the stronger requirement and the higher confidence, then written as ExtractionCandidate rows with exact character offsets for their evidence.
Candidates then go through validation and normalization: those that resolve to a canonical skill are marked ACCEPTED and materialized into ExtractedSkill rows; the rest are flagged NEEDS_REVIEW and queued as unknown-skill candidates for an admin.
Scoring algorithm
The scorer (DeterministicSkillScorer, version skills-weighted-v2-accepted-only) walks every skill the job requires and looks for a matching resume skill. Required skills are weighted 3.0 and preferred skills 1.0, so missing a required skill always costs more than missing a preferred one. The final score is:
score = round(matched_weight / total_weight × 100, 2)Matching first tries the canonical skill_id stamped during normalization, then falls back to normalized text for unknown skills. Every job skill produces one match row tagged with the method used:
EXACT- Job and resume resolved to the same canonical skill with identical normalized text.
ALIAS- Same canonical skill, reached through a different surface form (an alias).
NORMALIZED_TEXT- Neither side mapped to a known skill, but their normalized text strings are identical (unknown-skill match).
UNMATCHED- No resume skill satisfied this job skill — recorded as a gap.
TRIGRAM / VECTOR / AI_ADJUDICATED- Reserved in the schema for future fuzzy/semantic matching; not used by the current deterministic scorer.
Before matching, each side is deduplicated per document — by canonical skill when known, otherwise by normalized text — keeping the highest-confidence (and, for the job, the strongest-requirement) row. Results are written deterministically: stable sort order, decimal arithmetic, and a recorded scoring_version, so a re-run on the same accepted facts reproduces the score exactly.
Skill taxonomy & matching
A canonical skill library underpins matching. Each Skill has a normalized name and any number of SkillAlias surface forms; SkillSourceRecord keeps provenance for imported skills. Admins curate this library through the admin endpoints and UI.
During normalization, extracted mentions are resolved to a canonical skill (directly or via an alias) and stamped with its skill_id. Mentions that resolve to nothing become UnknownSkillCandidate rows for admin triage — they can still match by normalized text but never silently pollute the taxonomy.
Background processing & reliability
Parsing and analysis run off the request path on Celery workers. The pipeline is designed to be safe under retries and redelivery:
- Atomic claim
- A job is claimed with a conditional update before any work runs, so a redelivered Celery message can't process the same job twice.
- Guarded transitions
- Each stage advances status only from its expected previous status, making the state machine safe to resume mid-pipeline.
- No duplicate active jobs
- A partial unique index forbids two in-flight analyses for the same (resume, job description) pair.
- Transient vs permanent errors
- Transient failures raise to Celery's retry policy with backoff; permanent failures store a safe, user-facing error code and stop.
- Poisoned-transaction recovery
- On failure the session is rolled back before the job is marked failed, so a job never gets stuck in an in-progress status.
- Checksum & signature checks
- Uploads are only completed after the backend verifies the object's size, SHA-256, and file-type signature (e.g. %PDF-, PK\x03\x04).
API reference
The backend exposes a REST API under /api. For the full, always-current schema, open the interactive docs: Swagger UI or ReDoc. Selected endpoints, with the access level required:
Auth
- POST
/api/auth/registerCreate an account with email + password.Public - POST
/api/auth/loginLog in; returns access + refresh tokens.Public - POST
/api/auth/googleSign in / link with a Google ID token.Public - POST
/api/auth/refreshExchange a refresh token for a new access token.Public - POST
/api/auth/logoutRevoke the current refresh session.User - POST
/api/auth/logout-allRevoke every refresh session for the user.User - POST
/api/auth/change-passwordChange password and revoke sessions.User - POST
/api/auth/forgot-passwordEmail a one-time reset link.Public - POST
/api/auth/reset-passwordConsume a reset token and set a new password.Public - GET
/api/auth/meGet the current user profile.User
Resumes
CRUD lives under /api/resumes; the presigned upload flow under /api/v1/resumes.
- GET
/api/resumesList your resumes (paginated).User - GET
/api/resumes/{id}Get one resume you own.Owner - PATCH
/api/resumes/{id}Rename a resume.Owner - DELETE
/api/resumes/{id}Soft-delete a resume.Owner - POST
/api/v1/resumes/upload-intentsCreate a presigned upload intent.User - POST
/api/v1/resumes/{id}/complete-uploadVerify the upload and queue parsing.Owner - POST
/api/v1/resumes/{id}/download-urlGet a short-lived download URL.Owner
Job descriptions
- GET
/api/job-descriptionsList your job descriptions (paginated).User - POST
/api/job-descriptionsCreate one from pasted text.User - GET
/api/job-descriptions/{id}Get one you own.Owner - PATCH
/api/job-descriptions/{id}Update metadata.Owner - DELETE
/api/job-descriptions/{id}Soft-delete.Owner - POST
/api/v1/job-descriptions/upload-intentsCreate a presigned upload intent.User - POST
/api/v1/job-descriptions/{id}/complete-uploadVerify the upload and queue parsing.Owner
Analysis
All analysis routes are versioned under /api/v1/analysis.
- POST
/api/v1/analysis/jobsCreate a single resume-vs-JD analysis job.User - GET
/api/v1/analysis/jobsList analysis jobs with filters.User - GET
/api/v1/analysis/jobs/{id}Get one analysis job.Owner - GET
/api/v1/analysis/jobs/{id}/resultGet the detailed scoring result.Owner - POST
/api/v1/analysis/jobs/{id}/retryRetry a failed job.Owner - DELETE
/api/v1/analysis/jobs/{id}Delete a terminal job.Owner - POST
/api/v1/analysis/groups/comparisonsCreate a grouped comparison (1:N or N:1).User - GET
/api/v1/analysis/groupsList comparison groups.User - GET
/api/v1/analysis/groups/{id}Get a group with its jobs.Owner - GET
/api/v1/analysis/results/{id}Get a result by id.Owner
Admin
- GET
/api/admin/storage/healthCheck S3/MinIO connectivity.Admin - GET
/api/admin/usersList all users.Admin - POST
/api/admin/usersCreate a user.Admin - PATCH
/api/admin/users/{id}/roleChange a user's role.Admin - POST
/api/admin/users/{id}/reset-passwordReset a user's password.Admin
Admin · skill library
- GET
/api/admin/skillsSearch/filter the canonical skill library.Admin - POST
/api/admin/skillsCreate a canonical skill.Admin - PATCH
/api/admin/skills/{id}Update a skill.Admin - POST
/api/admin/skills/{id}/aliasesAdd an alias to a skill.Admin - PATCH
/api/admin/skills/{id}/aliases/{aliasId}Update an alias.Admin - DELETE
/api/admin/skills/{id}/aliases/{aliasId}Deactivate an alias.Admin
Data model
PostgreSQL is the system of record. Most tables carry an owner_id for authorization and push invariants into the schema with CHECK constraints and partial unique indexes. Key entities and their notable fields:
User
Auth principal. Password is nullable for Google-only accounts.
email · password_hash? · google_sub? · full_name? · role · account_status · last_login_at
RefreshSession
Hashed, revocable refresh token so sessions can be ended server-side.
owner_id · token_hash · expires_at · revoked_at? · user_agent? · ip_address?
PasswordResetToken
One-time, time-limited password-reset token.
owner_id · token_hash · expires_at · used_at?
Resume / JobDescription
Uploaded or pasted documents sharing one storage mixin. JD adds company/role/source.
title · input_type · object_key? · sha256? · storage_status · parsing_status · extracted_text? · structure_data · parse_quality · page_count
Skill / SkillAlias / SkillSourceRecord
The canonical skill taxonomy, its alternate surface forms, and provenance/attribution.
canonical_name · normalized_name · category? · is_active — alias · normalized_alias — source_system · source_id · attribution
AnalysisJob
One resume-vs-JD run. Tracks the state machine and Celery linkage.
resume_id · job_description_id · group_id? · status · attempt_count · celery_task_id? · safe_error_code? · queued/started/completed/failed_at
AnalysisGroup / AnalysisGroupItem
Container for multi-comparisons (1:N or N:1) plus its ordered document members.
mode · title? — group_id · resume_id? XOR job_description_id? · display_order
AnalysisResult
The scoring output for a job. Score is constrained to 0–100 at the DB level.
analysis_job_id · score · matched_weight · total_weight · matched/total_required_count · matched/total_preferred_count · llm_fit_summary?
AnalysisSkillMatch
Per-skill outcome with CHECK constraints tying is_matched to method and resume skill.
job_extracted_skill_id · resume_extracted_skill_id? · requirement_type · match_method · is_matched · weight · score_component · confidence? · explanation
ExtractedSkill
An accepted skill mention with mandatory evidence text and a review status.
source_type · resume_id? / job_description_id? · skill_id? · raw_name · normalized_text · requirement_type? · evidence_text · confidence · review_status
ExtractionCandidate
Pre-acceptance proposal carrying evidence offsets and six calibrated confidence signals.
analysis_job_id · candidate_source · proposed_name/skill_id? · evidence_start/end · explicit · llm/evidence/taxonomy/agreement/explicitness/section/validation_confidence · validation_status
Suggestion
LLM-generated, prioritized guidance attached to a result.
analysis_result_id · suggestion_type · text · priority (1–5) · source_reference?
UnknownSkillCandidate
Queue of extracted skills with no taxonomy match, for admin triage.
extracted_skill_id · raw_name · normalized_text · status · mapped_skill_id? · reviewed_by? · review_notes?
LLMGeneration
Audit log of every LLM call: purpose, provider/model, tokens, latency, outcome.
purpose · status · provider · model_name · prompt_version · input_sha256 · token counts · latency_ms · retry_count · generation_metadata
Enumerations
The domain's state lives in a handful of database enums:
AnalysisStatus- CREATED · QUEUED · PARSING · EXTRACTING · NORMALIZING · SCORING · GENERATING_SUGGESTIONS · COMPLETED · FAILED
ParsingStatus- PENDING · QUEUED · PARSING · OCR · READY · FAILED
StorageStatus- PENDING · UPLOADED · FAILED · DELETED
AnalysisMode- SINGLE · MULTI_RESUME_ONE_JOB · ONE_RESUME_MULTI_JOB
RequirementType- REQUIRED · PREFERRED
MatchMethod- EXACT · ALIAS · NORMALIZED_TEXT · TRIGRAM · VECTOR · AI_ADJUDICATED · UNMATCHED
EvidenceReviewStatus- ACCEPTED · NEEDS_REVIEW · REJECTED
CandidateValidationStatus- PENDING · ACCEPTED · NEEDS_REVIEW · REJECTED
ExtractionCandidateSource- DETERMINISTIC · LLM
SuggestionType- GAP_EXPLANATION · RESUME_REWRITE
UnknownSkillStatus- PENDING · MAPPED · APPROVED_NEW_SKILL · REJECTED
LLMGenerationPurpose- SECTION_DETECTION · SKILL_EXTRACTION · GUIDANCE
LLMGenerationStatus- SUCCEEDED · SKIPPED · FAILED
UserRole / AccountStatus- USER · ADMIN — ACTIVE · DEACTIVATED
Authentication & security
- JWT access tokens
- Short-lived (default 15 min) bearer tokens signed with a server secret; required in production.
- Revocable refresh sessions
- Refresh tokens are stored only as hashes; logout, logout-all, and password change revoke them server-side.
- Password hashing
- PBKDF2 with a high iteration count (default 390,000). Access tokens issued before a password change are rejected.
- Owner-scoped authorization
- Every resource carries an owner_id and queries are owner-scoped, so users can only read/write their own data.
- Role-gated admin
- Admin endpoints require the ADMIN role; the edge middleware also blocks /admin routes for non-admins.
- Rate limiting
- Token-bucket limits per user and per anonymous client, with separate capacities and refill rates.
- Upload safety
- Type allow-list, size cap, SHA-256 verification, and magic-byte signature checks before an upload is accepted.
- Log redaction & prod checks
- A logging filter redacts secrets; a startup validator refuses weak defaults (e.g. the JWT secret) in production.
Frontend architecture
The frontend is a Next.js App Router application organized into feature slices, with server state in TanStack Query and auth state in a Zustand store. Notable areas:
app/- App Router routes: landing, login/register, dashboard, analysis/[id], user, admin/skills, and this /docs page.
middleware.ts- Edge auth gate: redirects unauthenticated users from protected routes and authenticated users away from public ones.
features/- Vertical slices — auth, resumes, job-descriptions, analysis, admin, submissions — each with its api/, components/, hooks/.
components/- Shared UI: Navbar, Button/ButtonLink, Modal, Toaster, TextInput, BackLink, Logo, and more.
stores/ & providers/- Zustand auth store and the TanStack Query provider for server-state caching.
lib/- Axios client with token-refresh interceptors, validation schemas (Zod), storage helpers, error extraction.
Configuration
The backend is configured via environment variables (see backend/app/core/settings.py and the root README.md for the full list). The main groups:
Database
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL | postgresql+psycopg://…@db:5432/… | Primary Postgres connection (via PgBouncer). |
DATABASE_MIGRATIONS_URL | (direct connection) | Bypasses the pooler for Alembic. |
DB_POOL_SIZE | 5 | SQLAlchemy pool size (+ overflow, recycle, pre-ping). |
Auth & JWT
| Variable | Default | Purpose |
|---|---|---|
RESUMERADAR_JWT_SECRET | (required in prod) | Signing key for access tokens. |
RESUMERADAR_ACCESS_TOKEN_MINUTES | 15 | Access-token lifetime. |
RESUMERADAR_REFRESH_TOKEN_DAYS | 7 | Refresh-session lifetime. |
RESUMERADAR_PASSWORD_ITERATIONS | 390000 | PBKDF2 iterations. |
Storage (S3/MinIO)
| Variable | Default | Purpose |
|---|---|---|
S3_ENDPOINT_URL | http://minio:9000 | S3-compatible endpoint. |
S3_BUCKET_NAME | resumeradar | Bucket for private documents. |
RESUMERADAR_MAX_RESUME_UPLOAD_BYTES | 10485760 | Upload size cap (10 MiB). |
Redis / Celery
| Variable | Default | Purpose |
|---|---|---|
REDIS_URL | redis://redis:6379/0 | Broker connection. |
CELERY_RESULT_BACKEND | redis://redis:6379/1 | Task result store. |
RESUMERADAR_WORKER_TASK_RETRIES | 3 | Max retries per task (with backoff). |
Rate limiting
| Variable | Default | Purpose |
|---|---|---|
RESUMERADAR_RATE_LIMIT_USER_CAPACITY | 100 | Authenticated bucket size. |
RESUMERADAR_RATE_LIMIT_PUBLIC_CAPACITY | 20 | Anonymous bucket size. |
LLM / OCR (optional, off by default)
| Variable | Default | Purpose |
|---|---|---|
LLM_ENABLED | false | Master switch; sub-flags gate section detection, extraction, guidance. |
LLM_SHADOW_MODE | true | Store/validate LLM proposals without letting them affect scores. |
RESUMERADAR_LLM_PROVIDER | openai | openai · gemini · groq (key set separately). |
OCR_ENABLED | false | Enable OCR fallback for image-only documents. |
Setup, testing & CI
Set RESUMERADAR_JWT_SECRET and run docker compose up --build from the repository root to bring up the full stack:
- backend (FastAPI / Uvicorn) :8000
- frontend (Next.js) :3000
- celery worker
- PostgreSQL 16 :5432 + PgBouncer :6432
- Redis :6379
- MinIO :9000 / console :9001
- Mailpit SMTP :1025 / UI :8025
- pgAdmin :8080
Continuous integration runs four jobs on every change:
- Backend tests
- Runs the pytest suite against the FastAPI app and services.
- Frontend checks
- ESLint, TypeScript type-check, and the Vitest suite (with MSW).
- Alembic migrations
- Validates the migration chain applies cleanly and matches the models.
- Docker build
- Verifies the production backend and frontend images build.