Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Dashboard_Wiring
commit
17279b567b
394
README.md
394
README.md
|
|
@ -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 0–100, ≤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 73–97 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [],
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="Couldn’t 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)
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue