An AI-assisted Applicant Tracking System. A candidate applies through a public job board; the system parses their CV with an LLM, enriches the profile from public GitHub data, ranks it semantically against the job posting, routes it to a review panel, and helps schedule the interview.
Live: smartats.tech — no install needed.
The public job board at /careers is open to
everyone; recruiter screens need an account, which you can create yourself at
/register.
Coursework project for Introduction to Software Engineering (Intro2SE), VNUHCM — University of Science.
- What it does
- What makes it interesting
- Architecture
- Roles and permissions
- Run it locally
- A tour of the API
- Testing
- Deployment
- Configuration
- Known limitations
- License
Four flows, matching the SRS:
A · Apply. A candidate opens a shared job link, fills in a screening questionnaire, and uploads a PDF CV. Gemini extracts structured fields — name, contact, skills, experience, education — and the application is persisted with the file in Azure Blob Storage. No account required, ever: a candidate arriving through a link has no session and never will.
B · Rank. A recruiter describes the role in plain language ("senior backend
engineer, distributed systems, must know Go"). Required skills run first as a
hard filter — missing one is elimination regardless of how good the
semantic score is — then multilingual-e5-base embeddings rank whoever
survives, via a pgvector query inside Postgres rather than in application code.
C · Enrich. GitHub activity is pulled and summarised into a skills matrix, with live progress streamed over a WebSocket. Results are persisted, so they survive a restart instead of being recomputed on every page view.
D · Review and schedule. HR invites Tech Leads onto a review panel per job posting. A candidate advances only when 80% of the panel approves — the threshold, the required count, and the rule are computed server-side and sent to the browser, so the UI never re-derives them. Once approved, a sweep-line pass over every interviewer's Google Calendar free/busy finds the slots where everyone is genuinely free, and the confirmation writes a calendar event plus a Slack notification.
These are the decisions worth reading the code for.
Candidate PII is masked by the API, not by the UI. tech_lead accounts see
*** instead of names, emails and phone numbers. The masking happens in
modules/shared/infrastructure/abac.py on the way out of the backend — if the
UI were doing the hiding, the real values would still be sitting in the network
response for anyone who opened devtools. The whitelist is default-deny, so
a PII column added next year is masked automatically instead of leaking until
somebody notices.
Bias-sensitive fields are redacted for everyone, including admins. Race, gender identity, disability and veteran status, age band — masked for every role, no exceptions. They stay in the database for anonymised aggregate reporting only. Showing them on a screening screen puts bias exactly at the point of decision.
A tech_lead who is not on the panel gets 404, not 403. 403 would
confirm that the candidate exists, which turns the endpoint into a probe for
enumerating applicants.
Hard filters run before semantic search. A cosine score of 0.94 does not outrank a missing mandatory skill. Relevance and requirements are different questions, and conflating them is how ranked search quietly recommends unqualified people.
The smoke test asserts outcomes, not status codes.
src/backend/scripts/smoke_flows.py runs 35 checks against a real deployment:
ranking must be monotonically decreasing, a hard filter must actually shrink
the result set, tech_lead must not be able to read a candidate's name. It
has twice caught 500s that the entire unit-test suite passed straight through.
Nothing reports success it did not achieve. Email delivery returns 503
with a reason when SMTP is unconfigured rather than a cheerful true;
notification results are written back to the database instead of only being set
on an in-memory object. An honest error beats a green light that means nothing.
Row-level security is on. The anon key ships inside the public JavaScript
bundle, so every table has RLS enabled except published job postings. Signed-in
screens go through /api/catalog/*, where the backend holds the service key
and decides authorisation itself.
Candidate ──▶ /careers ─┐
│ ┌──────────────────────────┐
Recruiter ──▶ workspace ─┴──────▶ │ Next.js 15 (App Router) │
└────────────┬─────────────┘
│ JWT access + refresh
▼
┌──────────────────────────┐
│ FastAPI · modules/* │
│ ABAC masking on egress │
└────────────┬─────────────┘
┌─────────────────────────────┼──────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────────────┐ ┌───────────────┐
│ Gemini │ │ Supabase │ │ GitHub API │
│ CV parsing │ │ Postgres+pgvector│ │ Google Calendar│
└─────────────┘ │ RLS enabled │ │ Slack webhook │
└──────────────────┘ └───────────────┘
▲
┌──────────────────┐
│ Azure Blob │
│ CV files │
└──────────────────┘
The backend is organised by module, not by technical layer. Each module
under src/backend/modules/ owns its own domain/, application/,
adapters/ and infra/, so one feature reads top to bottom in one place.
| Module | Responsibility |
|---|---|
auth |
Registration, email/password and Google sign-in, JWT issue and refresh |
ingestion |
CV upload, PDF extraction, Gemini parsing, application records |
enrichment |
GitHub enrichment, skills matrix, live progress over WebSocket |
scoring |
Job posting embeddings for semantic matching |
search |
Semantic candidate search: hard filters, then pgvector ranking |
review |
Review panels, approval threshold, decisions |
scheduling |
Sweep-line slot search over calendars, Google Calendar, Slack |
catalog |
Read models for signed-in screens (replaces direct PostgREST calls) |
admin |
User management, session revocation, ABAC policies, audit log |
shared |
Config, Supabase clients, auth dependencies, ABAC masking, rate limits |
Stack — Next.js 15 · React 19 · TypeScript (strict) · Tailwind ·
FastAPI · Pydantic v2 · structlog · Supabase (Postgres + pgvector) ·
Google Gemini · sentence-transformers (multilingual-e5-base) · LangGraph.
Exactly three roles. modules/shared/domain/roles.py is the single source of
truth, mirrored 1:1 by src/frontend/lib/rbac.ts.
| Role | Access |
|---|---|
admin |
Admin Panel only — deliberately excluded from operational screens |
hr |
Full recruiting workflow, sees complete candidate data |
tech_lead |
The same screens as hr, but candidate PII is masked by the API |
Public registration lets you pick between hr and tech_lead. admin is not
self-assignable: the request model accepts only those two values and the
service checks again before touching the database. Admin accounts come from the
seed script or the Admin Panel.
You only need this if you want to change the code — the deployed app is at smartats.tech.
- Python 3.10+ and Node.js 20+
- A Supabase project (URL, anon key, service key, service-role key)
- A Google Gemini API key for CV parsing
- ~3 GB free disk: the embedding model is ~1 GB and PyTorch another ~1.6 GB
git clone https://github.com/HDT-0103/Applicant_Tracking_System.git
cd Applicant_Tracking_Systempython3 -m venv venv
./venv/bin/python -m pip install -r requirements.txt
npm installcp .env.example .envFour variables are required — the backend reads settings at import time, so a missing one stops startup immediately instead of failing later on a request:
SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, JWT_SECRET
One more is required in practice, and its absence is nastier because the app
still starts and /health still answers: SUPABASE_SERVICE_KEY. It is a
different variable from SUPABASE_SERVICE_ROLE_KEY — the admin Supabase
client reads it straight from the environment. Without it, every route that
bypasses RLS (ingest, catalog, search, scheduling, review) silently gets None
and returns 503 or an empty list.
tests/test_env_contract.py fails if a required setting is missing from
.env.example, so the template cannot silently drift out of date.
Apply the numbered migrations in src/backend/migrations/ to your Supabase
project (SQL editor or psql), then create an admin account:
./venv/bin/python src/backend/scripts/seed_admin.pyDefaults to admin@smartats.com / Admin@123 — change the password right
after the first sign-in, or override ADMIN_SEED_EMAIL / ADMIN_SEED_PASSWORD.
docs/RLS_RUNBOOK.md walks through enabling row-level security, and
docs/supabase_schema.md is the table reference.
./start_backend.shnpm run devBackend on http://localhost:8000 (API docs at /docs), frontend on
http://localhost:3000.
On Windows, or if you prefer to type it out:
PYTHONPATH="$(pwd)/src:$(pwd)/src/backend" ./venv/bin/python -m uvicorn apps.main:app --reload --port 8000 --app-dir src/backendBoth paths matter: the repo mixes three import prefixes (modules.*, app.*,
src.backend.app.*), and --app-dir alone does not cover all of them.
The first semantic search of a process loads the embedding model — about 7 seconds and ~1 GB of RAM, cached for the lifetime of the process. That is also why the backend cannot run on serverless platforms.
Full interactive docs at /docs when the backend is running. The operational
endpoints accept hr and tech_lead only — an admin token gets 403
there by design.
Register and get a token (pick hr or tech_lead):
curl -s -X POST http://localhost:8000/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"name":"HR Demo","email":"hr.demo@example.com","password":"Demo@12345","role":"hr"}'Submit a CV — public, no token, PDF only, 10 MB maximum:
curl -s -X POST http://localhost:8000/api/v1/ingest \
-F "file=@cv.pdf;type=application/pdf" -F "job_id=<JOB_ID>"Search candidates semantically:
curl -s -X POST http://localhost:8000/api/search \
-H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' \
-d '{"summary":"senior backend engineer, distributed systems","required_skills":["Go"],"top_k":10}'Enrich a candidate from GitHub (runs in the background, 5–30 s):
curl -s -X POST http://localhost:8000/api/enrichment/<UUID>/sync -H "Authorization: Bearer <TOKEN>"
curl -s http://localhost:8000/api/enrichment/<UUID> -H "Authorization: Bearer <TOKEN>"Live progress for the same job is available over
ws://localhost:8000/api/enrichment/ws/v1/analysis/<UUID>; the first frame the
client sends must be the access token.
./venv/bin/python -m pytest -q # 375 backend tests
cd src/frontend && npm test -- --run # 202 frontend tests
npm run typecheck # tsc --noEmit
npm run build # production buildRun pytest from the repository root: there are two test directories
(tests/ and src/backend/tests/) and the root conftest.py puts both on the
path and loads .env. Tests that need a real Supabase project live in
tests/integration/ and skip themselves unless RUN_INTEGRATION_TESTS=true.
Then there is the smoke test, which is a different kind of check — it runs against a running system and asserts real outcomes:
./venv/bin/python src/backend/scripts/smoke_flows.py # local
BASE=https://<host> ./venv/bin/python src/backend/scripts/smoke_flows.py --jwt-secret "<secret>"It creates one test candidate, tagged [SMOKE], and cleans up after itself
(--keep to inspect the data). Against a remote deployment you must pass that
environment's JWT_SECRET, because the script signs its own tokens.
Scripts that need real API keys or human input live in tests/manual/ and are
deliberately not named test_*.py: pytest imports such a file, finds no
test functions, reports nothing, and that reads exactly like a pass.
The frontend runs on Vercel, the backend on Azure Container Apps.
docs/DEPLOY.md (in Vietnamese) is the full runbook:
first-time infrastructure, shipping a new version, custom domains,
troubleshooting, and the traps that cost this team time.
The backend is a long-lived container by necessity, not preference: the
embedding model is cached in-process, enrichment continues in
BackgroundTasks after the response is sent, and there is a WebSocket. None of
those survive a serverless function.
The full list is in .env.example. The ones that matter most:
| Variable | Default | Notes |
|---|---|---|
SUPABASE_URL |
— | Required. |
SUPABASE_ANON_KEY |
— | Required. Public; subject to RLS. |
SUPABASE_SERVICE_ROLE_KEY |
— | Required. Bypasses RLS — backend only, never the frontend. |
SUPABASE_SERVICE_KEY |
— | Required in practice. Read directly by the admin client; see above. |
JWT_SECRET |
— | Required. Use a fresh value per environment. |
CORS_ORIGINS |
http://localhost:3000 |
Comma-separated. Every deployed frontend origin must be listed. |
GEMINI_API_KEY |
— | CV parsing. Without it, applications are created empty. |
GEMINI_MODEL |
gemini-3.6-flash |
Pinned deliberately; Google removes older models. |
GITHUB_API_TOKEN |
— | Needs public_repo. An expired token makes enrichment return empty scores. |
AZURE_STORAGE_CONNECTION_STRING |
— | Where CV files are stored. |
SLACK_WEBHOOK_URL |
— | Optional. Interview confirmations. |
SMTP_* |
— | Optional. Unset means send_room_details returns 503 with a reason. |
ACCESS_TOKEN_EXPIRE_MINUTES |
60 |
|
REFRESH_TOKEN_EXPIRE_DAYS |
7 |
Refresh rotates the token, so an active user is never signed out. |
MAX_UPLOAD_MB |
10 |
Written down rather than hidden — most of these are deliberate trade-offs made under a course deadline.
- Two data paths on the frontend. Newer screens call the backend API with a JWT; a few older ones still query Supabase directly with the anon key. They disagree about session state, so part of the app can keep working after a session dies.
src/backend/app/is a second, older tree. SQLAlchemy models and LangGraph pipelines coexist with the Supabase SDK.modules/searchbridges the two; new work belongs inmodules/.- Public signup grants an operational role. Anyone can create an
hrortech_leadaccount. Panel gating and ABAC limit what a freshtech_leadcan actually see, but this would need an approval queue in production. - LinkedIn enrichment is disabled. It requires a paid Apify token.
- Responsive layout is thin. The app targets desktop.
- PyTorch cannot be upgraded on Intel macOS — upstream stopped publishing x86_64 wheels after 2.2.2. The remaining advisories are a platform limit, not a choice.
MIT — use it, fork it, learn from it.