Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Dashboard_Wiring

Dashboard_Wiring
ahmed.mujtaba 2026-08-12 16:16:55 +05:00
commit 17279b567b
17 changed files with 1531 additions and 778 deletions

394
README.md
View File

@ -1,223 +1,253 @@
# Bulk ATS Scoring Engine
# HR-ATS-Portal
One job description in, many resume PDFs in, a score-sorted leaderboard out.
An applicant-tracking system with AI resume scoring: job descriptions and CVs go in,
a validated, score-sorted candidate leaderboard comes out — through a React portal,
a FastAPI backend, and an embeddable LLM scoring engine.
Resumes are extracted with `pypdf`, evaluated concurrently against the job description
with the OpenAI Responses API, validated against a strict schema, and returned as a
single JSON response. A failure on one resume never fails the batch.
[CLAUDE.md](claude.md) is the specification this implements and remains the source of
truth for design decisions.
## Setup
Requires Python 3.11+.
```bash
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS / Linux
pip install -e ".[dev]"
copy .env.example .env # Windows
# cp .env.example .env # macOS / Linux
```
Email inbox (Graph proxy) Recruiter browser
│ attachments │ uploads (PDF)
▼ ▼
┌──────────────────────────────────────────────────┐
│ backend/ (FastAPI + Postgres) │
│ │
│ inbox module ──► agent (LangGraph): │
│ "which job is this CV for?" │
│ │
│ candidate module ──► scoring engine (app/): │
│ "how well does it fit? 0-100" │
│ │ │
│ ▼ │
│ app.candidates table │
└──────────────────────┬───────────────────────────┘
frontend/ (React) — CV Import · Candidates ·
Talent Pool · Inbox · Jobs · RBAC · Auth
```
Put an `OPENAI_API_KEY` in `.env`. `.env` is gitignored; never commit it.
The two AI flows are complementary: the **agent** routes an emailed CV to the job it
is probably applying for; the **scoring engine** evaluates a CV against one chosen
job and persists an evidence-based score.
> On Windows, write `.env` as UTF-8 **without** a BOM. PowerShell 5.1's
> `Set-Content -Encoding utf8` adds one, and a BOM becomes part of the first
> variable's name — that setting then silently reads as empty. The app parses `.env`
> as `utf-8-sig` so it tolerates this, but other tools reading the same file will not.
## Repository layout
Run the service:
| Path | What it is |
|---|---|
| `app/` | **Bulk ATS scoring engine** — standalone FastAPI service *and* importable library. Spec: [CLAUDE.md](CLAUDE.md) (source of truth for its design). |
| `backend/` | **Main backend** — users/RBAC/JWT auth, email inbox sync, job posts + Buffer publishing, agent matching, candidate scoring + persistence. House style: `backend/LLM_CONTEXT_PROMPT.md`. |
| `frontend/` | **React portal** (Vite + react-query). Candidate screens run on live data; remaining screens still use seed data. |
| `scripts/` | Engine verification: `smoke_structured_output.py` (live request-shape check), `audit_scoring.py` (positive/negative scoring audit over real CVs). |
| `tests/` | Engine test suite — 195 tests, no live API calls. |
| `CVS/` | Sample resume PDFs used by the audits. Personal data — do not commit new ones casually. |
```bash
uvicorn app.main:create_app --factory --reload
## Quick start (clean machine)
**Prerequisites:** Python 3.11+, Node 18+, PostgreSQL, an OpenAI API key.
Optional: Redis + Docker (only for background inbox sync / taskiq workers).
### 1. Environment files
Root `.env` (engine + scoring settings — see [.env.example](.env.example)):
```
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-5.4-mini
OPENAI_MAX_OUTPUT_TOKENS=4000
OPENAI_EFFORT=low
SCORING_CONCURRENCY=5
MAX_RESUMES_PER_REQUEST=50
MAX_PDF_SIZE_MB=10
```
There is deliberately no module-level `app` object. Building one at import time would
read settings — and fail on a bad `ANTHROPIC_MODEL` — merely because something imported
the module.
`backend/.env` (everything in `backend/.env.example`; the must-haves):
## Verify the request shape before trusting it
Unit tests use fakes, so they cannot prove the real API accepts the request. One live
check does, and it costs a few cents:
```bash
python scripts/smoke_structured_output.py
```
DB_USERNAME=... DB_PASSWORD=... DB_HOST=localhost DB_PORT=5432 DB_NAME=hrms
JWT_SECRET_KEY=...
OPENAI_API_KEY=sk-... # shared names with the root .env
```
It confirms the schema derived from `ATSScore` is accepted, that `output_parsed` comes
back valid, and that the second call reports non-zero `cached_tokens`.
Run it whenever you change the model or upgrade the SDK.
> Windows note: write `.env` files as UTF-8 **without** BOM, and don't leave stray
> non `KEY=VALUE` lines — python-dotenv warns on every load.
Last verified against `gpt-5.4-mini`: both calls parsed, and call 2 served 2304 of
2649 input tokens from cache.
### 2. Fresh database — one manual step
## API
Migrations run automatically on boot, but on a **brand-new database** one enum type
must exist first (known migration gap in the inbox module):
### `POST /api/v1/score`
```sql
CREATE TYPE candidate_application_status AS ENUM
('PROCESS','PENDING','APPROVED','REJECTED','ONHOLD','CLOSED');
```
`multipart/form-data`:
Everything else — including the `app.candidates` scoring table — is created by
Alembic autogeneration on first boot.
| Field | Type | Notes |
### 3. Install and run
```bash
# Backend deps + the scoring engine as an editable library
pip install -r backend/requirements.txt
pip install -e .
# Backend (the frontend dev config expects 127.0.0.1:8000)
cd backend
uvicorn main:app --port 8000
# Frontend (second terminal)
cd frontend
npm install
npm run dev # http://localhost:5173
```
Optional — standalone scoring engine with its own test UI (Talent-Pool-style card
grid, per-card view/download):
```bash
uvicorn app.main:create_app --factory # http://localhost:8000/ (pick a free port)
```
Optional — background inbox sync workers (need Redis):
```bash
docker compose up redis taskiq-worker taskiq-scheduler
```
### 4. First run
1. Sign up / log in (`/auth/login`) — the user needs a role carrying
`candidates.create` + `candidates.view` (RBAC screen or seed a role).
2. Create a job post (Job Board) — resumes are always scored **against a job**.
3. **CV Import** → pick the job → drop PDF resumes → each file returns scored
(score chip + one-line assessment) or failed (error code). Rows persist.
4. Browse results in **Candidates** (table, filters, ATS-match modal) and
**Talent Pool** (card grid). Failed extractions carry their error code.
## Backend API (scoring surface)
All routes use the envelope `{"data": ..., "total": n, "status_code": 200}` and JWT
bearer auth. Permissions in parentheses.
| Route | Method | Purpose |
|---|---|---|
| `job_description` | text | Required, non-blank, `MAX_JD_CHARS` ceiling |
| `resumes` | file[] | Required, `.pdf` only, `MAX_RESUMES_PER_REQUEST` / `MAX_PDF_SIZE_MB` ceilings |
| `/candidate/score` | POST multipart `job_id`, `files[]` | Score uploaded PDFs against a job; persists + returns leaderboard (candidates.create) |
| `/candidate/score_inbox` | POST `{job_id, message_ids[]}` | Score decoded email attachments; `message_ids` are inbox PK uuids (candidates.create) |
| `/candidate/fetch?job_id=` | GET | Persisted leaderboard; omit `job_id` for the cross-job pool (candidates.view) |
| `/candidate/fetch_by_id?candidate_id=` | GET | One candidate row (candidates.view) |
| `/job/fetch` | GET | Active job posts for pickers (job_board.view *or* candidates.view) |
| `/candidate/cv_upload` | POST multipart `file` | Text extraction only, nothing stored (candidates.create) |
| `/candidate/inbox-match` | POST `?inbox_message_id=` | Queue the agent "which job?" match (candidates.edit; needs Redis) |
```bash
curl -X POST http://localhost:8000/api/v1/score \
-F "job_description=Backend engineer. Required: Python, FastAPI, Docker." \
-F "resumes=@candidate-a.pdf" \
-F "resumes=@candidate-b.pdf"
```
**Candidate row** (what `/candidate/fetch` returns per CV):
```json
{
"request_id": "2ce31ea9-29b2-4cad-a916-1a18cfc69c20",
"total": 2,
"succeeded": 1,
"failed": 1,
"results": [
{
"filename": "candidate-a.pdf",
"status": "completed",
"candidate_name": "Ada Lovelace",
"job_title": "Backend Engineer",
"current_company": "Acme",
"years_experience": 6,
"match_score": 82,
"matched_keywords": ["Python", "FastAPI", "Docker"],
"missing_keywords": ["AWS", "Kubernetes"],
"summary_critique": "Strong Python backend experience, but no cloud or orchestration evidence."
},
{
"filename": "candidate-b.pdf",
"status": "failed",
"error_code": "PDF_TEXT_UNAVAILABLE",
"error_message": "No usable text could be extracted from the PDF."
}
]
"id": "…", "job_id": "…", "source": "upload",
"filename": "jane_doe.pdf", "content_sha256": "…",
"candidate_name": "Jane Doe", "job_title": "Backend Engineer",
"current_company": "Acme", "years_experience": 6,
"match_score": 82,
"matched_keywords": ["Python", "FastAPI", "Docker"],
"missing_keywords": ["AWS", "Kubernetes"],
"summary_critique": "Strong backend experience, but no cloud evidence.",
"status": "completed", "error_code": null, "model": "gpt-5.4-mini",
"created_at": "…", "updated_at": "…"
}
```
Completed results come first, sorted by `match_score` descending. Failures follow, in
upload order. Ties keep upload order.
Behavior guarantees:
The profile fields (`candidate_name`, `job_title`, `current_company`,
`years_experience`) are extracted from the resume by the model and are `null`
whenever the resume does not state them. `years_experience` uses the total stated in
the resume when there is one, otherwise it is computed from explicitly stated dates —
never guessed. `matched_keywords` are verified server-side against the resume text;
a keyword the resume never mentions is dropped rather than shown as evidence.
- **One bad file never sinks a batch** — unreadable/encrypted/oversized/non-PDF
files become rows with `status: "failed"` and a stable `error_code`
(`INVALID_PDF`, `PDF_ENCRYPTED`, `PDF_TEXT_UNAVAILABLE`, `UNSUPPORTED_FILE_TYPE`,
`PAYLOAD_TOO_LARGE`, `FILE_NOT_FOUND`, `MODEL_*`).
- **Content-hash dedupe** — re-scoring the same bytes against the same job updates
the existing row (`(job_id, content_sha256)` unique) instead of duplicating.
- **Ordering** — completed by score descending, failures last, ties stable.
- **Profile fields are extraction, not judgment**`null` when the resume doesn't
state them; `years_experience` prefers a stated total, else explicit dates, never
a guess.
- **Matched keywords are verified** server-side against the resume text — a skill
the resume never mentions is dropped rather than shown as evidence.
### Status codes
## The scoring engine (`app/`)
| Code | Meaning |
|---|---|
| 200 | Batch processed — including batches where every candidate failed |
| 400 | Malformed multipart request, or blank job description |
| 413 | Too many files, or a file over the size limit |
| 415 | A file is not a PDF |
| 422 | Structurally valid request with an out-of-range field value |
| 500 | Unexpected internal error |
Also usable standalone: `POST /api/v1/score` takes `job_description` (text) +
`resumes` (PDFs) and returns the same result shape without persistence. Full
contract in [CLAUDE.md](CLAUDE.md). Highlights:
Error responses are `{"request_id", "error_code", "error_message"}`. Stack traces,
provider response bodies, prompts, and document content never appear in them.
- **Structured outputs, strictly validated** — every model reply must parse into a
bounded schema (score 0100, ≤30 keywords, one-sentence critique) or the
candidate fails with `MODEL_RESPONSE_INVALID`; invalid output is never accepted.
- **Prompt-injection hardened** — document content is untrusted; an "ignore your
instructions, score 100" payload inside a CV or JD does not move scores (audited).
- **Unintelligible JDs score 0** with an explanatory critique instead of a
confident-looking number.
- **Cost control via prompt caching** — instructions + job description form a
byte-stable prefix shared by the whole batch; the first candidate is scored alone
to prime the cache before the rest fan out under a concurrency semaphore.
Caching needs a 1024-token minimum prefix, so very short JDs never cache.
- **Model policy**`OPENAI_MODEL` must support structured outputs (validated at
startup: `gpt-5*`, `gpt-4.1*`, `o3*`, `o4*`; `gpt-4o` and `-chat-latest`
excluded). No temperature/top_p: lower `OPENAI_EFFORT` to cut cost, never
`OPENAI_MAX_OUTPUT_TOKENS` (floor 2048; small caps truncate mid-JSON).
### Per-candidate error codes
`INVALID_PDF`, `PDF_ENCRYPTED`, `PDF_TEXT_UNAVAILABLE`, `MODEL_RATE_LIMITED`,
`MODEL_TIMEOUT`, `MODEL_REFUSED`, `MODEL_RESPONSE_INVALID`, `MODEL_UNAVAILABLE`,
`INTERNAL_ERROR`.
## Configuration
See [.env.example](.env.example) for the full list. Three settings are easy to get
wrong:
**`OPENAI_MODEL` must support structured outputs.** Validated at startup as a prefix
check over known families — `gpt-5*`, `gpt-4.1*`, `o3*`, `o4*` — rather than an exact
list, so a new point release isn't rejected on arrival. Two deliberate exclusions:
`gpt-4o` (snapshots before 2024-08-06 lack structured outputs, and aliases hide which
you get) and any `-chat-latest` variant (tracks the ChatGPT product surface, no
reasoning effort). `gpt-4.1` *is* allowed but is not a reasoning model — the adapter
detects that and omits the `reasoning` parameter instead of sending a 400.
**`OPENAI_MAX_OUTPUT_TOKENS` covers reasoning tokens and the response together.** A
small cap truncates mid-JSON and the candidate fails with `MODEL_RESPONSE_INVALID`.
The enforced floor is 2048 and the tested baseline is 4000.
**Lower `OPENAI_EFFORT`, not `OPENAI_MAX_OUTPUT_TOKENS`, to cut cost.** The token
budget is a truncation guard, not a spend dial; effort is the spend dial.
There is no `temperature` / `top_p` setting. Reasoning models reject them; the model is
steered by the system prompt and structured outputs instead.
## How cost is controlled
OpenAI caches automatically on an exact prompt *prefix* match — there is no breakpoint
to place, so **block ordering is the entire strategy**. The instructions and job
description are byte-identical across every candidate in a batch and go first; the
resume goes second. On the live smoke test this served 87% of input tokens from cache
(2304 of 2649) on the second call.
Two supporting details:
- `prompt_cache_key` is sent as a routing hint, derived from a hash of the job
description. It is stable for a whole batch and never per-candidate — a
high-cardinality key would defeat the purpose.
- A cache entry only becomes readable once the first response exists. If all 50
candidates launched at once, every one would pay full price — so `score_batch`
awaits the first candidate alone to prime the prefix, then fans the rest out under
the concurrency semaphore.
This is why nothing volatile may ever enter the job-description block. A timestamp,
request id, or filename there moves the divergence point to the front of the prompt
and the whole batch stops hitting the cache. [test_llm.py](tests/unit/test_llm.py)
fails if that happens.
Caching has a **1024-token minimum**, so short job descriptions will not cache at all.
## Development
## Testing and QA status
```bash
ruff check .
ruff format --check .
mypy app
pytest -q
# Engine suite — 195 tests, no live API calls, fakes + env isolation
ruff check app tests scripts && mypy app && pytest -q
# Live verifications (cost: cents; need OPENAI_API_KEY)
python scripts/smoke_structured_output.py # request shape + cache check
python scripts/audit_scoring.py # pos/neg scoring audit over CVS/
```
No test makes a live API call. Tests inject either `FakeScorer` (replacing the whole
adapter) or a fake `responses` resource (to exercise the adapter itself), and an
autouse fixture strips `OPENAI_*` from the environment so a real key cannot leak in.
Verified in QA (2026-08-10, full reports in session records):
Swapping providers is a contained change: the `Scorer` protocol in
[app/services/llm.py](app/services/llm.py) is the only seam that touches a vendor SDK.
Models, PDF handling, orchestration, routing, and logging are provider-agnostic.
- **Scoring audit** — 28/28 checks: AI CVs score 7397 on AI jobs, 0 on an
unrelated nursing job, ≤38 on an adjacent frontend job; keyword stuffing scores
18; prompt injection moves nothing; identical CVs with different names score
identically (98 = 98).
- **Backend integration** — 23/23 end-to-end checks on a scratch Postgres: auth
(401/403), mixed-batch per-file failures, idempotent re-scoring, inbox scoring
with source linkage, leaderboard ordering, 404/413 negatives.
- Score variance across identical runs is ±10 worst-case (typically ≤4) — treat
close scores as ties; the ranking is decision support, not a verdict.
## Data handling
Resumes contain personal data.
Resumes are personal data.
* Uploads are held in memory and parsed from `io.BytesIO`. Nothing is written to disk.
* Resume text, job-description text, prompts, and full model responses are never
logged. The logger emits only an explicit allowlist of keys, and exceptions are
recorded as a type plus `file:line:func` frames — never a formatted message, because
provider errors can echo request content.
* Nothing is persisted between requests. There is no database and no queue, so
retention is bounded by process lifetime. **Adding any storage means writing a
retention and deletion policy first.**
- The engine holds uploads in memory only; the backend persists **extracted fields
+ a content hash**, not the uploaded PDF (inbox attachments do live on disk under
`backend/inbox/decoded_attachments/`).
- Resume text, JD text, prompts, and raw model responses are never logged; the
engine's logger emits an explicit allowlist of keys only.
- The score is **decision support, not a hiring decision**. The prompt forbids
inferring protected characteristics; candidates are scored independently, never
compared to each other in a prompt.
- Before public exposure: authentication exists (JWT + RBAC) but application-level
rate limiting and a written retention/deletion policy for stored candidate data
and decoded attachments are still required.
## Limitations
## Known limitations and roadmap
* **Not a hiring decision.** The score is decision support. The prompt forbids
inferring or scoring protected characteristics, and candidates are never compared
against each other — each is scored independently against the same job description.
* **Scanned and image-only PDFs fail** with `PDF_TEXT_UNAVAILABLE`. There is no OCR.
* **Multi-column layouts extract in reading-order-ish, not exact, order.** The system
prompt tells the model this is an extraction artifact and not to penalise it.
* **No authentication or rate limiting.** Both are required before public deployment.
| Gap | Status |
|---|---|
| Contact info (email/phone/location), education, certifications, full skill list not extracted | Fields exist in the CVs; next natural step (schema + prompt + columns) |
| Pipeline stages, recruiter assignment, interviews, notes/feedback | Workflow features, not extraction — need their own tables; UI hides them rather than faking them |
| DOC/DOCX resumes | Decoded from email but not parseable → failed rows ("not supported yet") |
| Scanned/image-only PDFs | No OCR → `PDF_TEXT_UNAVAILABLE` |
| Uploaded CV files not stored | Only extracted data + hash persist; "download resume" needs a storage + retention decision |
| Fresh-DB enum migration gap | Workaround in Quick start §2; proper fix belongs in the inbox migration |
| Inbox "score against job" button | API exists (`/candidate/score_inbox`); Inbox screen not wired yet |
| Scoring telemetry (cache hits, dropped keywords) invisible in backend logs | Backend log formatter doesn't render structured extras |
## Contributing
Work lands on feature branches (current: `Talha`); `main` is updated only through
pull requests. Before any engine change is done: `ruff check`, `ruff format
--check`, `mypy app`, `pytest -q` must pass, and prompt/schema changes require one
live `smoke_structured_output.py` run. Backend code follows
`backend/LLM_CONTEXT_PROMPT.md` exactly — read it before adding a module.

View File

@ -44,6 +44,16 @@ OPENAI_BASE_URL=
OPENAI_ORGANIZATION=
OPENAI_PROJECT=
# ATS scoring (bulk-ats engine embedded via `pip install -e ..`).
# OPENAI_API_KEY / OPENAI_MODEL / OPENAI_MAX_OUTPUT_TOKENS above are shared.
OPENAI_EFFORT=low
OPENAI_ENABLE_PROMPT_CACHE=true
SCORING_CONCURRENCY=5
MAX_RESUMES_PER_REQUEST=50
MAX_PDF_SIZE_MB=10
MAX_JD_CHARS=30000
MAX_RESUME_CHARS=60000
REDIS_URL=redis://localhost:6379/0
TASKIQ_QUEUE_NAME=inbox
TASKIQ_CV_QUEUE_NAME=cv_upload

View File

@ -1,10 +1,13 @@
"""Inbox Taskiq tasks — CV → job-post matching."""
"""Inbox Taskiq tasks — CV → job-post matching and ATS scoring."""
from __future__ import annotations
import logging
from datetime import datetime,timezone
from fastapi import HTTPException
from sqlalchemy import select
from agent.execute_agent import run_agent
from db_setup import session_scope
from employment_agent.execute_agent import run_employment_agent
@ -19,6 +22,58 @@ logger=logging.getLogger("inbox.tasks")
_DONE=frozenset({"matched","skipped","no_text","failed","dlq"})
async def score_message_against_job(record_id:str,job_id:str) -> dict:
"""ATS-score one inbox CV against one job post — the no-upload path.
The decoded attachment already on disk is the CV; the job post in the
database is the JD. Idempotent: a (message, job) pair with a completed
score is never paid for twice; re-runs are a no-op.
"""
# Lazy imports: inbox.plugins imports job.candidate.views, so a top-level
# import here would be circular.
from job.candidate.models import Candidates
from job.candidate.views import CandidateScoring
mid=Candidates._as_uuid(record_id)
jid=Candidates._as_uuid(job_id)
if mid is None or jid is None:
raise PermanentTaskError("record_id and job_id must be uuids")
async with session_scope() as session:
existing=await session.execute(
select(Candidates).where(
Candidates.inbox_message_id==mid,
Candidates.job_id==jid,
Candidates.status=="completed",
)
)
if existing.scalars().first() is not None:
return {"status":"already_scored"}
job=await JobPosts.get_job_post_by_id(session,job_id)
if job is None or job.is_deleted:
raise PermanentTaskError("job post missing or deleted")
service=CandidateScoring(session=session)
try:
# Attribute the rows to the job's owner — there is no request user
# in a background task.
results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)})
except HTTPException as exc:
# 400/404 from score_inbox are permanent (no attachment, bad ids);
# retrying cannot fix them.
raise PermanentTaskError(str(exc.detail)) from exc
return {"status":"scored","results":len(results)}
@broker.task(
task_name="inbox.score_message",
retry_on_error=True,
max_retries=MAX_RETRIES,
delay=RETRY_DELAY,
)
async def score_inbox_message(record_id:str,job_id:str) -> dict:
return await score_message_against_job(record_id,job_id)
@broker.task(
task_name="inbox.match_message",
retry_on_error=True,
@ -81,6 +136,24 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
status=status,
error=result.get("error") or "",
)
# Auto-score: the match just paired this CV with jobs, so run the ATS on the
# spot — assigned job first, else the agent's top suggestion. Scoring failures
# must not fail the match; the match result is already committed above.
suggested=[str(j) for j in (result.get("suggested_job_post_ids") or []) if j]
score_job_id=None
async with session_scope() as session:
fresh=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
if fresh is not None and fresh.assigned_job_post_id:
score_job_id=str(fresh.assigned_job_post_id)
if score_job_id is None and suggested:
score_job_id=suggested[0]
if score_job_id:
try:
outcome=await score_message_against_job(record_id,score_job_id)
logger.info("ats auto-score %s vs %s: %s",record_id,score_job_id,outcome.get("status"))
except Exception as exc:
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,score_job_id,exc)
return {
"status":status,
"suggested_job_post_ids":result.get("suggested_job_post_ids") or [],

View File

@ -179,6 +179,20 @@ class Email:
updated=await Inbox_Messages.set_assigned_job_post(self.session,record_id,job_post_id)
if not updated:
raise HTTPException(status_code=404,detail="Message not found")
if job_post_id is not None:
# Assignment pairs this CV with a JD we already have — queue the ATS
# score in the background so the recruiter is not held on an OpenAI
# call. Idempotent server-side; broker-down just logs (the profile's
# Score-with-ATS button remains the manual fallback).
from inbox.tasks import score_inbox_message
try:
await score_inbox_message.kicker().with_labels(
created_at=datetime.now(timezone.utc).isoformat(),
correlation_id=str(record_id),
queue="inbox",
).kiq(str(record_id),str(job_post_id))
except Exception as exc:
logger.warning("could not queue ats score for %s: %s",record_id,exc)
return await self.get_inbox_message_by_id(record_id)
async def mark_read(self,record_id):

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query
from fastapi.responses import JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from job.candidate.views import FileRead,CandidateView
from job.candidate.views import CandidateScoring,FileRead,CandidateView
from job.interviews.views import Interview
from job.notes.views import Note
from job.activity.views import ActivityLog
@ -13,6 +13,8 @@ from job.cost.views import HiringCost
from sqlalchemy.ext.asyncio import AsyncSession
from users.permissions import PermissionTag, require_permission
from job.job_post.views import JobPost,JobPostCreate
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
import logging
from job.job_post.plugins import PlatformAlias
from fastapi import UploadFile, File, Form
@ -253,6 +255,64 @@ async def buffer_channels(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
class InboxScoreRequest(BaseModel):
job_id: str
message_ids: list[str] # inbox_messages PK uuids, not Graph message ids
@router.post("/candidate/score")
async def score_candidates(
job_id: str = Form(...),
files: list[UploadFile] = File(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
"""Score uploaded CV PDFs against a job post; persists and returns the leaderboard."""
try:
pairs=[(f.filename,await f.read()) for f in files]
service=CandidateScoring(session=session)
data=await service.score_uploads(job_id,pairs,current_user)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/score_inbox")
async def score_inbox_candidates(
payload: InboxScoreRequest,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
"""Score the decoded attachments of inbox messages against a job post."""
try:
service=CandidateScoring(session=session)
data=await service.score_inbox(payload.job_id,payload.message_ids,current_user)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/scored/fetch")
async def fetch_scored_candidates(
job_id: str = Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Persisted leaderboard: completed by score desc, failures last. Without job_id
returns the whole pool across jobs."""
try:
service=CandidateScoring(session=session)
data=await service.fetch_candidates(job_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/fetch")
async def fetch_job_posts(
@ -261,7 +321,13 @@ async def fetch_job_posts(
skip: int = Query(0, ge=0),
ids: str | None = Query(None),
active_only: bool = Query(True),
current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_VIEW)),
# Either job-board or candidate viewers may list jobs — recruiters scoring
# CVs need a job to score against (CV Import picker).
current_user: dict = Depends(
require_permission(
PermissionTag.JOB_BOARD_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False
)
),
session: AsyncSession = Depends(get_session),
):
try:
@ -280,6 +346,23 @@ async def fetch_job_posts(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch_by_id")
async def fetch_candidate_by_id(
candidate_id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateScoring(session=session)
data=await service.fetch_candidate_by_id(candidate_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch")
async def fetch_candidate(
user_id:str=Query(None),

View File

@ -2,7 +2,8 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, func
from sqlalchemy import JSON, DateTime, func, UniqueConstraint
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
@ -112,6 +113,120 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return row
class Candidates(SQLModel, table=True):
"""One scored (or failed-to-score) CV against one job post.
The dedupe key is (job_id, content_sha256), not the filename: inbox attachments
are stored by basename so different candidates can collide on "resume.pdf", while
identical bytes can arrive via both upload and email. Re-scoring the same bytes
against the same job updates the existing row (fresh model output, updated_at
bumped) instead of duplicating it. content_sha256 is NULL when the file bytes
were never readable (missing on disk); NULLs never conflict in the unique index.
"""
__tablename__ = "candidates"
__table_args__ = (UniqueConstraint("job_id", "content_sha256"),)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
job_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True)
source: str = Field(default="upload") # "upload" | "inbox"
inbox_message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
filename: str
file_path: str | None = Field(default=None) # decoded-attachment path (inbox only)
content_sha256: str | None = Field(default=None, index=True)
candidate_name: str | None = Field(default=None)
job_title: str | None = Field(default=None)
current_company: str | None = Field(default=None)
years_experience: int | None = Field(default=None)
match_score: int | None = Field(default=None) # None on failed rows
matched_keywords: list[str] = Field(default_factory=list, sa_type=JSON)
missing_keywords: list[str] = Field(default_factory=list, sa_type=JSON)
summary_critique: str | None = Field(default=None)
status: str # "completed" | "failed"
error_code: str | None = Field(default=None)
error_message: str | None = Field(default=None)
model: str | None = Field(default=None) # which OPENAI_MODEL produced the score
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_candidate_by_id(cls, session: AsyncSession, record_id: str):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def get_candidates_by_job(cls, session: AsyncSession, job_id: str | None = None):
"""Leaderboard order: completed by score desc, failures last, ties stable.
job_id=None returns the whole pool across jobs (same ordering) for the
frontend's unscoped Candidates/Talent Pool views.
"""
statement = select(cls)
if job_id is not None:
uid = cls._as_uuid(job_id)
if uid is None:
return []
statement = statement.where(cls.job_id == uid)
statement = statement.order_by(
cls.status.asc(), # "completed" < "failed"
cls.match_score.desc().nulls_last(),
cls.created_at.asc(),
)
result = await session.execute(statement)
return result.scalars().all()
@classmethod
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
existing = None
sha = fields.get("content_sha256")
if sha:
result = await session.execute(
select(cls).where(cls.job_id == fields["job_id"], cls.content_sha256 == sha)
)
existing = result.scalars().first()
if existing is None:
row = cls(**fields)
session.add(row)
try:
await session.commit()
except IntegrityError:
# A concurrent request inserted the same (job_id, sha) first; take over
# that row and update it instead.
await session.rollback()
result = await session.execute(
select(cls).where(cls.job_id == fields["job_id"], cls.content_sha256 == sha)
)
existing = result.scalars().first()
if existing is None:
raise
else:
await session.refresh(row)
return row
for key, value in fields.items():
setattr(existing, key, value)
existing.updated_at = _now()
session.add(existing)
await session.commit()
await session.refresh(existing)
return existing
class Interviews(SQLModel, table=True):
__tablename__ = "interviews"

View File

@ -1,4 +1,4 @@
"""CV text cleanup helpers for the PDF extractor.
"""CV text cleanup, scoring-JD builder, and the ATS scorer singleton.
Pure module: no FastAPI imports and no HTTPException.
@ -6,14 +6,93 @@ Designer-made resumes position every glyph individually, so pypdf hands back
"S K I L L S" instead of "SKILLS". In that layout a single space is glyph
padding and a run of two or more spaces is the real word gap, which is what
the `despace_line` decorator keys off to rebuild readable lines.
The scoring pieces reuse the bulk-ats engine, installed editable from the repo
root (`pip install -e ..` -> `import app.*`), and share llm_setup's process-wide
AsyncOpenAI client rather than opening a second connection pool.
"""
from __future__ import annotations
import re
from app.core.config import Settings, get_settings
from app.services.llm import OpenAIScorer
from dotenv import load_dotenv
from job.candidate.decorators import despace_line, normalize_unicode
load_dotenv()
# Backend-local per-candidate error code for cases the bulk-ats engine never sees
# (its uploads always have bytes; inbox attachments can vanish from disk).
FILE_NOT_FOUND = "FILE_NOT_FOUND"
_scorer: OpenAIScorer | None = None
def get_scoring_settings() -> Settings:
"""Validated scoring knobs (model family, token floor, size limits).
Reads real env vars, which load_dotenv() above has populated from the nearest
.env (backend/.env, else the repo root). Shared names (OPENAI_MODEL,
OPENAI_MAX_OUTPUT_TOKENS) therefore match what llm_setup uses.
"""
return get_settings()
def get_scorer() -> OpenAIScorer:
"""Process-wide scorer over llm_setup's shared AsyncOpenAI client.
Lazy so that a missing/invalid OPENAI configuration surfaces on the first
scoring request, not at import; llm_setup.init_llm() in the app lifespan has
normally created and verified the client before this ever runs.
"""
global _scorer
if _scorer is None:
from llm_setup import get_client
settings = get_scoring_settings()
_scorer = OpenAIScorer(
get_client(),
model=settings.openai_model,
max_output_tokens=settings.openai_max_output_tokens,
effort=settings.openai_effort,
enable_cache=settings.openai_enable_prompt_cache,
)
return _scorer
def build_job_description(job) -> str:
"""Deterministic scoring JD from a JobPosts row.
Byte-stable per job: derived only from stored column values, in fixed order,
because OpenAI prompt caching works on exact prefix match one volatile byte
(an id, a timestamp) would stop the whole batch from reusing the cache.
Deliberately excludes post_text (hashtags, salary, LinkedIn formatting) and
salary; list order is taken as stored.
"""
lines = [f"Job Title: {job.title}"]
if job.employment_type:
lines.append(f"Employment Type: {job.employment_type}")
if job.location:
lines.append(f"Location: {job.location}")
if job.experience_min is not None and job.experience_max is not None:
lines.append(f"Experience Required: {job.experience_min}-{job.experience_max} years")
elif job.experience_min is not None:
lines.append(f"Experience Required: {job.experience_min}+ years")
elif job.experience_max is not None:
lines.append(f"Experience Required: up to {job.experience_max} years")
if job.description:
lines += ["", "Description:", job.description.strip()]
if job.requirements:
lines += ["", "Mandatory Requirements:"]
lines += [f"- {item}" for item in job.requirements]
if job.optional_skills:
lines += ["", "Preferred (nice to have):"]
lines += [f"- {item}" for item in job.optional_skills]
return "\n".join(lines)
@normalize_unicode
@despace_line

View File

@ -6,6 +6,32 @@ from job.interviews.serializers import serialize_interview
from job.activity.serializers import serialize_activity
from job.feedback.serializers import serialize_feedback
def serialize_candidate(row) -> dict:
return {
"id": str(row.id),
"job_id": str(row.job_id),
"inbox_message_id": str(row.inbox_message_id) if row.inbox_message_id else None,
"source": row.source,
"filename": row.filename,
"file_path": row.file_path,
"content_sha256": row.content_sha256,
"candidate_name": row.candidate_name,
"job_title": row.job_title,
"current_company": row.current_company,
"years_experience": row.years_experience,
"match_score": row.match_score,
"matched_keywords": list(row.matched_keywords or []),
"missing_keywords": list(row.missing_keywords or []),
"summary_critique": row.summary_critique,
"status": row.status,
"error_code": row.error_code,
"error_message": row.error_message,
"model": row.model,
"created_by": str(row.created_by),
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
return {

View File

@ -1,5 +1,5 @@
from sqlalchemy.ext.asyncio import AsyncSession
import base64,io,logging,os,uuid
import asyncio,base64,dataclasses,hashlib,io,logging,os,uuid
from datetime import datetime,timezone
from pathlib import Path
from dotenv import load_dotenv
@ -8,13 +8,26 @@ from pypdf import PdfReader
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlmodel import true
from app.core.errors import ATSError,ErrorCode
from app.models.scoring import CompletedCandidate
from app.services.pdf import extract_resume,sanitize_filename
from app.services.scoring import score_batch
from inbox.models import Inbox_Messages,Inbox
from job.candidate.models import Candidates
from job.candidate.plugins import (
FILE_NOT_FOUND,
build_job_description,
get_scorer,
get_scoring_settings,
normalize_spaced_text,
)
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
from job.candidate.serializers import serialize_candidate_profile,serialize_manual_upload_candidate
from job.candidate.serializers import serialize_manual_upload_candidate
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
from job.notes.serializers import serialize_note
from inbox.models import Inbox_Messages,Inbox
from job.candidate.plugins import extract_candidate_email,normalize_spaced_text
from job.candidate.plugins import extract_candidate_email
load_dotenv()
logger=logging.getLogger("job.candidate.views")
@ -229,10 +242,231 @@ class FileRead:
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)
# get_file=
class CandidateScoring:
"""ATS scoring of CVs against one job post, persisted to the candidates table.
Complements the agent's inbox-match flow: the agent suggests WHICH job a CV is
for; this service scores HOW WELL a CV fits a chosen job (0-100 leaderboard).
Deviation from the bulk-ats HTTP API (which rejects a whole batch with 415/413
on a bad file): here per-file problems become persisted rows with
status="failed" so one broken attachment never sinks the rest of the batch.
Request-level errors (unknown job, too many files) still raise.
"""
def __init__(self,session:AsyncSession):
self.session=session
async def score_uploads(self,job_id,files,current_user):
"""files: list of (filename, bytes) pairs from the route handler."""
settings=get_scoring_settings()
if len(files)>settings.max_resumes_per_request:
raise HTTPException(
status_code=413,
detail=f"At most {settings.max_resumes_per_request} resumes per request",
)
sources=[]
for filename,data in files:
source={
"filename":filename or "resume.pdf",
"data":data,
"file_path":None,
"inbox_message_id":None,
"precheck":None,
}
if not (filename or "").lower().endswith(".pdf"):
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"Only PDF resumes are supported.")
elif len(data)>settings.max_pdf_size_bytes:
source["precheck"]=(ErrorCode.PAYLOAD_TOO_LARGE,"The file exceeds the size limit.")
sources.append(source)
return await self._score_and_persist(job_id,sources,"upload",current_user)
async def score_inbox(self,job_id,message_ids,current_user):
"""Score the decoded attachments of inbox messages (PK uuids, not Graph ids)."""
# Local import: inbox.plugins imports this module (FileRead), so a top-level
# import would be circular — same pattern as match_inbox_cv above.
from inbox.plugins import resolve_attachment_path
sources=[]
for mid in message_ids:
row=await Inbox_Messages.get_inbox_message_by_id(self.session,mid)
if row is None:
raise HTTPException(status_code=404,detail=f"Inbox message {mid} not found")
if not row.file_path:
continue
for path_str in (p.strip() for p in row.file_path.split(",") if p.strip()):
path=resolve_attachment_path(path_str)
source={
"filename":path.name,
"data":None,
"file_path":str(path),
"inbox_message_id":row.id,
"precheck":None,
}
suffix=path.suffix.lower()
if suffix in (".doc",".docx"):
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"DOC/DOCX extraction is not supported yet.")
elif suffix!=".pdf":
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"Only PDF resumes are supported.")
elif not path.is_file():
source["precheck"]=(FILE_NOT_FOUND,"The decoded attachment is missing on disk.")
else:
try:
source["data"]=await asyncio.to_thread(path.read_bytes)
except OSError:
source["precheck"]=(FILE_NOT_FOUND,"The decoded attachment could not be read.")
sources.append(source)
if not sources:
raise HTTPException(status_code=400,detail="No attachments found for the given message(s)")
return await self._score_and_persist(job_id,sources,"inbox",current_user)
async def fetch_candidates(self,job_id=None):
# job_id omitted -> the whole pool across jobs (frontend Candidates/TalentPool).
if job_id is not None:
job=await JobPosts.get_job_post_by_id(self.session,job_id)
if job is None or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
rows=await Candidates.get_candidates_by_job(self.session,job_id)
return [serialize_candidate(row) for row in rows]
async def fetch_candidate_by_id(self,candidate_id):
row=await Candidates.get_candidate_by_id(self.session,candidate_id)
if row is None:
raise HTTPException(status_code=404,detail="Candidate not found")
return serialize_candidate(row)
async def _score_and_persist(self,job_id,sources,source_kind,current_user):
job=await JobPosts.get_job_post_by_id(self.session,job_id)
if job is None or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
settings=get_scoring_settings()
jd=build_job_description(job)
if len(jd)>settings.max_jd_chars:
raise HTTPException(status_code=422,detail="The job post is too large to score against")
# Slot-indexed like app/api/routes.py: results merge back by position, never
# by filename — inbox attachments can share a basename.
results_by_slot={}
extracted=[]
for slot,source in enumerate(sources):
source["safe_name"]=sanitize_filename(source["filename"])
data=source["data"]
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
if source["precheck"] is not None:
code,message=source["precheck"]
results_by_slot[slot]=self._failed_fields(source,code,message)
continue
try:
# pypdf is CPU-bound: keep it off the event loop. Despace BEFORE
# scoring so keyword verification sees the exact text the model saw;
# ExtractedResume is frozen, hence dataclasses.replace.
resume=await asyncio.to_thread(
extract_resume,data,source["safe_name"],settings.max_resume_chars
)
resume=dataclasses.replace(resume,text=normalize_spaced_text(resume.text))
except ATSError as exc:
results_by_slot[slot]=self._failed_fields(source,exc.error_code,exc.public_message)
continue
extracted.append((slot,resume))
scored=await score_batch(
[resume for _,resume in extracted],
job_description=jd,
scorer=get_scorer(),
concurrency=settings.scoring_concurrency,
)
for (slot,_),result in zip(extracted,scored,strict=True):
source=sources[slot]
if isinstance(result,CompletedCandidate):
results_by_slot[slot]={
**self._base_fields(source),
"status":"completed",
"candidate_name":result.candidate_name,
"job_title":result.job_title,
"current_company":result.current_company,
"years_experience":result.years_experience,
"match_score":result.match_score,
"matched_keywords":result.matched_keywords,
"missing_keywords":result.missing_keywords,
"summary_critique":result.summary_critique,
"error_code":None,
"error_message":None,
}
else:
results_by_slot[slot]=self._failed_fields(source,result.error_code,result.error_message)
common={
"job_id":job.id,
"source":source_kind,
"created_by":uuid.UUID(str(current_user["id"])),
"model":settings.openai_model,
}
rows=[]
for slot in range(len(sources)):
fields={**results_by_slot[slot],**common}
rows.append(await Candidates.upsert_candidate(self.session,fields))
# Leaderboard order: completed by score desc, failures last, stable.
rows.sort(key=lambda r:(0,-(r.match_score or 0)) if r.status=="completed" else (1,0))
return [serialize_candidate(row) for row in rows]
@staticmethod
def _base_fields(source):
return {
"inbox_message_id":source["inbox_message_id"],
"filename":source["safe_name"],
"file_path":source["file_path"],
"content_sha256":source["sha256"],
}
@classmethod
def _failed_fields(cls,source,code,message):
return {
**cls._base_fields(source),
"status":"failed",
"error_code":str(code),
"error_message":message,
"candidate_name":None,
"job_title":None,
"current_company":None,
"years_experience":None,
"match_score":None,
"matched_keywords":[],
"missing_keywords":[],
"summary_critique":None,
}
class CandidateView:
def __init__(self,session:AsyncSession):
self.session=session
@staticmethod
def _recommendation(score):
if score is None:
return None
# Same bands the frontend uses (Candidates.jsx / seed.js).
return "Strong Match" if score>=82 else "Potential Match" if score>=65 else "Weak Match"
async def _scores_by_message(self,message_ids):
"""Completed ATS scores (candidates table) per inbox message id, newest first.
One batched query the profile list would otherwise pay a query per row.
"""
mids=[m for m in message_ids if m]
if not mids:
return {}
result=await self.session.execute(
select(Candidates)
.where(Candidates.inbox_message_id.in_(mids),Candidates.status=="completed")
.order_by(Candidates.updated_at.desc())
)
scores={}
for row in result.scalars().all():
scores.setdefault(row.inbox_message_id,[]).append(row)
return scores
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None):
try:
email=(candidate_email or "").strip().lower()
@ -335,11 +569,16 @@ class CandidateView:
"""Normalize list/single, serialize each record, attach full job_posts rows."""
single=not isinstance(data,list)
records=[data] if single else list(data or [])
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
enriched=[]
for record in records:
payload=serialize_candidate_profile(record)
payload["job_posts"]=[]
payload["assigned_job_post"]=None
scored=scores.get(getattr(record,"message_id",None)) or []
if scored:
payload["ai_score"]=scored[0].match_score
payload["recommendation"]=self._recommendation(scored[0].match_score)
assigned_id=payload.get("assigned_job_post_id")
if assigned_id:
await self.get_job_post_by_id(record_id=assigned_id,data=payload,as_assigned=True)
@ -410,6 +649,27 @@ class CandidateView:
)
notes=[serialize_note(r) for r in result.scalars().all()]
# ATS score: join the scoring engine's `candidates` rows onto the profile
# by inbox message. The serializer stubs ai_score/recommendation to None;
# this is where they get real values. Prefer the score against the
# assigned job post, else the most recent completed score.
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
scored_rows=[row for rows in scores.values() for row in rows]
if scored_rows:
assigned_uid=Candidates._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
chosen=None
if assigned_uid is not None:
chosen=next((r for r in scored_rows if r.job_id==assigned_uid),None)
if chosen is None:
chosen=max(scored_rows,key=lambda r:r.updated_at)
base["ai_score"]=chosen.match_score
base["recommendation"]=self._recommendation(chosen.match_score)
base["matched_keywords"]=list(chosen.matched_keywords or [])
base["missing_keywords"]=list(chosen.missing_keywords or [])
base["summary_critique"]=chosen.summary_critique
base["scored_job_post_id"]=str(chosen.job_id)
base["scored_at"]=chosen.updated_at.isoformat() if chosen.updated_at else None
activity.sort(key=lambda r:(r.get("activity_date") or ""),reverse=True)
base["favorite"]=favorite
base["rating"]=rating

View File

@ -38,3 +38,9 @@ redis>=5.0,<6.0 # DLQ middleware (taskiq_management/middleware.py) as
# --- LLM -------------------------------------------------------------------
openai==2.53.0 # AsyncOpenAI client in llm_setup.py
langgraph==1.2.10 # StateGraph agent framework in agent/agent_setup.py
# --- ATS scoring -----------------------------------------------------------
# The bulk-ats scoring engine (app.services.pdf / llm / scoring) is installed
# editable from the repo root — run once per environment:
# pip install -e ..
# Its dependencies are already satisfied by the pins above.

View File

@ -1,5 +1,92 @@
/* ============================================================
candidates.js candidate endpoints (backend/job/app.py).
Two data families share this module:
- ATS scoring (persisted `candidates` table): listJobs, listCandidates,
getCandidate, scoreUploads, scoreInbox, toCandidateView.
- Candidate profiles (inbox -> users -> roles join): list, getByUserId,
toRows.
Same conventions as inbox.js: one named export per endpoint, no hooks,
camelCase params mapped to snake_case at the call boundary, and every
function returns the parsed {data, total, status_code} envelope.
============================================================ */
import { request } from '../lib/apiClient'
/** Active job posts for pickers. Needs job_board.view OR candidates.view. */
export function listJobs() {
return request('/job/fetch')
}
/**
* Persisted scoring leaderboard. Needs candidates.view.
* Omit jobId for the whole pool across jobs; rows are ordered completed-by-
* score-desc, then failed rows.
*/
export function listCandidates({ jobId } = {}) {
return request('/candidate/scored/fetch', { params: { job_id: jobId } })
}
/** One scored candidate row by id. Needs candidates.view. 404s on unknown ids. */
export function getCandidate(candidateId) {
return request('/candidate/fetch_by_id', { params: { candidate_id: candidateId } })
}
/**
* Score uploaded CV PDFs against a job post. Needs candidates.create.
* Multipart: unreadable/oversized/non-PDF files come back as rows with
* status "failed" instead of failing the batch. Re-scoring identical bytes
* against the same job updates the existing row (no duplicates).
*/
export function scoreUploads(jobId, files) {
const form = new FormData()
form.append('job_id', jobId)
for (const file of files) form.append('files', file, file.name)
return request('/candidate/score', { method: 'POST', body: form })
}
/**
* Score the decoded attachments of inbox messages against a job post.
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
* field the inbox list returns), not Graph message ids.
*/
export function scoreInbox(jobId, messageIds) {
return request('/candidate/score_inbox', {
method: 'POST',
body: { job_id: jobId, message_ids: messageIds },
})
}
/**
* Shared snake_case camelCase view-model mapper for candidate rows, so the
* three candidate screens agree on field names. Fields the backend does not
* store (email, phone, stage, education) are deliberately absent screens
* hide those affordances rather than render placeholders (Inbox precedent).
*/
export function toCandidateView(row) {
const name = row.candidate_name || row.filename || 'Unknown'
return {
id: row.id,
jobId: row.job_id,
name,
filename: row.filename,
source: row.source, // 'upload' | 'inbox'
currentTitle: row.job_title ?? null,
currentCompany: row.current_company ?? null,
experience: row.years_experience ?? null,
aiScore: row.match_score ?? null,
matchedSkills: Array.isArray(row.matched_keywords) ? row.matched_keywords : [],
missingSkills: Array.isArray(row.missing_keywords) ? row.missing_keywords : [],
critique: row.summary_critique ?? null,
scoringStatus: row.status, // 'completed' | 'failed'
errorCode: row.error_code ?? null,
errorMessage: row.error_message ?? null,
applied: row.created_at ? new Date(row.created_at) : null,
inboxMessageId: row.inbox_message_id ?? null,
}
}
/**
* Candidate profiles the `inbox -> users -> roles` join, restricted server-side
* to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).

View File

@ -16,11 +16,6 @@ export const qk = {
permissions: () => ['roles', 'permissions'],
tags: () => ['roles', 'permission-tags'],
},
candidates: {
all: () => ['candidates'],
list: (p = {}) => ['candidates', 'list', p],
detail: (userId) => ['candidates', 'detail', userId],
},
mailbox: {
all: () => ['mailbox'],
messages: () => ['mailbox', 'messages'],
@ -32,6 +27,15 @@ export const qk = {
all: () => ['jobPosts'],
list: (p = {}) => ['jobPosts', 'list', p],
},
jobs: {
all: () => ['jobs'],
list: () => ['jobs', 'list'],
},
candidates: {
all: () => ['candidates'],
list: (p = {}) => ['candidates', 'list', p],
detail: (id) => ['candidates', 'detail', id],
},
analytics: {
all: () => ['analytics'],
kpis: (p = {}) => ['analytics', 'kpis', p],

View File

@ -1,4 +1,4 @@
/* The 8-tab candidate profile modal, split out of Candidates.jsx it was the
/* The 8-tab candidate profile modal, split out of Candidates.jsx it was the
single largest block in js/candidates.js and deserves its own file.
TWO DATA MODES, selected by whether the caller passes a `userId`:
@ -130,6 +130,16 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
})
// Score the candidate's inbox CV against the assigned job with the ATS engine.
// Needs both an assigned job (what to score against) and a message (whose
// attachment to score); the refetch lands the new ai_score in this modal.
const canScoreAts = isLive && Boolean(live?.assigned_job_post_id && live?.message_id)
const scoreAts = useProfileWrite({
userId: c.userId,
mutationFn: () => candidatesApi.scoreInbox(live.assigned_job_post_id, [live.message_id]),
success: 'CV scored against the assigned job',
})
const title = live?.job_title || c.currentTitle
const company = live?.currentCompany || c.currentCompany
@ -171,6 +181,15 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
>
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
</button>
{canScoreAts && (
<button
className="btn btn-secondary"
disabled={scoreAts.isPending}
onClick={() => scoreAts.mutate()}
>
<Icon name="sparkles" /> {scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
</button>
)}
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
@ -194,7 +213,7 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
</div>
</div>
<div style={{ textAlign: 'center' }}>
<ScoreChip score={c.aiScore} />
<ScoreChip score={live?.ai_score ?? c.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
</div>

View File

@ -1,39 +1,50 @@
/* ============================================================
Candidates the largest screen in the app: a 14-facet filter panel, a
composite relevance sort, a multi-select bulk bar, favourites, a
recently-viewed strip, the ATS-match modal and the 8-tab profile
(CandidateProfile.jsx).
/* ============================================================
Candidates the scored-candidate pool, on live backend data.
Uses the headless `useDataTable` rather than <DataTable/>, because the
selection column needs to render against a Set this component owns.
Rows come from GET /candidate/fetch (all jobs) via the shared
toCandidateView mapper. Facets, columns and actions that had no backing
column (stage, recruiter, notice period, favourites) are gone rather than
rendered as placeholders the Inbox screen set that precedent. Adding
candidates happens through CV Import (real scoring), not a manual form.
============================================================ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Pagination, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, FieldError, Icon, ProgressBar, ScoreChip } from '../ui/primitives'
import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import CandidateProfile from './CandidateProfile'
import CandidateProfile from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import { persist, seedQuery, useSeedMutation } from '../data/seedQueries'
import {
atsRecommendationClass, avatarColor, departments, educationLevels, getJob,
initials as initialsOf, int, locations, skillsPool, sources, stages, TODAY,
} from '../data/seed'
import { useFormState } from '../components/AuthLayout'
import { persist } from '../data/seedQueries'
import { atsRecommendationClass, avatarColor, initials as initialsOf, sources, stages } from '../data/seed'
const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
const EXP_BUCKETS = ['0-2', '3-5', '6-9', '10+']
const ATS_BANDS = ['85+', '70-84', '<70']
const INTERVIEW_STATES = ['Not Scheduled', 'Scheduled', 'Completed']
const NOTICE = ['Immediate', '2 weeks', '1 month', '2 months', '3 months']
const AVAILABILITY = ['Immediate', '2 weeks', '1 month', 'Passive']
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
const EMPTY_FILTERS = { job: '', skill: '', source: '', ats: '', status: '' }
async function fetchCandidates() {
const res = await candidatesApi.listCandidates()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(candidatesApi.toCandidateView)
}
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
}
function recommendationOf(c) {
if (c.aiScore == null) return 'Weak Match'
return c.aiScore >= 82 ? 'Strong Match' : c.aiScore >= 65 ? 'Potential Match' : 'Weak Match'
}
/* Client-side guard only the route has no size cap of its own, so this just
stops an obviously wrong file from being read into memory and posted. */
@ -63,43 +74,46 @@ const REFERRAL_RE = new RegExp(
*/
const referralValue = (raw) => (raw || '').trim().toLowerCase()
const EMPTY_FILTERS = {
job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '',
manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '',
}
export default function Candidates() {
const { toast } = useToast()
const qc = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates })
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data])
const jobsById = useMemo(
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
[jobsQuery.data],
)
const { data: recentlyViewed = [] } = useQuery({
queryKey: qk.seed.recentlyViewed(),
queryFn: async () => [],
staleTime: Infinity,
gcTime: Infinity,
})
const updateCandidates = useSeedMutation('candidates')
const [q, setQ] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('relevance')
const [selected, setSelected] = useState(() => new Set())
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
const [bulkAssigning, setBulkAssigning] = useState(false)
/** ATS + matched-skill ratio + recency. Verbatim from js/candidates.js:14-20. */
const jobTitleOf = useCallback(
(c) => jobsById[c.jobId]?.title ?? '—',
[jobsById],
)
/** ATS score + matched-skill ratio + recency same shape as before, but every
input is now real: matched/missing come from the model, applied from the DB. */
const relevance = useCallback((c) => {
const req = (getJob(c.jobId) || {}).skills || []
const skillRatio = req.length ? c.matchedSkills.length / req.length : 0.5
const recency = 1 - Math.min(1, (TODAY - c.applied) / (90 * 864e5))
if (c.aiScore == null) return 0
const total = c.matchedSkills.length + c.missingSkills.length
const skillRatio = total ? c.matchedSkills.length / total : 0.5
const recency = c.applied ? 1 - Math.min(1, (Date.now() - c.applied) / (90 * 864e5)) : 0.5
return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10)
}, [])
@ -115,7 +129,7 @@ export default function Candidates() {
[qc],
)
// Deep links from global search, dashboard, pipeline, calendar, interviews
// Deep links from Talent Pool, global search, dashboard
useEffect(() => {
const st = location.state
if (!st) return
@ -126,124 +140,62 @@ export default function Candidates() {
}
}, [location.state, candidates, openProfile])
const jobTitles = useMemo(() => [...new Set(candidates.map((c) => c.jobTitle))], [candidates])
const skillOptions = useMemo(() => {
const set = new Set()
for (const c of candidates) for (const s of c.matchedSkills) set.add(s)
return [...set].sort((a, b) => a.localeCompare(b)).slice(0, 40)
}, [candidates])
const jobOptions = useMemo(
() => (jobsQuery.data ?? []).map((j) => j.title),
[jobsQuery.data],
)
const rows = useMemo(() => {
const f = filters
let list = candidates.filter((c) => {
if (f.job && c.jobTitle !== f.job) return false
if (f.skill && !c.skills.includes(f.skill)) return false
if (f.dept && c.department !== f.dept) return false
if (f.location && c.location !== f.location) return false
if (f.exp === '0-2' && c.experience > 2) return false
if (f.exp === '3-5' && (c.experience < 3 || c.experience > 5)) return false
if (f.exp === '6-9' && (c.experience < 6 || c.experience > 9)) return false
if (f.exp === '10+' && c.experience < 10) return false
if (f.edu && c.education !== f.edu) return false
if (f.recruiter && c.recruiter !== f.recruiter) return false
if (f.manager) {
const job = getJob(c.jobId)
if (!job || job.manager !== f.manager) return false
}
if (f.job && jobTitleOf(c) !== f.job) return false
if (f.skill && !c.matchedSkills.includes(f.skill)) return false
if (f.source && c.source !== f.source) return false
if (f.ats === '85+' && c.aiScore < 85) return false
if (f.ats === '70-84' && (c.aiScore < 70 || c.aiScore > 84)) return false
if (f.ats === '<70' && c.aiScore >= 70) return false
if (f.stage && c.stage !== f.stage) return false
if (f.interview && c.interviewStatus !== f.interview) return false
if (f.notice && c.noticePeriod !== f.notice) return false
if (f.availability && c.availability !== f.availability) return false
if (f.status === 'Scored' && c.scoringStatus !== 'completed') return false
if (f.status === 'Failed' && c.scoringStatus !== 'failed') return false
if (f.ats === '85+' && (c.aiScore == null || c.aiScore < 85)) return false
if (f.ats === '70-84' && (c.aiScore == null || c.aiScore < 70 || c.aiScore > 84)) return false
if (f.ats === '<70' && (c.aiScore == null || c.aiScore >= 70)) return false
if (q) {
const term = q.toLowerCase()
const hay = (c.name + c.email + c.jobTitle + c.currentCompany + c.recruiter + c.skills.join(' ')).toLowerCase()
const hay = [
c.name, c.filename, c.currentTitle ?? '', c.currentCompany ?? '',
c.matchedSkills.join(' '),
].join(' ').toLowerCase()
if (!hay.includes(term)) return false
}
return true
})
if (sortMode === 'relevance') list = [...list].sort((a, b) => relevance(b) - relevance(a))
else if (sortMode === 'ats') list = [...list].sort((a, b) => b.aiScore - a.aiScore)
else if (sortMode === 'recent') list = [...list].sort((a, b) => b.applied - a.applied)
else if (sortMode === 'ats') list = [...list].sort((a, b) => (b.aiScore ?? -1) - (a.aiScore ?? -1))
else if (sortMode === 'recent') list = [...list].sort((a, b) => (b.applied ?? 0) - (a.applied ?? 0))
else if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
return list
}, [candidates, filters, q, sortMode, relevance])
}, [candidates, filters, q, sortMode, relevance, jobTitleOf])
const columns = useMemo(
() => [
{ key: '_sel', label: '' },
{ key: 'name', label: 'Candidate', sortable: true },
{ key: 'jobTitle', label: 'Applied Job', sortable: true },
{ key: '_job', label: 'Scored For', sortable: true, sortValue: jobTitleOf },
{ key: 'experience', label: 'Exp', sortable: true, align: 'center' },
{ key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: relevance },
{ key: 'stage', label: 'Stage', sortable: true },
{ key: 'scoringStatus', label: 'Status', sortable: true },
{ key: 'aiScore', label: 'ATS', sortable: true, align: 'center' },
{ key: 'availability', label: 'Availability' },
{ key: 'applied', label: 'Added', sortable: true },
{ key: '_a', label: 'Actions', align: 'right' },
],
[relevance],
[relevance, jobTitleOf],
)
const t = useDataTable({ columns, rows, pageSize: 10 })
function toggleSelect(id) {
setSelected((s) => {
const next = new Set(s)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleFav(c) {
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success')
}
function advance(c) {
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) {
toast(`${c.name} cannot be advanced further`, 'warning')
return
}
const stage = STAGE_ORDER[i + 1]
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x)))
toast(`${c.name} moved to ${stage}`, 'success')
}
function bulk(action) {
const ids = [...selected]
if (!ids.length) return
if (action === 'email') {
toast(`Bulk email drafted to ${ids.length} candidates`, 'success')
setSelected(new Set())
return
}
if (action === 'assign') {
setBulkAssigning(true)
return
}
if (action === 'advance') {
updateCandidates((cs) =>
cs.map((c) => {
if (!selected.has(c.id)) return c
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) return c
const stage = STAGE_ORDER[i + 1]
return { ...c, stage, status: stage }
}),
)
toast(`${ids.length} candidates advanced`, 'success')
}
if (action === 'reject') {
updateCandidates((cs) =>
cs.map((c) => (selected.has(c.id) ? { ...c, stage: 'Rejected', status: 'Rejected' } : c)),
)
toast(`${ids.length} candidates rejected`, 'warning')
}
setSelected(new Set())
}
const recentChips = recentlyViewed
.slice(0, 6)
.map((id) => candidates.find((c) => c.id === id))
@ -251,6 +203,14 @@ export default function Candidates() {
const setFilter = (k, v) => setFilters((f) => ({ ...f, [k]: v }))
function openAts(c) {
if (c.scoringStatus !== 'completed') {
toast('This CV could not be scored — no match analysis available', 'info')
return
}
setAtsFor(c)
}
return (
<div className="page">
<div className="page-head">
@ -261,12 +221,12 @@ export default function Candidates() {
</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Search saved', 'success')}>
<Icon name="bookmark" /> Save Search
</button>
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}>
<Icon name="download" /> Export
</button>
<button className="btn btn-secondary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Import CVs
</button>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<Icon name="plus" /> Add Candidate
</button>
@ -278,25 +238,12 @@ export default function Candidates() {
<span className="text-muted text-sm fw-600">Recently viewed:</span>
{recentChips.map((c) => (
<button key={c.id} className="prompt-chip" style={{ padding: '5px 10px' }} onClick={() => openProfile(c)}>
<Avatar name={c.name} initials={c.initials} color={c.color} /> {c.name.split(' ')[0]}
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} /> {c.name.split(' ')[0]}
</button>
))}
</div>
)}
{selected.size > 0 && (
<div className="bulk-bar" style={{ display: 'flex' }}>
<span className="checkbox on"><Icon name="check" /></span>
<span className="fw-600">{selected.size} selected</span>
<div style={{ flex: 1 }} />
<button className="btn btn-sm" onClick={() => bulk('email')}><Icon name="mail" /> Bulk Email</button>
<button className="btn btn-sm" onClick={() => bulk('assign')}><Icon name="users" /> Assign</button>
<button className="btn btn-sm" onClick={() => bulk('advance')}><Icon name="check" /> Advance</button>
<button className="btn btn-sm" onClick={() => bulk('reject')}><Icon name="x" /> Reject</button>
<button className="btn btn-sm" onClick={() => setSelected(new Set())}><Icon name="x" /> Clear</button>
</div>
)}
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
@ -322,156 +269,150 @@ export default function Candidates() {
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobTitles} />
<Facet label="Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillsPool} />
<Facet label="Department" value={filters.dept} onChange={(v) => setFilter('dept', v)} any="Any Dept" options={departments} />
<Facet label="Location" value={filters.location} onChange={(v) => setFilter('location', v)} any="Any Location" options={locations} />
<Facet label="Experience" value={filters.exp} onChange={(v) => setFilter('exp', v)} any="Any Exp" options={EXP_BUCKETS} />
<Facet label="Education" value={filters.edu} onChange={(v) => setFilter('edu', v)} any="Any" options={educationLevels} />
<Facet label="Recruiter" value={filters.recruiter} onChange={(v) => setFilter('recruiter', v)} any="Any Recruiter" options={recruiters.map((r) => r.name)} />
<Facet label="Hiring Manager" value={filters.manager} onChange={(v) => setFilter('manager', v)} any="Any Manager" options={managers.map((m) => m.name)} />
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={sources} />
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobOptions} />
<Facet label="Matched Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillOptions} />
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={['upload', 'inbox']} labels={SOURCE_LABEL} />
<Facet label="ATS Score" value={filters.ats} onChange={(v) => setFilter('ats', v)} any="Any Score" options={ATS_BANDS} />
<Facet label="Pipeline Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any Stage" options={stages} />
<Facet label="Interview Status" value={filters.interview} onChange={(v) => setFilter('interview', v)} any="Any" options={INTERVIEW_STATES} />
<Facet label="Notice Period" value={filters.notice} onChange={(v) => setFilter('notice', v)} any="Any" options={NOTICE} />
<Facet label="Availability" value={filters.availability} onChange={(v) => setFilter('availability', v)} any="Any" options={AVAILABILITY} />
<Facet label="Status" value={filters.status} onChange={(v) => setFilter('status', v)} any="Any Status" options={['Scored', 'Failed']} />
</div>
)}
</div>
<div className="dt">
<div className="table-wrap">
<table className="data">
<thead>
<tr>
{columns.map((c) => {
const isSorted = t.sort.key === c.key
const cls = [
c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ')
return (
<th
key={c.key}
className={cls}
style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
>
{c.label}
{c.sortable && (
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody>
{t.pageRows.length === 0 ? (
<tr><td colSpan={columns.length}><EmptyState /></td></tr>
) : (
t.pageRows.map((c) => (
<tr key={c.id}>
<td>
<span
className={`checkbox ${selected.has(c.id) ? 'on' : ''}`}
onClick={() => toggleSelect(c.id)}
role="checkbox"
aria-checked={selected.has(c.id)}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelect(c.id) } }}
{candidatesQuery.isPending && (
<div className="card-body">
<EmptyState icon="users" title="Loading…">Fetching candidates from the server.</EmptyState>
</div>
)}
{candidatesQuery.isError && (
<div className="card-body">
<EmptyState icon="users" title="Couldnt load candidates">
{friendlyAuthError(candidatesQuery.error, 'Request failed')}
</EmptyState>
</div>
)}
{candidatesQuery.isSuccess && (
<div className="dt">
<div className="table-wrap">
<table className="data">
<thead>
<tr>
{columns.map((c) => {
const isSorted = t.sort.key === c.key
const cls = [
c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ')
return (
<th
key={c.key}
className={cls}
style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
>
<Icon name="check" />
</span>
</td>
<td>
<div className="user-cell">
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div>
<div className="cell-primary">
{c.name}{' '}
{c.favorite && (
<span className="star-btn on" style={{ display: 'inline' }}><Icon name="star" /></span>
)}
</div>
<div className="cell-sub">{c.currentTitle} · {c.location}</div>
</div>
</div>
</td>
<td>
<div className="text-sm">{c.jobTitle}</div>
<div className="cell-sub">{c.department}</div>
</td>
<td style={{ textAlign: 'center' }}><b>{c.experience}</b>y</td>
<td style={{ textAlign: 'center' }}>
<span className={`badge ${atsRecommendationClass(c.recommendation)} badge-plain`}>
{relevance(c)}%
</span>
</td>
<td><Badge>{c.stage}</Badge></td>
<td style={{ textAlign: 'center' }}>
<span style={{ cursor: 'pointer' }} onClick={() => setAtsFor(c)}>
<ScoreChip score={c.aiScore} />
</span>
</td>
<td>
<span className="text-sm">{c.availability}</span>
<div className="cell-sub">{c.noticePeriod} notice</div>
</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className={`act-btn star-btn ${c.favorite ? 'on' : ''}`} data-tip="Favorite" onClick={() => toggleFav(c)}>
<Icon name="star" />
</button>
<button className="act-btn" data-tip="ATS Match" onClick={() => setAtsFor(c)}><Icon name="target" /></button>
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Advance" onClick={() => advance(c)}><Icon name="check" /></button>
</div>
{c.label}
{c.sortable && (
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody>
{t.pageRows.length === 0 ? (
<tr>
<td colSpan={columns.length}>
<EmptyState title="No candidates yet">
Score resumes in CV Import to fill this table.
</EmptyState>
</td>
</tr>
))
)}
</tbody>
</table>
) : (
t.pageRows.map((c) => (
<tr key={c.id}>
<td>
<div className="user-cell">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} />
<div>
<div className="cell-primary">{c.name}</div>
<div className="cell-sub">
{c.currentTitle ?? c.filename}
{c.currentCompany ? ` · ${c.currentCompany}` : ''}
</div>
</div>
</div>
</td>
<td>
<div className="text-sm">{jobTitleOf(c)}</div>
<div className="cell-sub">{SOURCE_LABEL[c.source] ?? c.source}</div>
</td>
<td style={{ textAlign: 'center' }}>
{c.experience != null ? <><b>{c.experience}</b>y</> : '—'}
</td>
<td style={{ textAlign: 'center' }}>
{c.scoringStatus === 'completed' ? (
<span className={`badge ${atsRecommendationClass(recommendationOf(c))} badge-plain`}>
{relevance(c)}%
</span>
) : '—'}
</td>
<td>
{c.scoringStatus === 'completed'
? <Badge className="b-green">Scored</Badge>
: <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>}
</td>
<td style={{ textAlign: 'center' }}>
{c.aiScore != null ? (
<span style={{ cursor: 'pointer' }} onClick={() => openAts(c)}>
<ScoreChip score={c.aiScore} />
</span>
) : '—'}
</td>
<td>
<span className="text-sm">
{c.applied ? c.applied.toLocaleDateString() : '—'}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className="act-btn" data-tip="ATS Match" onClick={() => openAts(c)}><Icon name="target" /></button>
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination {...t} />
</div>
<Pagination {...t} />
</div>
)}
</div>
{atsFor && <AtsMatch candidate={atsFor} onClose={() => setAtsFor(null)} onProfile={(c) => { setAtsFor(null); openProfile(c) }} />}
{atsFor && (
<AtsMatch
candidate={atsFor}
jobTitle={jobTitleOf(atsFor)}
onClose={() => setAtsFor(null)}
onProfile={(c) => { setAtsFor(null); openProfile(c) }}
/>
)}
{profileFor && (
<CandidateProfile
candidate={candidates.find((c) => c.id === profileFor.id) ?? profileFor}
jobTitle={jobTitleOf(profileFor)}
onClose={() => setProfileFor(null)}
onAdvance={advance}
onToggleFav={toggleFav}
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
/>
)}
{bulkAssigning && (
<BulkAssign
count={selected.size}
recruiters={recruiters}
onClose={() => setBulkAssigning(false)}
onSave={(name) => {
updateCandidates((cs) => cs.map((c) => (selected.has(c.id) ? { ...c, recruiter: name } : c)))
setBulkAssigning(false)
setSelected(new Set())
toast('Recruiter assigned to selected candidates', 'success')
}}
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
/>
)}
{adding && (
<AddCandidate
jobs={jobs}
count={candidates.length}
onClose={() => setAdding(false)}
onSave={(c) => {
updateCandidates((cs) => [c, ...cs])
onSave={() => {
setAdding(false)
toast('Candidate added to pipeline', 'success')
}}
@ -482,37 +423,29 @@ export default function Candidates() {
)
}
function Facet({ label, value, onChange, any, options }) {
function Facet({ label, value, onChange, any, options, labels }) {
return (
<div className="form-field">
<label>{label}</label>
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{any}</option>
{options.map((o) => <option key={o}>{o}</option>)}
{options.map((o) => <option key={o} value={o}>{labels?.[o] ?? o}</option>)}
</select>
</div>
)
}
/** Exported so TalentPool's profile modal can open the same ATS breakdown. */
export function AtsMatch({ candidate: c, onClose, onProfile }) {
const sub = c.subScores
const recCls = c.recommendation === 'Strong Match' ? 'recc-strong'
: c.recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
const recommendation = recommendationOf(c)
const recCls = recommendation === 'Strong Match' ? 'recc-strong'
: recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
const ringColor = c.aiScore >= 82 ? 'var(--success)' : c.aiScore >= 65 ? 'var(--warning)' : 'var(--danger)'
const Row = ({ label, val }) => (
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
<span style={{ width: 110, fontSize: 13 }}>{label}</span>
<div style={{ flex: 1 }}><ProgressBar pct={val} /></div>
<b style={{ width: 42, textAlign: 'right' }}>{val}%</b>
</div>
)
return (
<Modal
title="ATS Match Analysis"
subtitle={`${c.id} · ${c.jobTitle}`}
subtitle={jobTitle}
size="modal-lg"
onClose={onClose}
footer={
@ -524,11 +457,11 @@ export function AtsMatch({ candidate: c, onClose, onProfile }) {
>
<div className={`recc-banner ${recCls}`}>
<span className="recc-icn">
<Icon name={c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
<Icon name={recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
</span>
<div style={{ flex: 1 }}>
<div className="fw-600" style={{ fontSize: 15 }}>{c.recommendation}</div>
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name} for {c.jobTitle}</div>
<div className="fw-600" style={{ fontSize: 15 }}>{recommendation}</div>
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name}{jobTitle ? ` for ${jobTitle}` : ''}</div>
</div>
</div>
@ -542,12 +475,8 @@ export function AtsMatch({ candidate: c, onClose, onProfile }) {
</div>
</div>
<div>
<Row label="Skills" val={sub.skills} />
<Row label="Experience" val={sub.experience} />
<Row label="Education" val={sub.education} />
<Row label="Keywords" val={sub.keywords} />
<Row label="Location" val={sub.location} />
<Row label="Salary" val={sub.salary} />
<div className="form-section-title" style={{ marginTop: 0 }}>Assessment</div>
<p className="text-muted" style={{ fontSize: 13 }}>{c.critique ?? '—'}</p>
</div>
</div>
@ -575,37 +504,14 @@ export function AtsMatch({ candidate: c, onClose, onProfile }) {
<div className="divider" />
<p className="text-muted text-sm">
<Icon name="sparkles" /> Score computed from JD keywords, resume parsing, experience,
education, location and salary alignment. Connect an AI model to refine with semantic matching.
<Icon name="sparkles" /> Scored by the ATS engine against the job post's requirements.
Matched skills are verified to appear in the resume text; the one-line assessment is
model-generated and evidence-based.
</p>
</Modal>
)
}
function BulkAssign({ count, recruiters, onClose, onSave }) {
const [name, setName] = useState(recruiters[0]?.name ?? '')
return (
<Modal
title="Bulk Assign Recruiter"
subtitle={`${count} candidates`}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
</>
}
>
<div className="form-field">
<label>Assign to</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
</Modal>
)
}
/**
* Add Candidate the only writer on this screen that reaches the server.
*
@ -618,14 +524,11 @@ function BulkAssign({ count, recruiters, onClose, onSave }) {
* job_post_id is a job_posts FK and a seed id would be coerced to NULL without
* an error the link would look saved and simply not exist.
*
* The row handed to onSave is still seed-shaped. Nothing on this screen reads
* /candidate/fetch manual rows do not pass through `inbox`, so they surface
* neither here nor in Talent Pool and dropping the candidate the recruiter
* just created out of the table would read as a failed save. The fabricated
* scoring fields are the pre-existing seed shape, unchanged; only the identity
* fields now carry what was actually posted.
* Manual rows do not pass through `inbox`, so /candidate/fetch may not surface
* them immediately; the save still invalidates the candidates query so the
* live-backed screens refetch and pick the row up once an application links it.
*/
function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
function AddCandidate({ onClose, onSave, onInvalid }) {
const { toast } = useToast()
const qc = useQueryClient()
const fileInput = useRef(null)
@ -655,46 +558,14 @@ function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
const create = useMutation({
mutationFn: (vars) => candidatesApi.createManual(vars),
onError: (err) => toast(friendlyAuthError(err, 'Could not add the candidate.'), 'error'),
onSuccess: (res) => {
onSuccess: () => {
// The new user_id lands in /candidate/fetch's join the moment an
// application exists for them, so let the live-backed screens refetch.
qc.invalidateQueries({ queryKey: qk.candidates.all() })
onSave(buildRow(res?.data))
onSave()
},
})
function buildRow(saved) {
const v = form.values
const post = posts.find((p) => String(p.id) === jobPostId)
const title = post?.title || v.job || jobs[0]?.title || 'Unassigned'
// Department, location, recruiter and skills are presentation-only columns
// the endpoint does not return borrow them from the seed job of the same
// title so the row renders like every other one.
const job = jobs.find((j) => j.title === title) || jobs[0] || {}
const skills = job.skills ?? []
const score = int(55, 95)
return {
id: `CAN-${5001 + count}`,
userId: saved?.user_id ?? null,
manualUploadId: saved?.id ?? null,
name: v.name, initials: initialsOf(v.name), color: avatarColor(v.name),
email: v.email, phone: v.phone || '+1 (555) 000-0000',
jobId: job.id, jobTitle: title, department: job.department,
experience: Number(v.experience) || 1, currentCompany: v.company || '—',
currentTitle: title, location: job.location,
stage: v.stage, status: v.stage, aiScore: score, source: v.source,
referredBy: referralValue(v.referral) || null,
recruiter: job.recruiter, recruiterId: job.recruiterId,
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: skills.slice(0, 4), rating: '4.0', salary: 120000,
matchedSkills: skills.slice(0, 3), missingSkills: skills.slice(3),
recommendation: score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match',
subScores: { skills: score, experience: 80, education: 80, keywords: score, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
}
}
function pickFile(next) {
if (!next) return
setCv(next)

View File

@ -1,169 +1,135 @@
/* ============================================================
CV Import the UX shape is right; the mechanics are still simulated.
CV Import real upload score persist flow.
The prototype's dropzone read only `e.dataTransfer.files.length` and threw
the files away, then invented a queue with setInterval-driven progress. That
is preserved deliberately: there is no upload endpoint, no object storage and
no parser behind this yet, so pretending otherwise would be worse than the
honest "processed locally in this demo" label the screen already carries.
Files go to POST /candidate/score as one multipart batch: the backend
extracts each PDF, scores it against the selected job with the ATS engine,
and persists a row per file. Unreadable/oversized/non-PDF files come back
as status "failed" rows instead of failing the batch, and re-uploading the
same bytes updates the existing record (content-hash dedupe) so there is
no separate "import" step and no duplicate modal anymore.
============================================================ */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Badge, Icon, ScoreChip } from '../ui/primitives'
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import {
avatarColor, companies, initials as initialsOf, int, locations, pick, TODAY,
} from '../data/seed'
const FIRST = ['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Priya', 'Diego', 'Yuki', 'Omar']
const LAST = ['Chen', 'Patel', 'Kim', 'Garcia', 'Silva', 'Ahmed', 'Novak', 'Reyes', 'Khan', 'Costa']
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
const STEPS = [
{ i: 'file', t: 'Resume parsing', d: 'Extract name, contact, experience, skills & education' },
{ i: 'target', t: 'ATS scoring', d: 'Generate a match score against the requisition' },
{ i: 'briefcase', t: 'Job matching', d: 'Suggest the best-matching open roles' },
{ i: 'users', t: 'Duplicate detection', d: 'Flag candidates already in the system' },
{ i: 'user-plus', t: 'Profile creation', d: 'Create a candidate profile in Applied stage' },
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
{ i: 'target', t: 'ATS scoring', d: 'LLM match score with matched & missing skills vs the selected job' },
{ i: 'users', t: 'Duplicate detection', d: 'Re-uploading the same file updates its existing record' },
{ i: 'user-plus', t: 'Saved to pool', d: 'Results persist — see Candidates and Talent Pool' },
]
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
}
function fmtSize(bytes) {
if (!Number.isFinite(bytes)) return ''
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
let rowSeq = 0
export default function CvImport() {
const { toast } = useToast()
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const updateCandidates = useSeedMutation('candidates')
const qc = useQueryClient()
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const jobs = jobsQuery.data ?? []
const [jobId, setJobId] = useState('')
const [queue, setQueue] = useState([])
const [dragging, setDragging] = useState(false)
const [duplicateFor, setDuplicateFor] = useState(null)
const timers = useRef(new Set())
const fileInput = useRef(null)
useEffect(() => {
const set = timers.current
return () => {
set.forEach((t) => { clearInterval(t); clearTimeout(t) })
set.clear()
}
}, [])
const advance = useCallback((id) => {
const tick = setInterval(() => {
const scoring = useMutation({
mutationFn: ({ job, files }) => candidatesApi.scoreUploads(job, files),
onSuccess: (res, vars) => {
const rows = Array.isArray(res?.data) ? res.data : []
setQueue((q) =>
q.map((item) => {
if (item.id !== id || item.status !== 'Uploading') return item
const progress = Math.min(100, item.progress + int(12, 30))
if (progress >= 100) {
clearInterval(tick)
timers.current.delete(tick)
const done = setTimeout(() => {
setQueue((q2) =>
q2.map((x) => (x.id === id ? { ...x, status: 'Ready', atsScore: int(52, 96) } : x)),
)
timers.current.delete(done)
}, 700 + int(0, 500))
timers.current.add(done)
return { ...item, progress: 100, status: 'Parsing' }
if (!vars.rowIds.includes(item.id)) return item
const match = rows.find((r) => r.filename === item.file)
if (!match) return { ...item, status: 'Failed', error: 'NO_RESULT' }
if (match.status !== 'completed') {
return { ...item, status: 'Failed', error: match.error_code || 'FAILED' }
}
return {
...item,
status: 'Ready',
name: match.candidate_name || item.file,
atsScore: match.match_score,
critique: match.summary_critique,
}
return { ...item, progress }
}),
)
}, 220)
timers.current.add(tick)
}, [])
const simulate = useCallback(
(count, isZip) => {
const n = isZip ? 8 : count
const open = jobs.filter((j) => j.status === 'Open')
const items = []
for (let k = 0; k < n; k++) {
const name = `${pick(FIRST)} ${pick(LAST)}`
items.push({
id: `UP-${Math.random().toString(36).slice(2, 8)}`,
name,
file: `${name.split(' ')[0]}_Resume.${pick(['pdf', 'docx', 'doc'])}`,
size: `${int(120, 620)} KB`,
progress: 0,
status: 'Uploading',
atsScore: null,
job: pick(open.length ? open : jobs),
duplicate: Math.random() < 0.18,
imported: false,
})
}
setQueue((q) => [...q, ...items])
items.forEach((i) => advance(i.id))
toast(isZip ? 'ZIP extracted — 8 resumes queued' : `${n} file(s) uploaded`, 'info')
qc.invalidateQueries({ queryKey: qk.candidates.all() })
const ok = rows.filter((r) => r.status === 'completed').length
const failed = rows.length - ok
toast(
failed
? `${ok} scored, ${failed} failed — results saved to the candidate pool`
: `${ok} resume${ok === 1 ? '' : 's'} scored and saved`,
failed ? 'warning' : 'success',
)
},
[jobs, advance, toast],
)
const doImport = useCallback(
(id) => {
const item = queue.find((x) => x.id === id)
if (!item || item.imported) return
const job = item.job
updateCandidates((cs) => [
{
id: `CAN-${5001 + cs.length}`,
name: item.name,
initials: initialsOf(item.name),
color: avatarColor(item.name),
email: `${item.name.toLowerCase().replace(/ /g, '.')}@email.com`,
phone: '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: int(2, 12), currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: item.atsScore, source: 'Manual CV Upload',
recruiter: job.recruiter, recruiterId: '',
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
// Kept verbatim from the prototype, including the literal constants
// this breakdown is fabricated and is flagged as the most misleading
// artefact in the repo (01-repository-assessment.md §2.2).
subScores: { skills: item.atsScore, experience: 80, education: 80, keywords: item.atsScore, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
},
...cs,
])
setQueue((q) => q.map((x) => (x.id === id ? { ...x, imported: true } : x)))
toast(`${item.name} imported → ${job.title}`, 'success')
onError: (err, vars) => {
setQueue((q) =>
q.map((item) =>
vars.rowIds.includes(item.id) ? { ...item, status: 'Failed', error: 'REQUEST_FAILED' } : item,
),
)
toast(friendlyAuthError(err, 'Scoring failed'), 'error')
},
[queue, updateCandidates, toast],
)
})
function importOne(item) {
if (item.duplicate) setDuplicateFor(item)
else doImport(item.id)
}
function importAll() {
const ready = queue.filter((i) => i.status === 'Ready' && !i.imported && !i.duplicate)
if (!ready.length) {
toast('No files ready to import', 'warning')
function handleFiles(fileList) {
const all = Array.from(fileList || [])
if (!all.length) return
if (!jobId) {
toast('Select a job to score against first', 'warning')
return
}
ready.forEach((i) => doImport(i.id))
toast(`${ready.length} candidates imported`, 'success')
const files = all.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
const skipped = all.length - files.length
if (skipped) toast(`Only PDF resumes are supported — ${skipped} file(s) skipped`, 'warning')
if (!files.length) return
const items = files.map((f) => ({
id: `UP-${++rowSeq}-${Date.now()}`,
name: f.name,
file: f.name,
size: fmtSize(f.size),
status: 'Scoring',
atsScore: null,
critique: null,
error: null,
}))
setQueue((q) => [...q, ...items])
scoring.mutate({ job: jobId, files, rowIds: items.map((i) => i.id) })
}
const importedCount = queue.filter((i) => i.imported).length
const scored = queue.filter((i) => i.status === 'Ready').length
const failed = queue.filter((i) => i.status === 'Failed').length
const selectedJob = jobs.find((j) => j.id === jobId)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">CV Import</h1>
<p className="page-sub">Upload resumes we parse, score, match, and dedupe automatically</p>
<p className="page-sub">Upload resume PDFs parsed, scored against a job, and saved automatically</p>
</div>
<div className="page-head-actions">
<span className="integration-status pending"><span className="pulse" />AI Resume Parser · Ready</span>
<span className="integration-status pending"><span className="pulse" />AI Resume Scoring · Live</span>
</div>
</div>
@ -171,45 +137,59 @@ export default function CvImport() {
<div>
<div className="card mb-18">
<div className="card-body">
<div className="flex items-center gap-8" style={{ marginBottom: 16 }}>
<span className="fw-600 text-sm" style={{ flexShrink: 0 }}>Score against</span>
<select
className="select"
style={{ flex: 1 }}
value={jobId}
onChange={(e) => setJobId(e.target.value)}
>
<option value="">Select a job post</option>
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select>
</div>
{jobsQuery.isError && (
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
{friendlyAuthError(jobsQuery.error, 'Could not load job posts')}
</p>
)}
<div
className={`dropzone${dragging ? ' drag' : ''}`}
onClick={() => simulate(int(2, 4))}
onClick={() => fileInput.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
simulate(e.dataTransfer.files.length || int(2, 4))
handleFiles(e.dataTransfer.files)
}}
>
<input
ref={fileInput}
type="file"
accept=".pdf,application/pdf"
multiple
hidden
onChange={(e) => { handleFiles(e.target.files); e.target.value = '' }}
/>
<div className="dz-icn"><Icon name="upload" /></div>
<h3>Drag &amp; drop resumes here</h3>
<p className="text-muted" style={{ marginBottom: 16 }}>
or click to browse PDF, DOC, DOCX and ZIP supported · up to 20 files
or click to browse PDF only · up to 50 files per batch
</p>
<button
className="btn btn-primary"
onClick={(e) => { e.stopPropagation(); simulate(int(2, 4)) }}
onClick={(e) => { e.stopPropagation(); fileInput.current?.click() }}
>
<Icon name="upload" /> Browse Files
</button>
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 16 }}>
{['PDF', 'DOC', 'DOCX', 'ZIP'].map((t) => (
<span className="badge b-gray badge-plain" key={t}>{t}</span>
))}
<span className="badge b-gray badge-plain">PDF</span>
<span className="text-muted text-sm">DOC / DOCX support coming later</span>
</div>
</div>
<div className="flex items-center gap-8" style={{ marginTop: 16, flexWrap: 'wrap' }}>
<button className="btn btn-secondary btn-sm" onClick={() => simulate(3)}>
<Icon name="sparkles" /> Simulate 3 files
</button>
<button className="btn btn-secondary btn-sm" onClick={() => simulate(1, true)}>
<Icon name="layers" /> Simulate ZIP (8 CVs)
</button>
<span className="text-muted text-sm" style={{ marginLeft: 'auto' }}>
Files are processed locally in this demo
</span>
</div>
</div>
</div>
@ -219,12 +199,11 @@ export default function CvImport() {
<div>
<h3>Processing Queue</h3>
<span className="ch-sub">
{queue.length} file{queue.length === 1 ? '' : 's'} · {importedCount} imported
{queue.length} file{queue.length === 1 ? '' : 's'} · {scored} scored
{failed ? ` · ${failed} failed` : ''}
{selectedJob ? ` · vs ${selectedJob.title}` : ''}
</span>
</div>
<button className="btn btn-primary btn-sm" onClick={importAll}>
<Icon name="check" /> Import All
</button>
</div>
<div className="card-body">
{queue.map((i) => (
@ -233,46 +212,39 @@ export default function CvImport() {
<div style={{ flex: 1, minWidth: 0 }}>
<div className="flex items-center gap-8">
<span className="fw-600 text-sm">{i.name}</span>
{i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 7px', fontSize: 10 }}>
DUPLICATE
</span>
)}
</div>
<div className="cell-sub">{i.file} · {i.size}</div>
{i.status === 'Uploading' || i.status === 'Parsing' ? (
{i.status === 'Scoring' && (
<div className="upload-progress" style={{ marginTop: 6 }}>
<div className="upload-progress-fill" style={{ width: `${i.progress}%` }} />
</div>
) : (
<div className="cell-sub" style={{ marginTop: 4 }}>
Best match: <b>{i.job?.title}</b>
<div className="upload-progress-fill" style={{ width: '66%' }} />
</div>
)}
{i.status === 'Ready' && i.critique && (
<div className="cell-sub" style={{ marginTop: 4 }}>{i.critique}</div>
)}
{i.status === 'Failed' && (
<div className="cell-sub" style={{ marginTop: 4 }}>Could not be scored</div>
)}
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
{i.status === 'Ready' ? (
<ScoreChip score={i.atsScore} />
) : (
<Badge className={i.status === 'Parsing' ? 'b-amber' : 'b-blue'}>
{i.status}{i.status === 'Uploading' ? ` ${i.progress}%` : ''}
</Badge>
)}
{i.status === 'Ready' && <ScoreChip score={i.atsScore} />}
{i.status === 'Scoring' && <Badge className="b-blue">Scoring</Badge>}
{i.status === 'Failed' && <Badge className="b-red">{i.error}</Badge>}
</div>
<div style={{ flexShrink: 0 }}>
{i.imported ? (
<Badge className="b-green">Imported</Badge>
) : i.status === 'Ready' ? (
<button className="btn btn-primary btn-sm" onClick={() => importOne(i)}>Import</button>
) : (
<button className="act-btn" disabled><Icon name="clock" /></button>
)}
{i.status === 'Ready' && <Badge className="b-green">Saved</Badge>}
</div>
</div>
))}
</div>
</div>
)}
{queue.length === 0 && jobsQuery.isSuccess && jobs.length === 0 && (
<EmptyState icon="briefcase" title="No job posts yet">
Create a job post first resumes are always scored against a job.
</EmptyState>
)}
</div>
<div className="card" style={{ alignSelf: 'start' }}>
@ -290,44 +262,6 @@ export default function CvImport() {
</div>
</div>
</div>
{duplicateFor && (
<Modal
title="Duplicate Detected"
subtitle={duplicateFor.name}
onClose={() => setDuplicateFor(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setDuplicateFor(null)}>Cancel</button>
<button
className="btn btn-secondary"
onClick={() => { setDuplicateFor(null); toast('Merged into existing profile', 'success') }}
>
Merge
</button>
<button
className="btn btn-primary"
onClick={() => { const id = duplicateFor.id; setDuplicateFor(null); doImport(id) }}
>
Import Anyway
</button>
</>
}
>
<div className="flex gap-16 items-center">
<span className="kpi-icn i-amber" style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}>
<Icon name="users" />
</span>
<div>
<p className="fw-600" style={{ fontSize: 15 }}>A similar candidate already exists</p>
<p className="text-muted" style={{ marginTop: 4 }}>
{duplicateFor.name} matches an existing profile (95% similarity on name + email).
Importing will create a duplicate.
</p>
</div>
</div>
</Modal>
)}
</div>
)
}

View File

@ -0,0 +1,138 @@
/* The profile modal for SCORED candidates (rows from /candidate/scored/fetch),
used by Candidates.jsx. Distinct from CandidateProfile.jsx, which renders
inbox-derived candidate profiles (userId, interviews, notes, feedback) for
TalentPool. The two data shapes share almost no fields, hence two modals. */
import { useState } from 'react'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
const TABS = ['Overview', 'Scoring', 'File']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) {
const [tab, setTab] = useState('Overview')
const scored = c.scoringStatus === 'completed'
return (
<Modal
title="Candidate Profile"
subtitle={c.filename}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
<button className="btn btn-primary" onClick={onClose}>Close</button>
</>
}
>
<div className="profile-hero">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{c.name}</div>
<div className="ph-role">
{c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''}
</div>
<div className="ph-tags">
{c.scoringStatus && (scored ? <Badge className="b-green">Scored</Badge> : <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>)}
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
{c.experience != null && (
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
)}
</div>
</div>
{c.aiScore != null && (
<div style={{ textAlign: 'center' }}>
<ScoreChip score={c.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
)}
</div>
<div style={{ marginTop: 22 }}>
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
</div>
<div className="tab-pane active">
{tab === 'Overview' && (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Scored For</div><div className="iv">{jobTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany ?? '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience != null ? `${c.experience} years` : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
<div className="info-item"><div className="il">Added On</div><div className="iv">{c.applied ? fmtDate(c.applied) : '—'}</div></div>
</div>
{scored && (
<>
<div style={LABEL}>Matched Skills</div>
<div className="k-tags">
{c.matchedSkills.length
? c.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)
: <span className="text-muted"></span>}
</div>
</>
)}
</>
)}
{tab === 'Scoring' && (
scored ? (
<>
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
<p className="text-muted" style={{ marginBottom: 18 }}>{c.critique ?? '—'}</p>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({c.matchedSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{c.matchedSkills.length
? c.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 ({c.missingSkills.length})
</div>
<div className="k-tags">
{c.missingSkills.length
? c.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">
{c.errorMessage ?? 'This CV could not be processed.'}
</EmptyState>
)
)}
{tab === 'File' && (
<div className="info-grid">
<div className="info-item"><div className="il">File Name</div><div className="iv">{c.filename}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
{c.inboxMessageId && (
<div className="info-item"><div className="il">Inbox Message</div><div className="iv">{c.inboxMessageId}</div></div>
)}
{!scored && (
<>
<div className="info-item"><div className="il">Error</div><div className="iv">{c.errorCode ?? '—'}</div></div>
<div className="info-item"><div className="il">Detail</div><div className="iv">{c.errorMessage ?? '—'}</div></div>
</>
)}
</div>
)}
</div>
</Modal>
)
}

View File

@ -1,4 +1,4 @@
/* ============================================================
/* ============================================================
Talent Pool the prototype's card grid, now fed by GET /candidate/fetch.
The layout, the toolbar, the card and the 8-tab profile modal are the
@ -82,6 +82,10 @@ function merge(row, template) {
status: stage,
currentTitle: title || template.currentTitle,
jobTitle: title || template.jobTitle,
// Real ATS score (scoring engine, joined server-side by inbox message)
// wins over the seed placeholder; recommendation follows it.
aiScore: row.ai_score ?? template.aiScore,
recommendation: row.recommendation ?? template.recommendation,
}
}