push the scoring connect

RecruiterHub
ahmed.mujtaba 2026-08-17 14:31:30 +05:00
parent 221633be0e
commit f7778538dd
13 changed files with 715 additions and 126 deletions

41
.dockerignore Normal file
View File

@ -0,0 +1,41 @@
# Build context for backend/Dockerfile and app/Dockerfile is the repo root, so this
# file decides what is even eligible to be copied into those images.
.git/
.gitignore
.gitignore.local
.claude/
.cursor/
.vscode/
.idea/
# Secrets are passed at runtime via compose `env_file` — never baked into a layer.
**/.env
**/.env.*
!**/.env.example
**/__pycache__/
**/*.py[cod]
**/*.egg-info/
.venv/
venv/
env/
.mypy_cache/
.pytest_cache/
.ruff_cache/
# Frontend has its own context (./frontend) and its own .dockerignore; nothing of it
# belongs in a Python image, and node_modules would dominate the context transfer.
frontend/
# Candidate CVs live on the bind mount, not inside an image.
backend/inbox/decoded_attachments/
docs/
tests/
scripts/
tools/
*.md
*.log
tmp/
temp/

View File

@ -113,6 +113,83 @@ Optional — background inbox sync workers (need Redis):
docker compose up redis taskiq-worker taskiq-scheduler
```
## Docker
Every service has its own image and its own container, all in one
[docker-compose.yml](docker-compose.yml). **Postgres is in that file but does not run**
— it sits behind a compose profile, and the stack talks to the PostgreSQL server
already running on the host.
```bash
docker compose build
docker compose up -d
docker compose ps
```
| Service | Image | Host port | Built from |
|---|---|---|---|
| `backend-api` | `hrms-backend:local` | 8000 | [backend/Dockerfile](backend/Dockerfile) |
| `taskiq-worker` · `taskiq-scheduler` · `taskiq-cv-worker` · `taskiq-cv-scheduler` | `hrms-backend:local` (same image, different `command`) | — | same |
| `ats-engine` | `hrms-ats-engine:local` | 8100 | [app/Dockerfile](app/Dockerfile) |
| `frontend` | `hrms-frontend:local` | 5173 | [frontend/Dockerfile](frontend/Dockerfile) |
| `redis` | `redis:7-alpine` | 6379 | — |
| `postgres` *(profile `postgres` — never starts by default)* | `hrms-postgres:local` | 5433 | [docker/postgres/Dockerfile](docker/postgres/Dockerfile) |
The backend image builds from the **repo root**, not `./backend`: `job/candidate`
imports the scoring engine from `app/`, and `inbox.plugins` pulls that in transitively,
so a `./backend` context produces workers that die on `No module named 'app'`.
### The shared file mount
`backend/inbox/decoded_attachments/` on the host is bind-mounted into every container
that touches a CV — `backend-api`, `taskiq-worker`, `taskiq-cv-worker` — at the
identical path `/app/inbox/decoded_attachments`. A PDF written by the API is the same
file the worker opens, and absolute paths stored in the database resolve in either
direction (`inbox.plugins.resolve_attachment_path` also falls back to
basename-under-that-folder for rows written by a host process). Point it elsewhere with
`ATTACHMENTS_DIR=/some/host/path`.
### Talking to the host
`backend/.env` is written for host processes, so compose overrides the three values a
container needs: `DB_HOST=host.docker.internal` (the local Postgres),
`EMAIL_URL=http://host.docker.internal:5000` (the email service on the host), and
`REDIS_URL=redis://redis:6379/0`. The host Postgres must accept connections from the
Docker bridge — `listen_addresses = '*'` plus a `pg_hba.conf` entry for `172.16.0.0/12`.
> **Stop the host `uvicorn` and `npm run dev` first.** Windows lets a host process bind
> `127.0.0.1:8000` while Docker binds `0.0.0.0:8000`, and `localhost` resolves to `::1`
> first — so both listen and requests silently reach whichever won. Same for 5173. Use
> `BACKEND_PORT` / `FRONTEND_PORT` / `ATS_PORT` if both must run.
`VITE_API_BASE` is inlined into the bundle at **build** time (default
`http://localhost:8000`), so changing the API origin means rebuilding the frontend
image, not restarting the container.
### The Postgres profile
The image is defined alongside everything else, but the `postgres` profile keeps it out
of `docker compose build` and `docker compose up` — bringing it up is always explicit:
```bash
docker compose --profile postgres build postgres
docker compose --profile postgres up -d postgres # host port 5433; 5432 is the host server's
```
Pointing the app at it is a second, deliberate step: set `DB_HOST=postgres` (the only
value that changes — services reach it on 5432 over the compose network) and recreate
the services. Its volume starts empty, so Alembic rebuilds the schema on first boot; it
does not share the host server's data.
### Live-code overlay
[docker-compose.dev.yml](docker-compose.dev.yml) is not a second stack — it defines no
services or images, it only adds source bind mounts and `--reload` to the ones above:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
```
### 4. First run
1. Sign up / log in (`/auth/login`) — the user needs a role carrying

35
app/Dockerfile Normal file
View File

@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
#
# Bulk ATS scoring engine — the standalone FastAPI service (CLAUDE.md is its spec).
# Serves POST /api/v1/score, GET /api/v1/health and the card-grid test UI at /.
#
# The backend imports this same package as a library; this image is the separate
# service form of it, so it can be scaled, restarted or pointed at a different model
# independently of the portal API.
#
# THE BUILD CONTEXT IS THE REPO ROOT (pyproject.toml lives there):
#
# docker build -f app/Dockerfile -t hrms-ats-engine:local .
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/srv
WORKDIR /srv
COPY pyproject.toml ./
COPY app/ ./app/
# Installs the pinned dependencies from pyproject.toml along with the package. The
# copy at /srv/app stays on sys.path ahead of the installed one, so the dev overlay's
# source bind mount is what actually executes.
RUN pip install --no-cache-dir .
EXPOSE 8100
# No module-level `app` object exists on purpose (app/main.py), so the factory form
# is mandatory here.
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8100"]

View File

@ -1,13 +1,43 @@
# syntax=docker/dockerfile:1
#
# Backend image. The FastAPI API and all four Taskiq processes (inbox worker and
# scheduler, CV worker and scheduler) run from this one image; docker-compose picks
# the process with `command:`.
#
# THE BUILD CONTEXT IS THE REPO ROOT, not ./backend:
#
# docker build -f backend/Dockerfile -t hrms-backend:local .
#
# backend/job/candidate imports the bulk-ats scoring engine (`app.core.errors`,
# `app.services.pdf`, `app.services.scoring`), which lives in app/ at the repo root
# and is pulled in transitively by inbox.plugins -> inbox.tasks. A ./backend context
# cannot see it, so the workers would die on import.
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/app
COPY requirements.txt .
WORKDIR /app
COPY backend/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Backend tree at /app; the scoring engine at /app/app so `import app.services.pdf`
# resolves under PYTHONPATH=/app. requirements.txt says to `pip install -e ..` for
# this in a host environment — copying it in is the container equivalent, and its
# dependencies (openai, pypdf, pydantic-settings, python-multipart) are already pinned
# above.
COPY backend/ /app/
COPY app/ /app/app/
# Runs the Taskiq worker against taskiq_management.broker_setup.
# docker-compose overrides this command if needed.
CMD ["taskiq", "worker", "taskiq_management.broker_setup:broker", "inbox.tasks", "inbox.sync_tasks", "taskiq_management.tasks"]
# Decoded CV attachments are read and written here. docker-compose bind-mounts the
# host folder over this path so every container shares one set of files; creating it
# in the image keeps an un-mounted container from failing on first write.
RUN mkdir -p /app/inbox/decoded_attachments
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

60
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,60 @@
# Live-code overlay. Mounts the source folders into the running containers, so what
# executes is what is on disk on the host — edit, save, uvicorn reloads.
#
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
#
# The CV attachments mount from the base file still applies: compose merges volumes
# by target path, and /app/inbox/decoded_attachments is nested under the /app mount,
# so the daemon mounts the parent first and the attachments folder on top.
#
# Two mounts per Python service, not one: /app is the backend tree and /app/app is the
# bulk-ats engine that backend/job/candidate imports. Mounting only ./backend over
# /app would hide the engine baked into the image and every worker would fail on
# import.
#
# The frontend stays as the built nginx image — a Vite dev server wants node_modules
# on the mount, which is slow and fragile across a Windows bind mount. Run
# `npm run dev` on the host for the frontend loop.
services:
backend-api:
command:
["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
volumes:
- ./backend:/app
- ./app:/app/app
ats-engine:
command:
[
"uvicorn",
"app.main:create_app",
"--factory",
"--host",
"0.0.0.0",
"--port",
"8100",
"--reload",
]
volumes:
- ./app:/srv/app
taskiq-worker:
volumes:
- ./backend:/app
- ./app:/app/app
taskiq-scheduler:
volumes:
- ./backend:/app
- ./app:/app/app
taskiq-cv-worker:
volumes:
- ./backend:/app
- ./app:/app/app
taskiq-cv-scheduler:
volumes:
- ./backend:/app
- ./app:/app/app

View File

@ -1,4 +1,82 @@
# HR-ATS-Portal — every service and every image, in one file.
#
# docker compose build # hrms-backend / hrms-ats-engine / hrms-frontend
# docker compose up -d
# docker compose ps
# docker compose logs -f backend-api
#
# ── Postgres ──────────────────────────────────────────────────────────────────────
# The `postgres` service at the bottom is behind a compose PROFILE, so it is defined
# and buildable here but never starts with a plain `docker compose up`. The stack
# talks to the PostgreSQL server already running on the host, via
# DB_HOST=host.docker.internal (backend/.env says localhost — correct for a host
# process, wrong inside a container).
#
# Host Postgres must accept connections from the Docker bridge: listen_addresses = '*'
# in postgresql.conf and a pg_hba.conf line for 172.16.0.0/12 (or the specific subnet).
#
# To build/run the containerised database instead, see the comments on that service.
#
# ── Ports ─────────────────────────────────────────────────────────────────────────
# Stop a host `uvicorn` (8000) and `npm run dev` (5173) before starting these, or
# override with BACKEND_PORT / FRONTEND_PORT / ATS_PORT. Windows lets a host process
# bind 127.0.0.1:8000 while Docker binds 0.0.0.0:8000, and `localhost` resolves to ::1
# first — both listen, and requests reach whichever won.
#
# ── Overlay ───────────────────────────────────────────────────────────────────────
# docker-compose.dev.yml adds live source mounts and --reload on top of this file. It
# defines no services or images of its own; it only overrides the ones here:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
x-backend-build: &backend-build
# Root context, not ./backend: backend/job/candidate imports the bulk-ats engine
# from app/, which sits outside the backend folder. See backend/Dockerfile.
context: .
dockerfile: backend/Dockerfile
x-backend-env: &backend-env
PYTHONPATH: /app
# backend/.env is written for host processes; these are the values a container needs.
#
# DB_HOST defaults to the LOCAL Postgres server on the host. Set DB_HOST=postgres in
# the shell (or a root .env) to point the whole stack at the container below instead
# — that is the only value that has to change, since services reach it over the
# compose network on 5432, not the published host port.
DB_HOST: ${DB_HOST:-host.docker.internal}
REDIS_URL: redis://redis:6379/0
EMAIL_URL: http://host.docker.internal:5000
BACKEND_URL: http://backend-api:8000
# The one shared folder. Every process that decodes, scores or serves a CV reads and
# writes the same host directory, so a file written by the API is the same file the
# worker opens. Absolute paths stored in the DB match across services because the
# mount target is identical everywhere; inbox.plugins.resolve_attachment_path also
# falls back to basename-under-this-directory for rows written by a host process.
x-attachments: &attachments
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
x-backend-service: &backend-service
build: *backend-build
image: hrms-backend:local
working_dir: /app
env_file:
- ./backend/.env
environment: *backend-env
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
redis:
condition: service_healthy
# required:false — the default stack uses the host's Postgres and never starts
# this one, and that must not be an error. When the profile IS active, startup
# waits for it to pass pg_isready.
postgres:
condition: service_healthy
required: false
restart: unless-stopped
services:
# --- Redis (broker + result backend for taskiq) ----------------------------------
redis:
image: redis:7-alpine
container_name: hrms-redis
@ -14,11 +92,80 @@ services:
retries: 5
restart: unless-stopped
taskiq-worker:
# --- portal API ------------------------------------------------------------------
backend-api:
<<: *backend-service
container_name: hrms-backend-api
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "${BACKEND_PORT:-8000}:8000"
volumes: *attachments
healthcheck:
# python:slim has no curl and the API exposes no /health route, so this is a
# plain TCP check against the uvicorn socket.
test:
[
"CMD",
"python",
"-c",
"import socket;socket.create_connection(('127.0.0.1',8000),3).close()",
]
interval: 15s
timeout: 5s
retries: 5
start_period: 40s
# --- bulk ATS scoring engine (standalone service form of app/) --------------------
ats-engine:
build:
context: ./backend
context: .
dockerfile: app/Dockerfile
image: hrms-ats-engine:local
container_name: hrms-ats-engine
env_file:
# OPENAI_API_KEY currently lives in backend/.env; a root .env (see .env.example)
# overrides it when present, and the stack still comes up when it is not.
- ./backend/.env
- path: ./.env
required: false
ports:
- "${ATS_PORT:-8100}:8100"
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8100/api/v1/health',timeout=3)",
]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
# --- React portal -----------------------------------------------------------------
frontend:
build:
context: ./frontend
args:
# Baked into the bundle at build time — change it and rebuild, not restart.
VITE_API_BASE: ${VITE_API_BASE:-http://localhost:8000}
image: hrms-frontend:local
container_name: hrms-frontend
ports:
- "${FRONTEND_PORT:-5173}:80"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 15s
timeout: 5s
retries: 5
restart: unless-stopped
# --- background processing (same image as backend-api, different command) ---------
taskiq-worker:
<<: *backend-service
container_name: hrms-taskiq-worker
working_dir: /app
command:
[
"taskiq",
@ -30,53 +177,29 @@ services:
"--workers",
"1",
]
env_file:
- ./backend/.env
environment:
PYTHONPATH: /app
REDIS_URL: redis://redis:6379/0
<<: *backend-env
TASKIQ_QUEUE_NAME: inbox
TASKIQ_WORKER_NAME: worker-01
# .env uses localhost for the host-side API; containers must reach the host.
DB_HOST: host.docker.internal
EMAIL_URL: http://host.docker.internal:5000
BACKEND_URL: http://host.docker.internal:8000
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./backend/inbox/decoded_attachments:/app/inbox/decoded_attachments
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
volumes: *attachments
taskiq-scheduler:
build:
context: ./backend
<<: *backend-service
container_name: hrms-taskiq-scheduler
working_dir: /app
command: ["taskiq", "scheduler", "taskiq_management.broker_setup:scheduler", "inbox.sync_tasks"]
env_file:
- ./backend/.env
command:
[
"taskiq",
"scheduler",
"taskiq_management.broker_setup:scheduler",
"inbox.sync_tasks",
]
environment:
PYTHONPATH: /app
REDIS_URL: redis://redis:6379/0
<<: *backend-env
TASKIQ_QUEUE_NAME: inbox
DB_HOST: host.docker.internal
EMAIL_URL: http://host.docker.internal:5000
BACKEND_URL: http://host.docker.internal:8000
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
taskiq-cv-worker:
build:
context: ./backend
<<: *backend-service
container_name: hrms-taskiq-cv-worker
working_dir: /app
command:
[
"taskiq",
@ -86,30 +209,15 @@ services:
"--workers",
"1",
]
env_file:
- ./backend/.env
environment:
PYTHONPATH: /app
REDIS_URL: redis://redis:6379/0
<<: *backend-env
TASKIQ_CV_QUEUE_NAME: cv_upload
TASKIQ_WORKER_NAME: cv-worker-01
DB_HOST: host.docker.internal
EMAIL_URL: http://host.docker.internal:5000
BACKEND_URL: http://host.docker.internal:8000
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./backend/inbox/decoded_attachments:/app/inbox/decoded_attachments
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
volumes: *attachments
taskiq-cv-scheduler:
build:
context: ./backend
<<: *backend-service
container_name: hrms-taskiq-cv-scheduler
working_dir: /app
command:
[
"taskiq",
@ -117,21 +225,49 @@ services:
"taskiq_management.cv_broker_setup:cv_scheduler",
"inbox.cv_tasks",
]
env_file:
- ./backend/.env
environment:
PYTHONPATH: /app
REDIS_URL: redis://redis:6379/0
<<: *backend-env
TASKIQ_CV_QUEUE_NAME: cv_upload
DB_HOST: host.docker.internal
EMAIL_URL: http://host.docker.internal:5000
BACKEND_URL: http://host.docker.internal:8000
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
redis:
condition: service_healthy
# --- Postgres: DEFINED HERE, NOT STARTED BY DEFAULT -------------------------------
# The profile is what keeps it out of `docker compose build` and `docker compose up`.
# Nothing about the default stack changes by its presence in this file.
#
# docker compose --profile postgres build postgres # build the image
# docker compose --profile postgres up -d postgres # run it, host port 5433
#
# Pointing the app at it is a separate, deliberate step — set DB_HOST=postgres (see
# x-backend-env) and recreate the services. The volume starts empty, so Alembic
# rebuilds the schema on first boot; it does not share the host server's data.
postgres:
profiles: ["postgres"]
build:
context: ./docker/postgres
image: hrms-postgres:local
container_name: hrms-postgres
environment:
# Interpolated from the shell or a root .env, NOT from backend/.env — compose
# variable substitution and container environment are different things.
# Defaults match backend/.env.example.
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_NAME:-hrms}
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
ports:
# 5433: the host's own Postgres server owns 5432. Only for host-side tools —
# containers reach this one on 5432 over the compose network.
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
volumes:
redis-data:
postgres-data:

View File

@ -0,0 +1,24 @@
# syntax=docker/dockerfile:1
#
# Postgres image — BUILT, BUT NOT USED BY THE DEFAULT STACK.
#
# Every service in docker-compose.yml points at the Postgres already running on the
# host (DB_HOST=host.docker.internal). This image exists so the database can be
# containerised on demand — a clean machine, a throwaway test run, a second dev — and
# it is kept in its own file (docker-compose.postgres.yml) so bringing it up is always
# a deliberate act:
#
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml build postgres
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d postgres
#
# Context is ./docker/postgres.
FROM postgres:16-alpine
# The inbox tables store tz-aware timestamps and the app reads them as UTC
# (backend/migrations/versions/20260812_1035-b3f1c2d4e5a6_inbox_timestamps_tz_aware.py).
ENV TZ=UTC \
PGTZ=UTC
# Runs once, against an empty data volume only.
COPY initdb/ /docker-entrypoint-initdb.d/

View File

@ -0,0 +1,17 @@
-- Runs once, on first initialisation of an empty data volume.
-- Timestamps are stored tz-aware and read back as UTC by the backend.
ALTER SYSTEM SET timezone TO 'UTC';
ALTER SYSTEM SET log_timezone TO 'UTC';
-- Known migration gap in the inbox module: Alembic autogeneration creates every
-- table on first boot, but not this enum type, and a brand-new database fails
-- without it (README "Fresh database — one manual step").
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_application_status') THEN
CREATE TYPE candidate_application_status AS ENUM
('PROCESS','PENDING','APPROVED','REJECTED','ONHOLD','CLOSED');
END IF;
END
$$;

9
frontend/.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules/
dist/
.vite/
tmp/
.env
.env.*
!.env.development
!.env.production
*.log

30
frontend/Dockerfile Normal file
View File

@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
#
# React portal: Vite build in node, served by nginx with the SPA history fallback.
# Context is ./frontend.
#
# docker build -t hrms-frontend:local ./frontend
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Vite inlines VITE_* at BUILD time, so the API origin is fixed when the image is
# built, not when the container starts — rebuild the image to point it elsewhere.
# `.env.production.local` outranks every other env file, so this wins over the empty
# VITE_API_BASE in .env.production (which means "same origin, behind a proxy").
ARG VITE_API_BASE=http://localhost:8000
RUN printf 'VITE_API_BASE=%s\n' "$VITE_API_BASE" > .env.production.local \
&& npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html
EXPOSE 80

View File

@ -23,7 +23,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-IKokchhk.js"></script>
<script type="module" crossorigin src="/assets/index-D-apCYSF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
</head>
<body>

30
frontend/nginx.conf Normal file
View File

@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# One SPA at `/`. `vite dev` and `vite preview` serve index.html for every
# unmatched path; vite.config.js notes that a static deploy needs the equivalent
# rewrite rule. This is it without it /auth/confirm-email (a router path, not a
# file) 404s when a confirmation email link is opened cold.
location / {
try_files $uri $uri/ /index.html;
}
# Hashed filenames, so they can be cached hard.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# index.html must never be cached, or a redeploy keeps serving the old asset hashes.
location = /index.html {
add_header Cache-Control "no-store";
}
gzip on;
gzip_min_length 1024;
gzip_types text/css text/javascript application/javascript application/json image/svg+xml;
}

View File

@ -1,6 +1,15 @@
/* The profile modal for candidate rows on /candidates (identity from
/candidate/fetch/users, detail from GET /candidate/fetch?user_id=). Distinct
from CandidateProfile.jsx, which renders the 8-tab TalentPool modal. */
from CandidateProfile.jsx, which renders the 8-tab TalentPool modal.
SCORING IS A THIRD, INDEPENDENT READ: GET /pipeline/candidate/score/fetch,
the candidate's current ats_results row. It has to be, because the detail
payload is not a source of scoring at all serialize_candidate_profile and
serialize_manual_candidate_profile both hardcode ai_score, matched_keywords,
missing_keywords, summary_critique and scored_at to null/[]
(backend/job/candidate/serializers.py:116, 174-185). Reading scoring from it
meant every candidate on this screen showed "Not scored yet" however many
times the engine had actually scored them. */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
@ -12,6 +21,7 @@ import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as pipelineApi from '../api/pipeline'
const TABS = ['Overview', 'Scoring', 'File']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
@ -41,6 +51,13 @@ function formatExperience(value, unit) {
return text
}
/** ats_results.computed_at is an ISO string; fmtDate takes a Date. */
function fmtStamp(value) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : fmtDate(d)
}
function useCandidateDetail(userId) {
return useQuery({
queryKey: qk.candidates.detail(userId),
@ -49,11 +66,51 @@ function useCandidateDetail(userId) {
})
}
/**
* The candidate's current ats_results row the only scoring source this modal has.
*
* Sent WITHOUT job_post_id, for the same reason Talent Pool omits it: a row here
* is a candidate USER account with no job context (toCandidateUserView leaves
* jobId null), so pinning could only ever hide a score that exists under some
* other post. Unpinned, the endpoint answers with the newest current score the
* candidate has anywhere.
*
* Keyed by qk.pipeline.candidateScore, so re-opening the same candidate or
* opening one already viewed in Talent Pool repaints from cache.
*/
function useAtsResult(userId) {
return useQuery({
queryKey: qk.pipeline.candidateScore({ userId: userId ?? null }),
queryFn: () => pipelineApi.fetchCandidateScore({ userId }),
select: pipelineApi.toAtsScore,
enabled: Boolean(userId),
})
}
/**
* job_post_id -> title, so the score reads as "scored against Senior Backend
* Engineer" rather than a uuid. Same query key and row shape as the Candidates
* screen's own jobs query, so this is a cache hit rather than a second request.
*/
function useJobTitles() {
return useQuery({
queryKey: qk.jobPosts.list(),
queryFn: async () => {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
},
select: (rows) => new Map(rows.map((row) => [String(row.id), row.title])),
})
}
export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) {
const [tab, setTab] = useState('Overview')
const isLive = Boolean(c.userId)
const detail = useCandidateDetail(c.userId)
const live = detail.data ?? null
const ats = useAtsResult(c.userId)
const atsRow = ats.data ?? null
const view = useMemo(() => {
const currentTitle = stripSentinel(live?.current_title) ?? c.currentTitle ?? null
@ -65,17 +122,20 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
live?.documents?.[0]?.name || c.filename || null
const matchSummary = live?.match_summary ?? null
const messageId = live?.message_id ?? null
const aiScore = live?.ai_score ?? c.aiScore ?? null
// ats_results wins over the detail payload's denormalised copy, because it is
// the row the copy is made from and on this screen the copy is always null.
const aiScore = atsRow?.overall_score ?? live?.ai_score ?? c.aiScore ?? null
const matchedSkills = live?.matched_keywords ?? c.matchedSkills ?? []
const missingSkills = live?.missing_keywords ?? c.missingSkills ?? []
const critique = live?.summary_critique ?? c.critique ?? null
const errorCode = live?.error_code ?? c.errorCode ?? null
const errorMessage = live?.error_message ?? live?.match_error ?? c.errorMessage ?? null
const scoredFor = live?.job_title ?? jobTitle ?? null
const scored = live
const scored = atsRow != null || (live
? Boolean(live.scored_at || live.ai_score != null)
: c.scoringStatus === 'completed'
: c.scoringStatus === 'completed')
return {
band: atsRow?.band || null,
name: c.name,
email: c.email,
applied: c.applied,
@ -100,7 +160,7 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
sourceLabel: SOURCE_LABEL[source] ?? source ?? '—',
subtitle: filename || c.email || null,
}
}, [c, live, jobTitle])
}, [c, live, atsRow, jobTitle])
// enabled:false stays pending forever in TanStack v5 short-circuit when no userId.
const guard = !isLive ? null
@ -146,8 +206,9 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
</div>
{view.aiScore != null && (
<div style={{ textAlign: 'center' }}>
<ScoreChip score={view.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
<ScoreChip score={Math.round(view.aiScore)} />
{/* The band replaces the static label only when the ATS row answered. */}
<div className="cell-sub" style={{ marginTop: 4 }}>{view.band || 'AI Match'}</div>
</div>
)}
</div>
@ -157,7 +218,12 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
</div>
<div className="tab-pane active">
{guard ?? (<>
{/* Scoring sits OUTSIDE `guard`: it renders from the ats_results query, so
a slow or failed detail fetch must not blank it, and its own loading
and error states belong to that query. */}
{tab === 'Scoring' && <ScoringTab enabled={isLive} ats={ats} fallbackJobTitle={view.scoredFor} />}
{tab !== 'Scoring' && (guard ?? (<>
{tab === 'Overview' && (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
@ -168,67 +234,101 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
<div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
<div className="info-item"><div className="il">Added On</div><div className="iv">{view.applied ? fmtDate(view.applied) : '—'}</div></div>
</div>
{view.scored && (
{/* Gated on the list being non-empty, not on `scored`: the detail
payload never carries keywords, so keying it to the score would
render a heading over a dash for every scored candidate. */}
{view.matchedSkills.length > 0 && (
<>
<div style={LABEL}>Matched Skills</div>
<div className="k-tags">
{view.matchedSkills.length
? view.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)
: <span className="text-muted"></span>}
{view.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)}
</div>
</>
)}
</>
)}
{tab === 'Scoring' && (
view.scored ? (
<>
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
<p className="text-muted" style={{ marginBottom: 18 }}>{view.critique ?? '—'}</p>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({view.matchedSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{view.matchedSkills.length
? view.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Missing Skills ({view.missingSkills.length})
</div>
<div className="k-tags">
{view.missingSkills.length
? view.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
</>
) : (
<EmptyState icon="target" title="Not scored yet">
This candidate has not been scored against a job post.
</EmptyState>
)
)}
{tab === 'File' && (
<div className="info-grid">
<div className="info-item"><div className="il">File Name</div><div className="iv">{view.filename ?? '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
{view.messageId && (
<div className="info-item"><div className="il">Inbox Message</div><div className="iv">{view.messageId}</div></div>
)}
<div className="info-item"><div className="il">Detail</div><div className="iv">{view.matchSummary ?? '—'}</div></div>
{view.errorCode && (
<div className="info-item"><div className="il">Error</div><div className="iv">{view.errorCode}</div></div>
)}
</div>
)}
</>)}
</>))}
</div>
</Modal>
)
}
/**
* The ats_results row, and nothing else.
*
* What that table stores IS the result: overall_score, band, the job post it was
* computed against, and when (backend/inbox/models.py::AtsResults). The matched
* and missing keywords and the critique live on the `candidates` table, which
* this endpoint does not join so they are absent here rather than rendered as
* a heading over a dash.
*/
function ScoringTab({ enabled, ats, fallbackJobTitle }) {
// Before the early returns: hook order cannot depend on query state.
const { data: jobTitles } = useJobTitles()
const row = ats.data ?? null
// enabled:false stays pending forever in TanStack v5, so a candidate with no
// userId must short-circuit rather than spin.
if (!enabled) {
return (
<EmptyState icon="target" title="Not scored yet">
This candidate has no account to look a score up against.
</EmptyState>
)
}
if (ats.isPending) {
return <EmptyState icon="refresh" title="Loading score…">Fetching the ATS result.</EmptyState>
}
if (ats.isError) {
return (
<EmptyState icon="alert" title="Could not load the ATS result">
{friendlyAuthError(ats.error, 'Please try again.')}
</EmptyState>
)
}
if (!row) {
return (
<EmptyState icon="target" title="Not scored yet">
This candidate has not been scored against a job post.
</EmptyState>
)
}
// The uuid resolves to a title only once the jobs list is cached; the prop is
// the fallback, and it is '' on this screen when the row carries no job.
const against = (row.job_post_id && jobTitles?.get(String(row.job_post_id))) || fallbackJobTitle || '—'
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 18, marginBottom: 22 }}>
{/* overall_score is a float column; the ring and the label both want an int. */}
<ScoreChip score={Math.round(row.overall_score ?? 0)} />
<div>
<div className="iv" style={{ fontSize: 16, fontWeight: 600 }}>{row.band || 'Scored'}</div>
<div className="cell-sub">ATS match score out of 100</div>
</div>
</div>
<div className="info-grid">
<div className="info-item">
<div className="il">Scored Against</div>
<div className="iv">{against}</div>
</div>
<div className="info-item">
<div className="il">Scored On</div>
<div className="iv">{fmtStamp(row.computed_at) ?? '—'}</div>
</div>
</div>
</>
)
}