Resume RadarSign in
Documentation

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. 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. 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. 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. 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. 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

DocumentAccepted formatsSize
ResumePDF (.pdf), Word (.docx)Up to 10 MB
Job descriptionText (.txt), PDF (.pdf), Word (.docx) — or paste text directlyUp 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.
Email
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. 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. 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. 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. 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. 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.

The authority boundary

The central design decision: deterministic analysis is authoritative, and the LLM is strictly advisory. Each layer has one job, and the score is computed only from accepted facts:

  • Parsers recover the original text, document blocks, character offsets, and a parse-quality score.
  • Extractors only propose skill candidates — they decide nothing.
  • Validators establish exact evidence, taxonomy mapping, calibrated confidence, and a review status for each candidate.
  • Normalizers stamp the canonical skill facts (skill_id) onto accepted skills.
  • The scorer computes the official result from accepted facts only — it is LLM-free and fully deterministic.
  • Optional LLM guidance explains an already-finished result; it cannot alter scores, weights, counts, matches, or requirements.

In LLM_SHADOW_MODE the system may store and validate LLM proposals for evaluation, but LLM-only candidates cannot enter extracted_skills or influence the official score.

Analysis pipeline

An analysis runs as a Celery task that advances through a guarded state machine. Each row below is one status transition:

  1. QUEUED → PARSINGThe worker claims the job atomically (so a retry never double-runs) and verifies both documents have usable extracted text.
  2. EXTRACTINGDeterministic extraction (plus optional LLM proposals) produces ExtractionCandidate rows, each with evidence offsets and several confidence signals.
  3. NORMALIZINGCandidates are validated and mapped to canonical skills; accepted ones are materialized into ExtractedSkill rows stamped with a skill_id.
  4. SCORINGThe DeterministicSkillScorer compares job vs resume skills, writes one match row per job skill, and computes the 0–100 score.
  5. GENERATING_SUGGESTIONSOptionally, the LLM writes a fit summary and prioritized suggestions. The result row is finalized regardless.
  6. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

VariableDefaultPurpose
DATABASE_URLpostgresql+psycopg://…@db:5432/…Primary Postgres connection (via PgBouncer).
DATABASE_MIGRATIONS_URL(direct connection)Bypasses the pooler for Alembic.
DB_POOL_SIZE5SQLAlchemy pool size (+ overflow, recycle, pre-ping).

Auth & JWT

VariableDefaultPurpose
RESUMERADAR_JWT_SECRET(required in prod)Signing key for access tokens.
RESUMERADAR_ACCESS_TOKEN_MINUTES15Access-token lifetime.
RESUMERADAR_REFRESH_TOKEN_DAYS7Refresh-session lifetime.
RESUMERADAR_PASSWORD_ITERATIONS390000PBKDF2 iterations.

Storage (S3/MinIO)

VariableDefaultPurpose
S3_ENDPOINT_URLhttp://minio:9000S3-compatible endpoint.
S3_BUCKET_NAMEresumeradarBucket for private documents.
RESUMERADAR_MAX_RESUME_UPLOAD_BYTES10485760Upload size cap (10 MiB).

Redis / Celery

VariableDefaultPurpose
REDIS_URLredis://redis:6379/0Broker connection.
CELERY_RESULT_BACKENDredis://redis:6379/1Task result store.
RESUMERADAR_WORKER_TASK_RETRIES3Max retries per task (with backoff).

Rate limiting

VariableDefaultPurpose
RESUMERADAR_RATE_LIMIT_USER_CAPACITY100Authenticated bucket size.
RESUMERADAR_RATE_LIMIT_PUBLIC_CAPACITY20Anonymous bucket size.

LLM / OCR (optional, off by default)

VariableDefaultPurpose
LLM_ENABLEDfalseMaster switch; sub-flags gate section detection, extraction, guidance.
LLM_SHADOW_MODEtrueStore/validate LLM proposals without letting them affect scores.
RESUMERADAR_LLM_PROVIDERopenaiopenai · gemini · groq (key set separately).
OCR_ENABLEDfalseEnable 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.