From 6f7cfb42a7be2d7caa5c0c36ad73d661998ae183 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 12 Aug 2026 16:36:37 +0500 Subject: [PATCH 1/4] updated mainj --- README.md | 394 ++++++------ backend/.env.example | 10 + backend/README.md | 24 +- backend/analytics/app.py | 109 ++++ backend/analytics/serializers.py | 22 + backend/analytics/views.py | 557 ++++++++++++++++ backend/inbox/models.py | 111 +++- backend/inbox/serializers.py | 9 +- backend/inbox/tasks.py | 75 ++- backend/inbox/views.py | 14 + backend/job/activity/views.py | 26 +- backend/job/app.py | 286 ++++++++- backend/job/assignment/models.py | 120 ++++ backend/job/assignment/serializers.py | 24 + backend/job/assignment/views.py | 69 ++ backend/job/candidate/models.py | 239 ++++++- backend/job/candidate/plugins.py | 81 ++- backend/job/candidate/serializers.py | 26 + backend/job/candidate/views.py | 268 +++++++- backend/job/cost/models.py | 91 +++ backend/job/cost/serializers.py | 13 + backend/job/cost/views.py | 44 ++ backend/job/interviews/serializers.py | 3 + backend/job/interviews/views.py | 25 +- backend/job/job_post/models.py | 16 +- backend/job/pipeline/serializers.py | 13 + backend/job/pipeline/views.py | 64 ++ backend/main.py | 4 + .../manual/001_dashboard_rbac_and_enum.sql | 242 +++++++ backend/offer/app.py | 125 ++++ backend/offer/models.py | 152 +++++ backend/offer/plugins.py | 4 + backend/offer/serializers.py | 38 ++ backend/offer/views.py | 146 +++++ backend/requirements.txt | 6 + backend/role/models.py | 28 +- backend/users/models.py | 21 +- frontend/.vite/deps/_metadata.json | 8 + frontend/.vite/deps/package.json | 3 + frontend/src/api/activity.js | 14 + frontend/src/api/analytics.js | 63 ++ frontend/src/api/candidates.js | 87 +++ frontend/src/api/interviews.js | 23 + frontend/src/api/offers.js | 38 ++ frontend/src/lib/queryKeys.js | 25 +- frontend/src/screens/CandidateProfile.jsx | 23 +- frontend/src/screens/Candidates.jsx | 599 +++++++----------- frontend/src/screens/CvImport.jsx | 364 +++++------ frontend/src/screens/Dashboard.jsx | 540 +++++++++++++--- .../src/screens/ScoredCandidateProfile.jsx | 138 ++++ frontend/src/screens/TalentPool.jsx | 6 +- 51 files changed, 4504 insertions(+), 926 deletions(-) create mode 100644 backend/analytics/app.py create mode 100644 backend/analytics/serializers.py create mode 100644 backend/analytics/views.py create mode 100644 backend/job/assignment/models.py create mode 100644 backend/job/assignment/serializers.py create mode 100644 backend/job/assignment/views.py create mode 100644 backend/job/cost/models.py create mode 100644 backend/job/cost/serializers.py create mode 100644 backend/job/cost/views.py create mode 100644 backend/job/pipeline/serializers.py create mode 100644 backend/job/pipeline/views.py create mode 100644 backend/migrations/manual/001_dashboard_rbac_and_enum.sql create mode 100644 backend/offer/app.py create mode 100644 backend/offer/models.py create mode 100644 backend/offer/plugins.py create mode 100644 backend/offer/serializers.py create mode 100644 backend/offer/views.py create mode 100644 frontend/.vite/deps/_metadata.json create mode 100644 frontend/.vite/deps/package.json create mode 100644 frontend/src/api/activity.js create mode 100644 frontend/src/api/analytics.js create mode 100644 frontend/src/api/interviews.js create mode 100644 frontend/src/api/offers.js create mode 100644 frontend/src/screens/ScoredCandidateProfile.jsx diff --git a/README.md b/README.md index a089d54..76b5d94 100644 --- a/README.md +++ b/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. diff --git a/backend/.env.example b/backend/.env.example index 43a3c50..e47fb09 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/README.md b/backend/README.md index 6f3f6cd..858277d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -110,6 +110,7 @@ backend/ ├── Dockerfile # image for the Taskiq worker / scheduler ├── alembic.ini # generated by alembic_setup.py, not hand-written ├── migrations/ # generated env.py + versions/ +│ └── manual/ # one-shot SQL (enum labels, RBAC seed, backfills) ├── LLM_CONTEXT_PROMPT.md # house-style prompt to paste into an LLM before editing │ ├── users/ # accounts, login, signup, RBAC enforcement @@ -117,10 +118,15 @@ backend/ ├── forget_password/ # reset-code request → verify → new password ├── notifications/ # email-confirmation tokens and mail ├── inbox/ # mailbox sync, attachments, applications +├── analytics/ # dashboard KPIs + charts (views only — no tables) +├── offer/ # offers + offer_status_history ├── job/ │ ├── app.py # routes for both sub-domains │ ├── job_post/ # job ads + Buffer publishing -│ └── candidate/ # CV reading, candidate profile +│ ├── candidate/ # CV reading, candidate profile, stage transitions model +│ ├── assignment/ # job_assignments + application_assignments +│ ├── cost/ # hiring_costs +│ └── pipeline/ # stage-change service (single writer) ├── agent/ # LangGraph CV → job-post matching agent └── taskiq_management/ # broker, scheduler, DLQ middleware, smoke task ``` @@ -602,6 +608,22 @@ python alembic_setup.py current python alembic_setup.py head ``` +Alembic autogenerate does **not** detect new PostgreSQL enum labels. Permission-tag +rows and analytics role bundles are also seeded out-of-band. Those live in +`migrations/manual/` and must be run by hand in psql (autocommit for `ADD VALUE`): + +```bash +# After `python alembic_setup.py upgrade` has created the new tables/columns: +psql "$DATABASE_URL" -f migrations/manual/001_dashboard_rbac_and_enum.sql +``` + +`001_dashboard_rbac_and_enum.sql` extends `candidate_application_status`, seeds all 104 +`permission_tags`, creates the `analytics_dashboard` bundle and attaches it to the +system roles that need the dashboard, seeds the eleven BRD `source_channels`, and +backfills `source_channel_id` / stage-transition / requisition-status rows. + +``` + Migrations run under a Postgres advisory lock, so several workers booting at once cannot migrate concurrently. Empty revisions are suppressed. Alembic's own `alembic_version` table is excluded from autogenerate, as is anything outside the configured schemas. diff --git a/backend/analytics/app.py b/backend/analytics/app.py new file mode 100644 index 0000000..bcfd118 --- /dev/null +++ b/backend/analytics/app.py @@ -0,0 +1,109 @@ +from datetime import datetime +from fastapi import APIRouter,Depends,Query +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from analytics.views import Analytics +from users.permissions import PermissionTag,require_permission +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +@router.get("/analytics/kpis/fetch") +async def fetch_kpis( + current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)), + from_date: datetime | None = Query(None), + to_date: datetime | None = Query(None), + department: str | None = Query(None), + recruiter_id: str | None = Query(None), + session: AsyncSession = Depends(get_session), +): + try: + service=Analytics(session=session) + data=await service.get_kpis(from_date,to_date,department,recruiter_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("/analytics/funnel/fetch") +async def fetch_funnel( + current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)), + from_date: datetime | None = Query(None), + to_date: datetime | None = Query(None), + department: str | None = Query(None), + recruiter_id: str | None = Query(None), + session: AsyncSession = Depends(get_session), +): + try: + service=Analytics(session=session) + data=await service.get_funnel(from_date,to_date,department,recruiter_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("/analytics/hiring-trend/fetch") +async def fetch_hiring_trend( + current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)), + months: int = Query(7,ge=1), + from_date: datetime | None = Query(None), + to_date: datetime | None = Query(None), + department: str | None = Query(None), + recruiter_id: str | None = Query(None), + session: AsyncSession = Depends(get_session), +): + try: + service=Analytics(session=session) + data=await service.get_hiring_trend(months,from_date,to_date,department,recruiter_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("/analytics/source-performance/fetch") +async def fetch_source_performance( + current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)), + from_date: datetime | None = Query(None), + to_date: datetime | None = Query(None), + department: str | None = Query(None), + recruiter_id: str | None = Query(None), + session: AsyncSession = Depends(get_session), +): + try: + service=Analytics(session=session) + data=await service.get_source_performance(from_date,to_date,department,recruiter_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("/analytics/recruiter-performance/fetch") +async def fetch_recruiter_performance( + current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)), + top: int = Query(5,ge=1), + from_date: datetime | None = Query(None), + to_date: datetime | None = Query(None), + department: str | None = Query(None), + recruiter_id: str | None = Query(None), + session: AsyncSession = Depends(get_session), +): + try: + service=Analytics(session=session) + data=await service.get_recruiter_performance(top,from_date,to_date,department,recruiter_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)) diff --git a/backend/analytics/serializers.py b/backend/analytics/serializers.py new file mode 100644 index 0000000..2e4d2f6 --- /dev/null +++ b/backend/analytics/serializers.py @@ -0,0 +1,22 @@ +"""Analytics responses are mostly assembled as dicts in views. + +Keep helpers here only when reuse across methods would otherwise duplicate. +""" + + +def serialize_stage_count(stage,count) -> dict: + return {"stage": stage,"count": int(count or 0)} + + +def serialize_source_count(source,count) -> dict: + return {"source": source or "Unknown","count": int(count or 0)} + + +def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire) -> dict: + return { + "id": str(user_id) if user_id else None, + "name": name, + "hires": int(hires or 0), + "open_reqs": int(open_reqs or 0), + "avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None, + } diff --git a/backend/analytics/views.py b/backend/analytics/views.py new file mode 100644 index 0000000..d7c5521 --- /dev/null +++ b/backend/analytics/views.py @@ -0,0 +1,557 @@ +import uuid +from datetime import datetime,timedelta,timezone + +from sqlalchemy import and_,func,or_,select +from sqlalchemy.ext.asyncio import AsyncSession + +from analytics.serializers import ( + serialize_recruiter_row, + serialize_source_count, + serialize_stage_count, +) +from inbox.enums import Candidate_application_Status +from inbox.models import Inbox,Inbox_Messages,SourceChannels +from job.assignment.models import JobAssignments +from job.candidate.models import ApplicationStageTransitions,Interviews +from job.cost.models import HiringCosts +from job.job_post.models import JobPosts +from offer.models import Offers +from role.models import EnumRoles,Roles +from users.models import Users + + +def _as_uuid(value): + if value in (None,""): + return None + try: + return uuid.UUID(str(value)) + except (TypeError,ValueError): + return None + + +def _month_start(dt: datetime) -> datetime: + return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) + + +def _next_month_start(dt: datetime) -> datetime: + if dt.month==12: + return datetime(dt.year+1,1,1,tzinfo=timezone.utc) + return datetime(dt.year,dt.month+1,1,tzinfo=timezone.utc) + + +def _resolve_windows(from_date,to_date): + """Return (from_date, to_date, prior_from, prior_to). Missing bounds → current calendar month.""" + now=datetime.now(timezone.utc) + if from_date is None and to_date is None: + from_date=_month_start(now) + to_date=_next_month_start(now) + elif from_date is None: + # open-ended lower bound: treat as same length as a calendar month ending at to_date + to_date=to_date if to_date.tzinfo else to_date.replace(tzinfo=timezone.utc) + from_date=_month_start(to_date) + elif to_date is None: + from_date=from_date if from_date.tzinfo else from_date.replace(tzinfo=timezone.utc) + to_date=_next_month_start(from_date) + else: + if from_date.tzinfo is None: + from_date=from_date.replace(tzinfo=timezone.utc) + if to_date.tzinfo is None: + to_date=to_date.replace(tzinfo=timezone.utc) + duration=to_date-from_date + prior_to=from_date + prior_from=from_date-duration + return from_date,to_date,prior_from,prior_to + + +def _month_key(dt): + """Normalize date_trunc / python month buckets for dict lookup.""" + if dt is None: + return None + if getattr(dt,"tzinfo",None) is None: + dt=dt.replace(tzinfo=timezone.utc) + else: + dt=dt.astimezone(timezone.utc) + return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) + + +def _days_expr(end_col,start_col): + return func.extract("epoch",end_col-start_col)/86400.0 + + +class Analytics: + def __init__(self,session:AsyncSession): + self.session=session + + async def _count_jobs(self,status,from_date=None,to_date=None,department=None,recruiter_id=None,*,closed_in_window=False): + statement=select(func.count()).select_from(JobPosts).where(JobPosts.is_deleted==False) # noqa: E712 + if status: + statement=statement.where(JobPosts.requisition_status==status) + if department: + statement=statement.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + statement=statement.where(JobPosts.current_recruiter_id==rid) + if closed_in_window: + if from_date is not None: + statement=statement.where(JobPosts.closed_at>=from_date) + if to_date is not None: + statement=statement.where(JobPosts.closed_at=as_of), + JobPosts.requisition_status=="open", + ) + if department: + statement=statement.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + statement=statement.where(JobPosts.current_recruiter_id==rid) + result=await self.session.execute(statement) + return int(result.scalar_one() or 0) + + async def _count_candidates(self,from_date=None,to_date=None,department=None,recruiter_id=None): + statement=( + select(func.count()) + .select_from(Inbox) + .join(Users,Inbox.user_id==Users.id) + .join(Roles,Users.role_id==Roles.id) + .where(Roles.role_name==EnumRoles.CANDIDATE.value) + ) + if from_date is not None: + statement=statement.where(Inbox.created_at>=from_date) + if to_date is not None: + statement=statement.where(Inbox.created_at=from_date) + if to_date is not None: + statement=statement.where(stamp=from_date) + if to_date is not None: + statement=statement.where(hired.valid_from=from_date) + if to_date is not None: + msg=msg.where(Inbox.created_at=from_date) + if to_date is not None: + statement=statement.where(hire.c.valid_from=from_date) + if to_date is not None: + statement=statement.where(JobPosts.closed_at=from_date) + if to_date is not None: + statement=statement.where(HiringCosts.incurred_at=today_start, + Interviews.interview_date=now, + ) + interviews_upcoming=int((await self.session.execute(upcoming_q)).scalar_one() or 0) + + next_q=select(func.min(func.coalesce(Interviews.interview_time,Interviews.interview_date))).where( + Interviews.interview_status.ilike("scheduled"), + Interviews.interview_date>=now, + ) + next_at=(await self.session.execute(next_q)).scalar_one() + next_interview_at=next_at.isoformat() if next_at else None + + offers_accepted=await self._count_offers(["accepted"],window_from,window_to,department,recruiter_id) + offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id) + offers_sent=await self._count_offers( + ["sent","negotiating","accepted","declined","expired"], + window_from,window_to,department,recruiter_id,exclude_draft=True, + ) + offers_sent_prior=await self._count_offers( + ["sent","negotiating","accepted","declined","expired"], + prior_from,prior_to,department,recruiter_id,exclude_draft=True, + ) + + hires=await self._count_hires(window_from,window_to,department,recruiter_id) + hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id) + + time_to_hire=await self._avg_time_to_hire(window_from,window_to,department,recruiter_id) + time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id) + time_to_fill=await self._avg_time_to_fill(window_from,window_to,department,recruiter_id) + time_to_fill_prior=await self._avg_time_to_fill(prior_from,prior_to,department,recruiter_id) + cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id) + cost_per_hire_prior=await self._cost_per_hire(hires_prior,prior_from,prior_to,department,recruiter_id) + + return { + "open_jobs": open_jobs, + "open_jobs_prior": open_jobs_prior, + "total_candidates": total_candidates, + "total_candidates_prior": total_candidates_prior, + "interviews_today": interviews_today, + "interviews_upcoming": interviews_upcoming, + "next_interview_at": next_interview_at, + "offers_accepted": offers_accepted, + "offers_accepted_prior": offers_accepted_prior, + "offers_sent": offers_sent, + "offers_sent_prior": offers_sent_prior, + "time_to_hire": time_to_hire, + "time_to_hire_prior": time_to_hire_prior, + "time_to_fill": time_to_fill, + "time_to_fill_prior": time_to_fill_prior, + "cost_per_hire": cost_per_hire, + "cost_per_hire_prior": cost_per_hire_prior, + "closed_jobs": closed_jobs, + "closed_jobs_prior": closed_jobs_prior, + "hires": hires, + "hires_prior": hires_prior, + } + + async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None): + statement=select( + Inbox_Messages.application_status, + func.count().label("count"), + ).select_from(Inbox_Messages) + if department or recruiter_id: + statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) + if department: + statement=statement.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + statement=statement.where( + or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) + ) + if from_date is not None or to_date is not None: + statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) + if from_date is not None: + statement=statement.where(Inbox.created_at>=from_date) + if to_date is not None: + statement=statement.where(Inbox.created_at=start) + .group_by(month_bucket) + .order_by(month_bucket) + ) + if department or recruiter_id: + apps_q=( + apps_q + .outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) + .outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) + ) + if department: + apps_q=apps_q.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + apps_q=apps_q.where( + or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) + ) + apps_rows=await self.session.execute(apps_q) + apps_map={} + for month,count in apps_rows.all(): + apps_map[_month_key(month)]=int(count or 0) + + hire_bucket=func.date_trunc("month",ApplicationStageTransitions.valid_from) + hires_q=( + select(hire_bucket.label("month"),func.count().label("count")) + .select_from(ApplicationStageTransitions) + .where( + ApplicationStageTransitions.to_stage==Candidate_application_Status.HIRED.value, + ApplicationStageTransitions.valid_from>=start, + ) + .group_by(hire_bucket) + .order_by(hire_bucket) + ) + if department or recruiter_id: + hires_q=( + hires_q + .outerjoin(Inbox,ApplicationStageTransitions.inbox_id==Inbox.id) + .outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) + .outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) + ) + if department: + hires_q=hires_q.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + hires_q=hires_q.where( + or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) + ) + hire_rows=await self.session.execute(hires_q) + hire_map={} + for month,count in hire_rows.all(): + hire_map[_month_key(month)]=int(count or 0) + + labels=[] + applications=[] + hires=[] + cursor=start + for _ in range(months): + labels.append(cursor.strftime("%b %Y")) + applications.append(apps_map.get(cursor,0)) + hires.append(hire_map.get(cursor,0)) + cursor=_next_month_start(cursor) + return {"labels": labels,"applications": applications,"hires": hires} + + async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None): + statement=( + select( + func.coalesce(SourceChannels.label,"Unknown").label("source"), + func.count().label("count"), + ) + .select_from(Inbox_Messages) + .outerjoin(SourceChannels,Inbox_Messages.source_channel_id==SourceChannels.id) + ) + if department or recruiter_id: + statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) + if department: + statement=statement.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + statement=statement.where( + or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) + ) + if from_date is not None or to_date is not None: + statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) + if from_date is not None: + statement=statement.where(Inbox.created_at>=from_date) + if to_date is not None: + statement=statement.where(Inbox.created_at=from_date) + if to_date is not None: + hires_q=hires_q.where(Inbox.created_at datetime: + return datetime.now(timezone.utc) + + class Inbox(SQLModel, table=True): __tablename__ = "inbox" @@ -42,8 +46,12 @@ class Inbox(SQLModel, table=True): message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id") messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox") - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + # tz-AWARE, matching every other timestamp the analytics layer filters on. + # A naive column here made asyncpg reject the aware UTC bounds that + # analytics/views.py builds, so /analytics/hiring-trend and /analytics/kpis + # both 500'd before the query ever reached Postgres. + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) favorite: Optional[bool] = Field(default=False) rating: Optional[float] = Field(default=0.0) @@ -132,6 +140,20 @@ class Inbox(SQLModel, table=True): result=await session.execute(select(cls).where(cls.id==iid)) return result.scalars().first() + @classmethod + async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None): + """Inbox row with `messages` selectin-loaded for stage / application writers.""" + if record_id is None: + return None + try: + iid=int(record_id) + except (TypeError,ValueError): + return None + result=await session.execute( + select(cls).options(selectinload(cls.messages)).where(cls.id==iid) + ) + return result.scalars().first() + @classmethod async def get_inbox_by_message_id(cls,session:AsyncSession,message_id): try: @@ -161,7 +183,7 @@ class Inbox(SQLModel, table=True): return None for key,value in fields.items(): setattr(row,key,value) - row.updated_at=datetime.now() + row.updated_at=_now() session.add(row) await session.commit() await session.refresh(row) @@ -175,7 +197,7 @@ class Inbox_Alerts(SQLModel, table=True): alert_sender_name: str alert_sender_email: str is_read: bool = Field(default=False) - recieve_time: datetime = Field(default_factory=datetime.now) + recieve_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) inbox: list[Inbox] = Relationship(back_populates="alerts") @@ -214,7 +236,15 @@ class Inbox_Messages(SQLModel, table=True): matched_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx") candidate_education: str | None = Field(default=None) - current_employment: str | None = Field(default=None) + current_employment: str | None = Field(default=None) + # Denormalised dashboard / list-screen fields. server_default is load-bearing + # for every NOT NULL column — these arrive as ALTERs on a populated table. + ats_score: float | None = Field(default=None) + ats_band: str = Field(default="", sa_column_kwargs={"server_default": ""}) + recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + is_duplicate: bool = Field(default=False, sa_column_kwargs={"server_default": "false"}) + source_channel_id: int | None = Field(default=None, foreign_key="source_channels.id") + processing_state: str = Field(default="unread", sa_column_kwargs={"server_default": "unread"}) inbox: list[Inbox] = Relationship(back_populates="messages") @staticmethod @@ -533,3 +563,74 @@ class Inbox_Messages(SQLModel, table=True): await session.commit() await session.refresh(row) return row + + +class SourceChannels(SQLModel, table=True): + __tablename__ = "source_channels" + + id: int | None = Field(default=None, primary_key=True) + key: str = Field(max_length=40, unique=True, index=True) + label: str + is_active: bool = Field(default=True) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @classmethod + async def get_by_id(cls, session: AsyncSession, record_id: int): + result = await session.execute(select(cls).where(cls.id == record_id)) + return result.scalars().first() + + @classmethod + async def get_by_key(cls, session: AsyncSession, key: str): + result = await session.execute(select(cls).where(cls.key == key)) + return result.scalars().first() + + @classmethod + async def list_active(cls, session: AsyncSession): + result = await session.execute( + select(cls).where(cls.is_active == True).order_by(cls.id.asc()) # noqa: E712 + ) + return list(result.scalars().all()) + + +class AtsResults(SQLModel, table=True): + __tablename__ = "ats_results" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + inbox_id: int = Field(index=True, foreign_key="inbox.id") + job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") + overall_score: float = Field(default=0.0) + band: str = Field(default="") + is_current: bool = Field(default=True) + superseded_by_id: uuid.UUID | None = Field(default=None, foreign_key="ats_results.id") + model_name: str | None = Field(default=None) + # default_factory was datetime.now: a naive LOCAL value bound to a timestamptz + # column, which asyncpg reads as UTC. That silently backdated every row by the + # host's offset (+5 h here) instead of raising, unlike the naive-column case. + computed_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_current_for_inbox(cls, session: AsyncSession, inbox_id: int): + result = await session.execute( + select(cls) + .where(cls.inbox_id == int(inbox_id), cls.is_current == True) # noqa: E712 + .order_by(cls.computed_at.desc()) + ) + return result.scalars().first() + + @classmethod + async def insert_result(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return row diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index b8cd123..8310bab 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -104,10 +104,13 @@ def serialize_application(message: Inbox_Messages) -> dict: "match_status": message.match_status, "match_error": message.match_error, "matched_at": message.matched_at.isoformat() if message.matched_at else None, - "ats_score": None, + "ats_score": message.ats_score, + "ats_band": message.ats_band or None, "phone": message.candidate_phone_number, "experience": message.experience or "", "current_employment": message.current_employment or "", - "recruiter": None, - "duplicate": None, + "recruiter": str(message.recruiter_id) if message.recruiter_id else None, + "duplicate": message.is_duplicate, + "processing_state": message.processing_state, + "source_channel_id": message.source_channel_id, } diff --git a/backend/inbox/tasks.py b/backend/inbox/tasks.py index ae48a0b..04b4480 100644 --- a/backend/inbox/tasks.py +++ b/backend/inbox/tasks.py @@ -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 [], diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 0b87e4d..873d88d 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -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): diff --git a/backend/job/activity/views.py b/backend/job/activity/views.py index 7339692..34acd64 100644 --- a/backend/job/activity/views.py +++ b/backend/job/activity/views.py @@ -28,16 +28,32 @@ class ActivityLog: return row return None - async def get_activity(self,activity_id=None,inbox_id=None): + async def get_activity(self,activity_id=None,inbox_id=None,top=None,skip=0): if activity_id: row=await Activity.get_activity_by_id(self.session,activity_id) if not row: raise HTTPException(status_code=404,detail="Activity not found") return serialize_activity(row) - if inbox_id is None: - raise HTTPException(status_code=400,detail="activity_id or inbox_id is required") - rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id)) - return [serialize_activity(r) for r in rows] + if inbox_id is not None: + rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id)) + return [serialize_activity(r) for r in rows] + if top is not None: + return await self.get_activity_feed(top=top,skip=skip) + raise HTTPException(status_code=400,detail="activity_id or inbox_id is required") + + async def get_activity_feed(self,top,skip=0): + rows,total=await Activity.get_activity_feed(self.session,top=top,skip=skip) + items=[] + for r in rows: + data=serialize_activity(r) + actor_name=None + if r.inbox_id is not None: + inbox=await Inbox.get_inbox_by_id(self.session,r.inbox_id) + if inbox and getattr(inbox,"user",None): + actor_name=inbox.user.name + data["actor_name"]=actor_name + items.append(data) + return items,total async def create_activity(self,payload): link=await self._resolve_inbox(payload) diff --git a/backend/job/app.py b/backend/job/app.py index 4568129..a1b23c8 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -2,14 +2,19 @@ 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 from job.feedback.views import FeedbackView +from job.pipeline.views import Pipeline +from job.assignment.views import Assignment +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 @@ -84,6 +89,32 @@ class FeedbackUpdate(BaseModel): reviewed_by: UUID | None = None +class StageChange(BaseModel): + inbox_id: int + to_stage: str + change_reason: str | None = None + + +class JobAssignmentCreate(BaseModel): + job_post_id: UUID + user_id: UUID + assignment_role: str | None = None + + +class ApplicationAssignmentCreate(BaseModel): + inbox_id: int + user_id: UUID + assignment_role: str | None = None + + +class HiringCostCreate(BaseModel): + cost_type: str + amount: float + job_post_id: UUID | None = None + currency: str | None = None + description: str | None = None + incurred_at: datetime | None = None + @router.get("/jobs/alias") async def get_job_alias(): @@ -224,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( @@ -232,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: @@ -251,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), @@ -294,13 +406,29 @@ async def update_candidate( async def fetch_interview( interview_id:str=Query(None), inbox_id:int=Query(None), + from_date:datetime=Query(None), + to_date:datetime=Query(None), + status:str=Query(None), + top:int=Query(None), + skip:int=Query(0,ge=0), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), session: AsyncSession = Depends(get_session), ): try: service=Interview(session=session) - data=await service.get_interview(interview_id=interview_id,inbox_id=inbox_id) - total=1 if isinstance(data,dict) else len(data) + if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or top is not None): + data,total=await service.get_interviews_range( + from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + ) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + data=await service.get_interview( + interview_id=interview_id,inbox_id=inbox_id, + from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + ) + if isinstance(data,tuple): + data,total=data + else: + total=1 if isinstance(data,dict) else len(data) return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: raise @@ -396,13 +524,21 @@ async def update_note( async def fetch_activity( activity_id:str=Query(None), inbox_id:int=Query(None), + top:int=Query(None), + skip:int=Query(0,ge=0), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), session: AsyncSession = Depends(get_session), ): try: service=ActivityLog(session=session) - data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id) - total=1 if isinstance(data,dict) else len(data) + if not activity_id and inbox_id is None and top is not None: + data,total=await service.get_activity_feed(top=top,skip=skip) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id,top=top,skip=skip) + if isinstance(data,tuple): + data,total=data + else: + total=1 if isinstance(data,dict) else len(data) return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: raise @@ -475,3 +611,141 @@ async def update_feedback( raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/candidate/stage") +async def change_candidate_stage( + payload:StageChange, + current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Pipeline(session=session) + data=await service.change_stage( + payload.inbox_id,payload.to_stage,current_user,change_reason=payload.change_reason, + ) + 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("/pipeline/transitions/fetch") +async def fetch_pipeline_transitions( + transition_id:str=Query(None), + inbox_id:int=Query(None), + current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Pipeline(session=session) + data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id) + total=1 if isinstance(data,dict) else len(data) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/job/assignments/fetch") +async def fetch_job_assignments( + job_post_id:str=Query(...), + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Assignment(session=session) + data=await service.list_job_assignments(job_post_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.post("/job/assignments/create") +async def create_job_assignment( + payload:JobAssignmentCreate, + current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Assignment(session=session) + data=await service.create_job_assignment(payload.model_dump(exclude_unset=True),current_user) + 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/assignments/fetch") +async def fetch_application_assignments( + inbox_id:int=Query(...), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Assignment(session=session) + data=await service.list_application_assignments(inbox_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.post("/candidate/assignments/create") +async def create_application_assignment( + payload:ApplicationAssignmentCreate, + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Assignment(session=session) + data=await service.create_application_assignment(payload.model_dump(exclude_unset=True),current_user) + 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("/job/costs/fetch") +async def fetch_hiring_costs( + job_post_id:str=Query(None), + from_date:datetime=Query(None), + to_date:datetime=Query(None), + top:int=Query(None), + skip:int=Query(0,ge=0), + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=HiringCost(session=session) + data,total=await service.list_costs( + job_post_id=job_post_id,from_date=from_date,to_date=to_date,top=top,skip=skip, + ) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/job/costs/create") +async def create_hiring_cost( + payload:HiringCostCreate, + current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=HiringCost(session=session) + data=await service.create_cost(payload.model_dump(exclude_unset=True),current_user) + 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)) diff --git a/backend/job/assignment/models.py b/backend/job/assignment/models.py new file mode 100644 index 0000000..21fa7b3 --- /dev/null +++ b/backend/job/assignment/models.py @@ -0,0 +1,120 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class JobAssignments(SQLModel, table=True): + __tablename__ = "job_assignments" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + job_post_id: uuid.UUID = Field(index=True, foreign_key="job_posts.id") + user_id: uuid.UUID = Field(foreign_key="users.id") + assignment_role: str = Field(default="primary_recruiter") + valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + assigned_by: uuid.UUID = Field(foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_by_id(cls, session: AsyncSession, record_id): + 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 fetch_by_job(cls, session: AsyncSession, job_post_id, *, current_only: bool = True): + uid = cls._as_uuid(job_post_id) + if uid is None: + return [] + statement = select(cls).where(cls.job_post_id == uid) + if current_only: + statement = statement.where(cls.valid_to.is_(None)) + statement = statement.order_by(cls.valid_from.desc()) + result = await session.execute(statement) + return list(result.scalars().all()) + + @classmethod + async def insert_assignment(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_by_id(session, row.id) + + @classmethod + async def count_open_reqs_by_user(cls, session: AsyncSession, user_id): + uid = cls._as_uuid(user_id) + if uid is None: + return 0 + statement = ( + select(func.count()) + .select_from(cls) + .where(cls.user_id == uid, cls.valid_to.is_(None)) + ) + result = await session.execute(statement) + return result.scalar_one() + + +class ApplicationAssignments(SQLModel, table=True): + __tablename__ = "application_assignments" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + inbox_id: int = Field(index=True, foreign_key="inbox.id") + user_id: uuid.UUID = Field(foreign_key="users.id") + assignment_role: str = Field(default="primary_recruiter") + valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + assigned_by: uuid.UUID = Field(foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_by_id(cls, session: AsyncSession, record_id): + 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 fetch_by_inbox(cls, session: AsyncSession, inbox_id: int, *, current_only: bool = True): + statement = select(cls).where(cls.inbox_id == int(inbox_id)) + if current_only: + statement = statement.where(cls.valid_to.is_(None)) + statement = statement.order_by(cls.valid_from.desc()) + result = await session.execute(statement) + return list(result.scalars().all()) + + @classmethod + async def insert_assignment(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_by_id(session, row.id) + +import users.models as _users_models # noqa: E402, F401 diff --git a/backend/job/assignment/serializers.py b/backend/job/assignment/serializers.py new file mode 100644 index 0000000..c1f22fc --- /dev/null +++ b/backend/job/assignment/serializers.py @@ -0,0 +1,24 @@ +def serialize_job_assignment(row) -> dict: + return { + "id": str(row.id), + "job_post_id": str(row.job_post_id) if row.job_post_id else None, + "user_id": str(row.user_id) if row.user_id else None, + "assignment_role": row.assignment_role, + "valid_from": row.valid_from.isoformat() if row.valid_from else None, + "valid_to": row.valid_to.isoformat() if row.valid_to else None, + "assigned_by": str(row.assigned_by) if row.assigned_by else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + +def serialize_application_assignment(row) -> dict: + return { + "id": str(row.id), + "inbox_id": row.inbox_id, + "user_id": str(row.user_id) if row.user_id else None, + "assignment_role": row.assignment_role, + "valid_from": row.valid_from.isoformat() if row.valid_from else None, + "valid_to": row.valid_to.isoformat() if row.valid_to else None, + "assigned_by": str(row.assigned_by) if row.assigned_by else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + } diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py new file mode 100644 index 0000000..b5be521 --- /dev/null +++ b/backend/job/assignment/views.py @@ -0,0 +1,69 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from job.assignment.models import ApplicationAssignments, JobAssignments +from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment +from role.models import EnumRoles, Roles +from users.models import Users + + +class Assignment: + def __init__(self,session:AsyncSession): + self.session=session + + async def _require_recruiter(self,user_id): + role=await Roles.get_role_by_name(self.session,EnumRoles.RECRUITER.value) + user=await Users.get_user_by_id(self.session,user_id) + if not role or not user or user.role_id!=role.id: + raise HTTPException(status_code=422,detail="user_id must be a recruiter") + return user + + async def list_job_assignments(self,job_post_id): + if not job_post_id: + raise HTTPException(status_code=400,detail="job_post_id is required") + rows=await JobAssignments.fetch_by_job(self.session,job_post_id) + return [serialize_job_assignment(r) for r in rows] + + async def create_job_assignment(self,payload,current_user): + user_id=payload.get("user_id") + job_post_id=payload.get("job_post_id") + if not user_id or not job_post_id: + raise HTTPException(status_code=422,detail="user_id and job_post_id are required") + await self._require_recruiter(user_id) + fields={ + "job_post_id":JobAssignments._as_uuid(job_post_id), + "user_id":JobAssignments._as_uuid(user_id), + "assignment_role":payload.get("assignment_role") or "primary_recruiter", + "assigned_by":JobAssignments._as_uuid( + current_user.get("id") if isinstance(current_user,dict) else None + ), + } + if not fields["job_post_id"] or not fields["user_id"] or not fields["assigned_by"]: + raise HTTPException(status_code=422,detail="Invalid job_post_id, user_id, or assigned_by") + row=await JobAssignments.insert_assignment(self.session,fields) + return serialize_job_assignment(row) + + async def list_application_assignments(self,inbox_id): + if inbox_id is None: + raise HTTPException(status_code=400,detail="inbox_id is required") + rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id)) + return [serialize_application_assignment(r) for r in rows] + + async def create_application_assignment(self,payload,current_user): + user_id=payload.get("user_id") + inbox_id=payload.get("inbox_id") + if not user_id or inbox_id is None: + raise HTTPException(status_code=422,detail="user_id and inbox_id are required") + await self._require_recruiter(user_id) + fields={ + "inbox_id":int(inbox_id), + "user_id":ApplicationAssignments._as_uuid(user_id), + "assignment_role":payload.get("assignment_role") or "primary_recruiter", + "assigned_by":ApplicationAssignments._as_uuid( + current_user.get("id") if isinstance(current_user,dict) else None + ), + } + if not fields["user_id"] or not fields["assigned_by"]: + raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by") + row=await ApplicationAssignments.insert_assignment(self.session,fields) + return serialize_application_assignment(row) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index c41a97e..2fffa91 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -2,7 +2,8 @@ import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import DateTime +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" @@ -148,6 +263,34 @@ class Interviews(SQLModel, table=True): ) return result.scalars().all() + @classmethod + async def get_interviews_in_range( + cls, + session: AsyncSession, + *, + from_date=None, + to_date=None, + status: str | None = None, + top: int | None = None, + skip: int = 0, + ): + statement = select(cls) + if from_date is not None: + statement = statement.where(cls.interview_date >= from_date) + if to_date is not None: + statement = statement.where(cls.interview_date < to_date) + if status: + statement = statement.where(cls.interview_status == status) + count_statement = select(func.count()).select_from(statement.subquery()) + total = (await session.execute(count_statement)).scalar_one() + statement = statement.order_by(cls.interview_date.asc()) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return list(result.scalars().all()), total + @classmethod async def insert_interview(cls, session: AsyncSession, fields: dict): row = cls(**fields) @@ -269,6 +412,19 @@ class Activity(SQLModel, table=True): ) return result.scalars().all() + @classmethod + async def get_activity_feed(cls, session: AsyncSession, *, top: int | None = None, skip: int = 0): + statement = select(cls) + count_statement = select(func.count()).select_from(cls) + total = (await session.execute(count_statement)).scalar_one() + statement = statement.order_by(cls.activity_date.desc(), cls.activity_time.desc()) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return list(result.scalars().all()), total + @classmethod async def insert_activity(cls, session: AsyncSession, fields: dict): row = cls(**fields) @@ -353,4 +509,85 @@ class Feedback(SQLModel, table=True): return row +class ApplicationStageTransitions(SQLModel, table=True): + """Temporal history of inbox_messages.application_status changes. + + valid_from / valid_to make time-in-stage a subtraction rather than a window + function. NULL valid_to means the stage is still current. + """ + + __tablename__ = "application_stage_transitions" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + inbox_id: int = Field(index=True, foreign_key="inbox.id") + from_stage: str | None = Field(default=None) + to_stage: str + valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + actor_kind: str = Field(default="user") + change_reason: str | None = Field(default=None) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_by_id(cls, session: AsyncSession, record_id): + 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 fetch_by_inbox(cls, session: AsyncSession, inbox_id: int): + result = await session.execute( + select(cls).where(cls.inbox_id == int(inbox_id)).order_by(cls.valid_from.desc()) + ) + return list(result.scalars().all()) + + @classmethod + async def get_open_transition(cls, session: AsyncSession, inbox_id: int): + result = await session.execute( + select(cls) + .where(cls.inbox_id == int(inbox_id), cls.valid_to.is_(None)) + .order_by(cls.valid_from.desc()) + ) + return result.scalars().first() + + @classmethod + async def insert_transition(cls, session: AsyncSession, fields: dict, *, commit: bool = True): + row = cls(**fields) + session.add(row) + if commit: + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def close_open(cls, session: AsyncSession, inbox_id: int, *, at: datetime | None = None, commit: bool = False): + row = await cls.get_open_transition(session, inbox_id) + if not row: + return None + row.valid_to = at or _now() + session.add(row) + if commit: + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def count_by_inbox(cls, session: AsyncSession, inbox_id: int): + statement = select(func.count()).select_from(cls).where(cls.inbox_id == int(inbox_id)) + result = await session.execute(statement) + return result.scalar_one() + + import users.models as _users_models # noqa: E402, F401 diff --git a/backend/job/candidate/plugins.py b/backend/job/candidate/plugins.py index 5f0863b..2b68fac 100644 --- a/backend/job/candidate/plugins.py +++ b/backend/job/candidate/plugins.py @@ -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 diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 267c00a..da0651a 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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 { diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 4e9b87f..7d36fb5 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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 diff --git a/backend/job/cost/models.py b/backend/job/cost/models.py new file mode 100644 index 0000000..7b97482 --- /dev/null +++ b/backend/job/cost/models.py @@ -0,0 +1,91 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class HiringCosts(SQLModel, table=True): + __tablename__ = "hiring_costs" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") + cost_type: str = Field(default="other") + amount: float = Field(default=0.0) + currency: str = Field(default="USD") + incurred_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + description: str | None = Field(default=None) + 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: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_by_id(cls, session: AsyncSession, record_id): + 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 fetch_costs( + cls, + session: AsyncSession, + *, + job_post_id=None, + from_date=None, + to_date=None, + top: int | None = None, + skip: int = 0, + ): + statement = select(cls) + if job_post_id is not None: + uid = cls._as_uuid(job_post_id) + if uid is not None: + statement = statement.where(cls.job_post_id == uid) + if from_date is not None: + statement = statement.where(cls.incurred_at >= from_date) + if to_date is not None: + statement = statement.where(cls.incurred_at < to_date) + count_statement = select(func.count()).select_from(statement.subquery()) + total = (await session.execute(count_statement)).scalar_one() + statement = statement.order_by(cls.incurred_at.desc()) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return list(result.scalars().all()), total + + @classmethod + async def insert_cost(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_by_id(session, row.id) + + @classmethod + async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None): + statement = select(func.coalesce(func.sum(cls.amount), 0.0)) + if from_date is not None: + statement = statement.where(cls.incurred_at >= from_date) + if to_date is not None: + statement = statement.where(cls.incurred_at < to_date) + result = await session.execute(statement) + return float(result.scalar_one() or 0.0) + +import users.models as _users_models # noqa: E402, F401 diff --git a/backend/job/cost/serializers.py b/backend/job/cost/serializers.py new file mode 100644 index 0000000..4975f16 --- /dev/null +++ b/backend/job/cost/serializers.py @@ -0,0 +1,13 @@ +def serialize_hiring_cost(row) -> dict: + return { + "id": str(row.id), + "job_post_id": str(row.job_post_id) if row.job_post_id else None, + "cost_type": row.cost_type, + "amount": row.amount, + "currency": row.currency, + "incurred_at": row.incurred_at.isoformat() if row.incurred_at else None, + "description": row.description, + "created_by": str(row.created_by) if row.created_by else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } diff --git a/backend/job/cost/views.py b/backend/job/cost/views.py new file mode 100644 index 0000000..41b16e1 --- /dev/null +++ b/backend/job/cost/views.py @@ -0,0 +1,44 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from job.cost.models import HiringCosts +from job.cost.serializers import serialize_hiring_cost + + +class HiringCost: + def __init__(self,session:AsyncSession): + self.session=session + + async def list_costs(self,job_post_id=None,from_date=None,to_date=None,top=None,skip=0): + rows,total=await HiringCosts.fetch_costs( + self.session, + job_post_id=job_post_id, + from_date=from_date, + to_date=to_date, + top=top, + skip=skip, + ) + return [serialize_hiring_cost(r) for r in rows],total + + async def create_cost(self,payload,current_user): + cost_type=payload.get("cost_type") + amount=payload.get("amount") + if not cost_type or amount is None: + raise HTTPException(status_code=422,detail="cost_type and amount are required") + created_by=HiringCosts._as_uuid( + current_user.get("id") if isinstance(current_user,dict) else None + ) + if not created_by: + raise HTTPException(status_code=422,detail="created_by is required") + fields={ + "job_post_id":HiringCosts._as_uuid(payload.get("job_post_id")), + "cost_type":cost_type, + "amount":float(amount), + "currency":payload.get("currency") or "USD", + "description":payload.get("description"), + "created_by":created_by, + } + if payload.get("incurred_at") is not None: + fields["incurred_at"]=payload["incurred_at"] + row=await HiringCosts.insert_cost(self.session,fields) + return serialize_hiring_cost(row) diff --git a/backend/job/interviews/serializers.py b/backend/job/interviews/serializers.py index eb319a9..7cab1b9 100644 --- a/backend/job/interviews/serializers.py +++ b/backend/job/interviews/serializers.py @@ -1,4 +1,6 @@ def serialize_interview(row) -> dict: + inbox=getattr(row,"inbox",None) + user=getattr(inbox,"user",None) if inbox else None return { "id": str(row.id), "inbox_id": row.inbox_id, @@ -6,4 +8,5 @@ def serialize_interview(row) -> dict: "interview_time": row.interview_time.isoformat() if row.interview_time else None, "interview_type": row.interview_type, "interview_status": row.interview_status, + "candidate_name": user.name if user else None, } diff --git a/backend/job/interviews/views.py b/backend/job/interviews/views.py index 6956da4..bb2feec 100644 --- a/backend/job/interviews/views.py +++ b/backend/job/interviews/views.py @@ -9,16 +9,31 @@ class Interview: def __init__(self,session:AsyncSession): self.session=session - async def get_interview(self,interview_id=None,inbox_id=None): + async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,top=None,skip=0): if interview_id: row=await Interviews.get_interview_by_id(self.session,interview_id) if not row: raise HTTPException(status_code=404,detail="Interview not found") return serialize_interview(row) - if inbox_id is None: - raise HTTPException(status_code=400,detail="interview_id or inbox_id is required") - rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id)) - return [serialize_interview(r) for r in rows] + if inbox_id is not None: + rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id)) + return [serialize_interview(r) for r in rows] + if from_date is not None or to_date is not None or status is not None or top is not None: + return await self.get_interviews_range( + from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + ) + raise HTTPException(status_code=400,detail="interview_id or inbox_id is required") + + async def get_interviews_range(self,from_date=None,to_date=None,status=None,top=None,skip=0): + rows,total=await Interviews.get_interviews_in_range( + self.session, + from_date=from_date, + to_date=to_date, + status=status, + top=top, + skip=skip, + ) + return [serialize_interview(r) for r in rows],total async def create_interview(self,payload): fields={ diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 2a32b89..b688739 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -20,9 +20,15 @@ class JobPosts(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) title: str = Field(index=True) + # foreign_keys is required, not decoration: current_recruiter_id below is a + # SECOND foreign key into users.id, so the join condition is ambiguous without + # it and every mapper fails to initialize. `user` is the AUTHOR of the post — + # current_recruiter_id is deliberately a bare column with no relationship of + # its own, because Users already carries five selectin relations that load on + # every authenticated request. Same pairing as Notes.user / Notes.author. user: Optional["Users"] = Relationship( back_populates="job_posts", - sa_relationship_kwargs={"lazy": "joined"}, + sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"}, ) platform: str = Field(default="linkedin") @@ -43,6 +49,14 @@ class JobPosts(SQLModel, table=True): buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) status: str = Field(default="draft") buffer_error: str | None = Field(default=None) + # requisition_status is the hiring lifecycle (open/closed/on_hold). Distinct from + # `status`, which tracks Buffer publishing (draft/scheduled/published/failed). + # server_default is load-bearing: this column arrives as an ALTER on a populated table. + requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"}) + department: str = Field(default="", sa_column_kwargs={"server_default": ""}) + vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) + closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") 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)) diff --git a/backend/job/pipeline/serializers.py b/backend/job/pipeline/serializers.py new file mode 100644 index 0000000..030bb84 --- /dev/null +++ b/backend/job/pipeline/serializers.py @@ -0,0 +1,13 @@ +def serialize_stage_transition(row) -> dict: + return { + "id": str(row.id), + "inbox_id": row.inbox_id, + "from_stage": row.from_stage, + "to_stage": row.to_stage, + "valid_from": row.valid_from.isoformat() if row.valid_from else None, + "valid_to": row.valid_to.isoformat() if row.valid_to else None, + "changed_by": str(row.changed_by) if row.changed_by else None, + "actor_kind": row.actor_kind, + "change_reason": row.change_reason, + "created_at": row.created_at.isoformat() if row.created_at else None, + } diff --git a/backend/job/pipeline/views.py b/backend/job/pipeline/views.py new file mode 100644 index 0000000..28a9e3e --- /dev/null +++ b/backend/job/pipeline/views.py @@ -0,0 +1,64 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from inbox.enums import Candidate_application_Status +from inbox.models import Inbox +from job.candidate.models import ApplicationStageTransitions +from job.pipeline.serializers import serialize_stage_transition + + +class Pipeline: + def __init__(self,session:AsyncSession): + self.session=session + + async def get_transitions(self,inbox_id=None,transition_id=None): + if transition_id: + row=await ApplicationStageTransitions.get_by_id(self.session,transition_id) + if not row: + raise HTTPException(status_code=404,detail="Transition not found") + return serialize_stage_transition(row) + if inbox_id is None: + raise HTTPException(status_code=400,detail="transition_id or inbox_id is required") + rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id)) + return [serialize_stage_transition(r) for r in rows] + + async def change_stage(self,inbox_id,to_stage,current_user,change_reason=None): + inbox=await Inbox.get_inbox_with_message(self.session,inbox_id) + if not inbox: + raise HTTPException(status_code=404,detail="Inbox not found") + message=inbox.messages + if not message: + raise HTTPException(status_code=404,detail="Inbox message not found") + try: + stage=Candidate_application_Status(to_stage) + except ValueError: + raise HTTPException(status_code=422,detail="Invalid to_stage") + current=message.application_status + from_stage=current.value if isinstance(current,Candidate_application_Status) else str(current) + if from_stage==stage.value: + raise HTTPException(status_code=400,detail="already at stage") + changed_by=None + if isinstance(current_user,dict) and current_user.get("id"): + changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id")) + await ApplicationStageTransitions.close_open(self.session,inbox.id,commit=False) + transition_data={ + "inbox_id":inbox.id, + "from_stage":from_stage, + "to_stage":stage.value, + "changed_by":changed_by, + "actor_kind":"user", + "change_reason":change_reason, + } + transition=await ApplicationStageTransitions.insert_transition( + self.session, + transition_data, + commit=False, + ) + message.application_status=stage + self.session.add(message) + await self.session.commit() + return { + "inbox_id":inbox.id, + "application_status":stage.value, + "transition":serialize_stage_transition(transition), + } diff --git a/backend/main.py b/backend/main.py index af4b7b1..1755f98 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,8 @@ from role.app import router as role_router from forget_password.app import router as forget_password_router from job.app import router as candidate_router from notifications.app import router as confirmation_router +from analytics.app import router as analytics_router +from offer.app import router as offer_router logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s") logger=logging.getLogger("main") @@ -79,3 +81,5 @@ app.include_router(role_router) app.include_router(forget_password_router) app.include_router(confirmation_router) app.include_router(candidate_router) +app.include_router(analytics_router) +app.include_router(offer_router) diff --git a/backend/migrations/manual/001_dashboard_rbac_and_enum.sql b/backend/migrations/manual/001_dashboard_rbac_and_enum.sql new file mode 100644 index 0000000..05764fe --- /dev/null +++ b/backend/migrations/manual/001_dashboard_rbac_and_enum.sql @@ -0,0 +1,242 @@ +-- 001_dashboard_rbac_and_enum.sql +-- Manual one-shot: enum labels, permission tags, analytics_dashboard bundle, +-- source channels, and backfills. Run in psql against the app DB. +-- ADD VALUE cannot run inside a transaction that also uses the new labels — +-- run section 1a with autocommit (psql default outside BEGIN). +-- +-- Order: (1) alembic upgrade for new tables/columns, (2) this file. +-- Section 1a (enum) can run before or after alembic. + +-- ============================================================================= +-- 1a. Extend candidate_application_status (unqualified — matches original migration) +-- ============================================================================= +ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'SCREENING'; +ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'ASSESSMENT'; +ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'INTERVIEW'; +ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'OFFER'; +ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'HIRED'; + +-- ============================================================================= +-- 1b. Seed all 104 permission tags (module x action) idempotently +-- ============================================================================= +INSERT INTO app.permission_tags + (tag_name, module, action, description, created_at, updated_at, is_active, is_deleted) +VALUES +('dashboard.view', 'dashboard', 'view', NULL, NOW(), NOW(), true, false), + ('dashboard.create', 'dashboard', 'create', NULL, NOW(), NOW(), true, false), + ('dashboard.edit', 'dashboard', 'edit', NULL, NOW(), NOW(), true, false), + ('dashboard.delete', 'dashboard', 'delete', NULL, NOW(), NOW(), true, false), + ('dashboard.approve', 'dashboard', 'approve', NULL, NOW(), NOW(), true, false), + ('dashboard.export', 'dashboard', 'export', NULL, NOW(), NOW(), true, false), + ('dashboard.manage', 'dashboard', 'manage', NULL, NOW(), NOW(), true, false), + ('dashboard.configure', 'dashboard', 'configure', NULL, NOW(), NOW(), true, false), + ('inbox.view', 'inbox', 'view', NULL, NOW(), NOW(), true, false), + ('inbox.create', 'inbox', 'create', NULL, NOW(), NOW(), true, false), + ('inbox.edit', 'inbox', 'edit', NULL, NOW(), NOW(), true, false), + ('inbox.delete', 'inbox', 'delete', NULL, NOW(), NOW(), true, false), + ('inbox.approve', 'inbox', 'approve', NULL, NOW(), NOW(), true, false), + ('inbox.export', 'inbox', 'export', NULL, NOW(), NOW(), true, false), + ('inbox.manage', 'inbox', 'manage', NULL, NOW(), NOW(), true, false), + ('inbox.configure', 'inbox', 'configure', NULL, NOW(), NOW(), true, false), + ('jobs.view', 'jobs', 'view', NULL, NOW(), NOW(), true, false), + ('jobs.create', 'jobs', 'create', NULL, NOW(), NOW(), true, false), + ('jobs.edit', 'jobs', 'edit', NULL, NOW(), NOW(), true, false), + ('jobs.delete', 'jobs', 'delete', NULL, NOW(), NOW(), true, false), + ('jobs.approve', 'jobs', 'approve', NULL, NOW(), NOW(), true, false), + ('jobs.export', 'jobs', 'export', NULL, NOW(), NOW(), true, false), + ('jobs.manage', 'jobs', 'manage', NULL, NOW(), NOW(), true, false), + ('jobs.configure', 'jobs', 'configure', NULL, NOW(), NOW(), true, false), + ('candidates.view', 'candidates', 'view', NULL, NOW(), NOW(), true, false), + ('candidates.create', 'candidates', 'create', NULL, NOW(), NOW(), true, false), + ('candidates.edit', 'candidates', 'edit', NULL, NOW(), NOW(), true, false), + ('candidates.delete', 'candidates', 'delete', NULL, NOW(), NOW(), true, false), + ('candidates.approve', 'candidates', 'approve', NULL, NOW(), NOW(), true, false), + ('candidates.export', 'candidates', 'export', NULL, NOW(), NOW(), true, false), + ('candidates.manage', 'candidates', 'manage', NULL, NOW(), NOW(), true, false), + ('candidates.configure', 'candidates', 'configure', NULL, NOW(), NOW(), true, false), + ('pipeline.view', 'pipeline', 'view', NULL, NOW(), NOW(), true, false), + ('pipeline.create', 'pipeline', 'create', NULL, NOW(), NOW(), true, false), + ('pipeline.edit', 'pipeline', 'edit', NULL, NOW(), NOW(), true, false), + ('pipeline.delete', 'pipeline', 'delete', NULL, NOW(), NOW(), true, false), + ('pipeline.approve', 'pipeline', 'approve', NULL, NOW(), NOW(), true, false), + ('pipeline.export', 'pipeline', 'export', NULL, NOW(), NOW(), true, false), + ('pipeline.manage', 'pipeline', 'manage', NULL, NOW(), NOW(), true, false), + ('pipeline.configure', 'pipeline', 'configure', NULL, NOW(), NOW(), true, false), + ('interviews.view', 'interviews', 'view', NULL, NOW(), NOW(), true, false), + ('interviews.create', 'interviews', 'create', NULL, NOW(), NOW(), true, false), + ('interviews.edit', 'interviews', 'edit', NULL, NOW(), NOW(), true, false), + ('interviews.delete', 'interviews', 'delete', NULL, NOW(), NOW(), true, false), + ('interviews.approve', 'interviews', 'approve', NULL, NOW(), NOW(), true, false), + ('interviews.export', 'interviews', 'export', NULL, NOW(), NOW(), true, false), + ('interviews.manage', 'interviews', 'manage', NULL, NOW(), NOW(), true, false), + ('interviews.configure', 'interviews', 'configure', NULL, NOW(), NOW(), true, false), + ('assessments.view', 'assessments', 'view', NULL, NOW(), NOW(), true, false), + ('assessments.create', 'assessments', 'create', NULL, NOW(), NOW(), true, false), + ('assessments.edit', 'assessments', 'edit', NULL, NOW(), NOW(), true, false), + ('assessments.delete', 'assessments', 'delete', NULL, NOW(), NOW(), true, false), + ('assessments.approve', 'assessments', 'approve', NULL, NOW(), NOW(), true, false), + ('assessments.export', 'assessments', 'export', NULL, NOW(), NOW(), true, false), + ('assessments.manage', 'assessments', 'manage', NULL, NOW(), NOW(), true, false), + ('assessments.configure', 'assessments', 'configure', NULL, NOW(), NOW(), true, false), + ('offers.view', 'offers', 'view', NULL, NOW(), NOW(), true, false), + ('offers.create', 'offers', 'create', NULL, NOW(), NOW(), true, false), + ('offers.edit', 'offers', 'edit', NULL, NOW(), NOW(), true, false), + ('offers.delete', 'offers', 'delete', NULL, NOW(), NOW(), true, false), + ('offers.approve', 'offers', 'approve', NULL, NOW(), NOW(), true, false), + ('offers.export', 'offers', 'export', NULL, NOW(), NOW(), true, false), + ('offers.manage', 'offers', 'manage', NULL, NOW(), NOW(), true, false), + ('offers.configure', 'offers', 'configure', NULL, NOW(), NOW(), true, false), + ('reports.view', 'reports', 'view', NULL, NOW(), NOW(), true, false), + ('reports.create', 'reports', 'create', NULL, NOW(), NOW(), true, false), + ('reports.edit', 'reports', 'edit', NULL, NOW(), NOW(), true, false), + ('reports.delete', 'reports', 'delete', NULL, NOW(), NOW(), true, false), + ('reports.approve', 'reports', 'approve', NULL, NOW(), NOW(), true, false), + ('reports.export', 'reports', 'export', NULL, NOW(), NOW(), true, false), + ('reports.manage', 'reports', 'manage', NULL, NOW(), NOW(), true, false), + ('reports.configure', 'reports', 'configure', NULL, NOW(), NOW(), true, false), + ('analytics.view', 'analytics', 'view', NULL, NOW(), NOW(), true, false), + ('analytics.create', 'analytics', 'create', NULL, NOW(), NOW(), true, false), + ('analytics.edit', 'analytics', 'edit', NULL, NOW(), NOW(), true, false), + ('analytics.delete', 'analytics', 'delete', NULL, NOW(), NOW(), true, false), + ('analytics.approve', 'analytics', 'approve', NULL, NOW(), NOW(), true, false), + ('analytics.export', 'analytics', 'export', NULL, NOW(), NOW(), true, false), + ('analytics.manage', 'analytics', 'manage', NULL, NOW(), NOW(), true, false), + ('analytics.configure', 'analytics', 'configure', NULL, NOW(), NOW(), true, false), + ('job_board.view', 'job_board', 'view', NULL, NOW(), NOW(), true, false), + ('job_board.create', 'job_board', 'create', NULL, NOW(), NOW(), true, false), + ('job_board.edit', 'job_board', 'edit', NULL, NOW(), NOW(), true, false), + ('job_board.delete', 'job_board', 'delete', NULL, NOW(), NOW(), true, false), + ('job_board.approve', 'job_board', 'approve', NULL, NOW(), NOW(), true, false), + ('job_board.export', 'job_board', 'export', NULL, NOW(), NOW(), true, false), + ('job_board.manage', 'job_board', 'manage', NULL, NOW(), NOW(), true, false), + ('job_board.configure', 'job_board', 'configure', NULL, NOW(), NOW(), true, false), + ('settings.view', 'settings', 'view', NULL, NOW(), NOW(), true, false), + ('settings.create', 'settings', 'create', NULL, NOW(), NOW(), true, false), + ('settings.edit', 'settings', 'edit', NULL, NOW(), NOW(), true, false), + ('settings.delete', 'settings', 'delete', NULL, NOW(), NOW(), true, false), + ('settings.approve', 'settings', 'approve', NULL, NOW(), NOW(), true, false), + ('settings.export', 'settings', 'export', NULL, NOW(), NOW(), true, false), + ('settings.manage', 'settings', 'manage', NULL, NOW(), NOW(), true, false), + ('settings.configure', 'settings', 'configure', NULL, NOW(), NOW(), true, false), + ('rbac_users.view', 'rbac_users', 'view', NULL, NOW(), NOW(), true, false), + ('rbac_users.create', 'rbac_users', 'create', NULL, NOW(), NOW(), true, false), + ('rbac_users.edit', 'rbac_users', 'edit', NULL, NOW(), NOW(), true, false), + ('rbac_users.delete', 'rbac_users', 'delete', NULL, NOW(), NOW(), true, false), + ('rbac_users.approve', 'rbac_users', 'approve', NULL, NOW(), NOW(), true, false), + ('rbac_users.export', 'rbac_users', 'export', NULL, NOW(), NOW(), true, false), + ('rbac_users.manage', 'rbac_users', 'manage', NULL, NOW(), NOW(), true, false), + ('rbac_users.configure', 'rbac_users', 'configure', NULL, NOW(), NOW(), true, false) +ON CONFLICT (tag_name) DO NOTHING; + +-- Bundle holding dashboard / analytics / offers / interviews.view tags +INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) +SELECT + 'analytics_dashboard', + 'Dashboard KPI tiles, analytics charts, offers, and interview list', + ( + SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) + FROM app.permission_tags + WHERE is_deleted = false + AND ( + module IN ('dashboard', 'analytics', 'offers') + OR tag_name = 'interviews.view' + ) + ), + true, + NOW(), + NOW(), + true, + false +WHERE NOT EXISTS ( + SELECT 1 FROM app.permissions WHERE name = 'analytics_dashboard' +); + +-- Append the bundle id to the named system roles (idempotent) +UPDATE app.roles r +SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id), + updated_at = NOW() +FROM app.permissions p +WHERE p.name = 'analytics_dashboard' + AND r.role_name IN ( + 'system_administrator', + 'hr_administrator', + 'recruiter', + 'hiring_manager', + 'department_head', + 'ceo' + ) + AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id)); + +-- ============================================================================= +-- 1c. Source channels (eleven BRD channels) +-- ============================================================================= +INSERT INTO app.source_channels (key, label, is_active, created_at, updated_at) +VALUES + ('microsoft_outlook', 'Microsoft Outlook', true, NOW(), NOW()), + ('career_portal', 'Career Portal', true, NOW(), NOW()), + ('manual_cv_upload', 'Manual CV Upload', true, NOW(), NOW()), + ('linkedin', 'LinkedIn', true, NOW(), NOW()), + ('indeed', 'Indeed', true, NOW(), NOW()), + ('rozee', 'Rozee', true, NOW(), NOW()), + ('mustakbil', 'Mustakbil', true, NOW(), NOW()), + ('employee_referral', 'Employee Referral', true, NOW(), NOW()), + ('recruitment_agency', 'Recruitment Agency', true, NOW(), NOW()), + ('campus_hiring', 'Campus Hiring', true, NOW(), NOW()), + ('walk_in', 'Walk-in', true, NOW(), NOW()) +ON CONFLICT (key) DO NOTHING; + +-- ============================================================================= +-- Backfills (require new columns/tables from alembic) +-- ============================================================================= + +-- Source channel from message_to board tags; default Microsoft Outlook +UPDATE app.inbox_messages m +SET source_channel_id = sc.id +FROM app.source_channels sc +WHERE m.source_channel_id IS NULL + AND ( + (LOWER(m.message_to) LIKE '%linkedin%' AND sc.key = 'linkedin') + OR (LOWER(m.message_to) LIKE '%indeed%' AND sc.key = 'indeed') + OR (LOWER(m.message_to) LIKE '%rozee%' AND sc.key = 'rozee') + OR (LOWER(m.message_to) LIKE '%mustakbil%' AND sc.key = 'mustakbil') + OR (LOWER(m.message_to) LIKE '%referral%' AND sc.key = 'employee_referral') + OR (LOWER(m.message_to) LIKE '%agency%' AND sc.key = 'recruitment_agency') + OR (LOWER(m.message_to) LIKE '%campus%' AND sc.key = 'campus_hiring') + OR (LOWER(m.message_to) LIKE '%portal%' AND sc.key = 'career_portal') + OR (LOWER(m.message_to) LIKE '%walk%' AND sc.key = 'walk_in') + OR (LOWER(m.message_to) LIKE '%manual%' AND sc.key = 'manual_cv_upload') + ); + +UPDATE app.inbox_messages m +SET source_channel_id = sc.id +FROM app.source_channels sc +WHERE m.source_channel_id IS NULL + AND sc.key = 'microsoft_outlook'; + +-- One open stage-transition row per application (cannot invent history) +INSERT INTO app.application_stage_transitions + (id, inbox_id, from_stage, to_stage, valid_from, valid_to, changed_by, actor_kind, change_reason, created_at) +SELECT + gen_random_uuid(), + i.id, + NULL, + m.application_status::text, + COALESCE(i.created_at, NOW()), + NULL, + NULL, + 'system', + 'backfill', + NOW() +FROM app.inbox i +JOIN app.inbox_messages m ON m.id = i.message_id +WHERE NOT EXISTS ( + SELECT 1 FROM app.application_stage_transitions t WHERE t.inbox_id = i.id +); + +-- Requisition status from is_active / is_deleted +UPDATE app.job_posts +SET requisition_status = CASE + WHEN is_active AND NOT is_deleted THEN 'open' + ELSE 'closed' +END +WHERE requisition_status IS NULL OR requisition_status = ''; diff --git a/backend/offer/app.py b/backend/offer/app.py new file mode 100644 index 0000000..6bc8fff --- /dev/null +++ b/backend/offer/app.py @@ -0,0 +1,125 @@ +from datetime import datetime +from fastapi import APIRouter,Depends,Query +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from offer.views import Offer +from users.permissions import PermissionTag,require_permission +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +class OfferCreate(BaseModel): + inbox_id: int + job_post_id: str + candidate_user_id: str + status: str | None = "draft" + base_salary: float | None = None + currency: str | None = None + salary_period: str | None = None + signing_bonus: float | None = None + annual_bonus_pct: float | None = None + equity_units: int | None = None + equity_instrument: str | None = None + start_date: datetime | None = None + expiry_date: datetime | None = None + change_reason: str | None = None + + +class OfferUpdate(BaseModel): + status: str | None = None + base_salary: float | None = None + currency: str | None = None + salary_period: str | None = None + signing_bonus: float | None = None + annual_bonus_pct: float | None = None + equity_units: int | None = None + equity_instrument: str | None = None + start_date: datetime | None = None + expiry_date: datetime | None = None + sent_at: datetime | None = None + responded_at: datetime | None = None + closed_at: datetime | None = None + issued_by: str | None = None + inbox_id: int | None = None + job_post_id: str | None = None + candidate_user_id: str | None = None + change_reason: str | None = None + + +class OfferIssue(BaseModel): + change_reason: str | None = None + + +@router.get("/offers/fetch") +async def fetch_offers( + current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)), + offer_id: str | None = Query(None), + status: str | None = Query(None), + inbox_id: int | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0,ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service=Offer(session=session) + data,total=await service.get_offers(offer_id,status,inbox_id,top,skip) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/offers/create") +async def create_offer( + payload: OfferCreate, + current_user: dict = Depends(require_permission(PermissionTag.OFFERS_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service=Offer(session=session) + data=await service.create_offer(payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/offers/update") +async def update_offer( + payload: OfferUpdate, + current_user: dict = Depends(require_permission(PermissionTag.OFFERS_EDIT)), + offer_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service=Offer(session=session) + data=await service.update_offer(offer_id,payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/offers/issue") +async def issue_offer( + current_user: dict = Depends(require_permission(PermissionTag.OFFERS_APPROVE)), + offer_id: str = Query(...), + payload: OfferIssue | None = None, + session: AsyncSession = Depends(get_session), +): + try: + service=Offer(session=session) + data=await service.issue_offer(offer_id,current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/offer/models.py b/backend/offer/models.py new file mode 100644 index 0000000..d65707e --- /dev/null +++ b/backend/offer/models.py @@ -0,0 +1,152 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Offers(SQLModel, table=True): + __tablename__ = "offers" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + inbox_id: int = Field(index=True, foreign_key="inbox.id") + job_post_id: uuid.UUID = Field(foreign_key="job_posts.id") + candidate_user_id: uuid.UUID = Field(foreign_key="users.id") + status: str = Field(default="draft") + base_salary: float = Field(default=0.0) + currency: str = Field(default="USD") + salary_period: str = Field(default="annual") + signing_bonus: float | None = Field(default=None) + annual_bonus_pct: float | None = Field(default=None) + equity_units: int | None = Field(default=None) + equity_instrument: str | None = Field(default=None) + start_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + expiry_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + responded_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + issued_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + 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: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_offer_by_id(cls, session: AsyncSession, record_id): + 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 fetch_offers( + cls, + session: AsyncSession, + *, + status: str | None = None, + inbox_id: int | None = None, + top: int | None = None, + skip: int = 0, + ): + statement = select(cls) + if status: + statement = statement.where(cls.status == status) + if inbox_id is not None: + statement = statement.where(cls.inbox_id == int(inbox_id)) + count_statement = select(func.count()).select_from(statement.subquery()) + total = (await session.execute(count_statement)).scalar_one() + statement = statement.order_by(cls.created_at.desc()) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return list(result.scalars().all()), total + + @classmethod + async def insert_offer(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_offer_by_id(session, row.id) + + @classmethod + async def update_offer(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_offer_by_id(session, record_id) + if not row: + return None + for key, value in fields.items(): + setattr(row, key, value) + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def count_by_status(cls, session: AsyncSession, status: str, *, from_date=None, to_date=None): + statement = select(func.count()).select_from(cls).where(cls.status == status) + if from_date is not None: + statement = statement.where(cls.created_at >= from_date) + if to_date is not None: + statement = statement.where(cls.created_at < to_date) + result = await session.execute(statement) + return result.scalar_one() + + +class OfferStatusHistory(SQLModel, table=True): + __tablename__ = "offer_status_history" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + offer_id: uuid.UUID = Field(index=True, foreign_key="offers.id") + from_status: str | None = Field(default=None) + to_status: str + valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + actor_kind: str = Field(default="user") + change_reason: str | None = Field(default=None) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_by_offer(cls, session: AsyncSession, offer_id): + uid = cls._as_uuid(offer_id) + if uid is None: + return [] + result = await session.execute( + select(cls).where(cls.offer_id == uid).order_by(cls.valid_from.desc()) + ) + return list(result.scalars().all()) + + @classmethod + async def insert_history(cls, session: AsyncSession, fields: dict, *, commit: bool = True): + row = cls(**fields) + session.add(row) + if commit: + await session.commit() + await session.refresh(row) + return row + +import users.models as _users_models # noqa: E402, F401 diff --git a/backend/offer/plugins.py b/backend/offer/plugins.py new file mode 100644 index 0000000..8f210ac --- /dev/null +++ b/backend/offer/plugins.py @@ -0,0 +1,4 @@ +def non_validation_values(): + fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct", + "equity_units","equity_instrument","start_date","expiry_date") + return fields \ No newline at end of file diff --git a/backend/offer/serializers.py b/backend/offer/serializers.py new file mode 100644 index 0000000..b1b0660 --- /dev/null +++ b/backend/offer/serializers.py @@ -0,0 +1,38 @@ +def serialize_offer(row) -> dict: + return { + "id": str(row.id) if row.id else None, + "inbox_id": row.inbox_id, + "job_post_id": str(row.job_post_id) if row.job_post_id else None, + "candidate_user_id": str(row.candidate_user_id) if row.candidate_user_id else None, + "status": row.status, + "base_salary": row.base_salary, + "currency": row.currency, + "salary_period": row.salary_period, + "signing_bonus": row.signing_bonus, + "annual_bonus_pct": row.annual_bonus_pct, + "equity_units": row.equity_units, + "equity_instrument": row.equity_instrument, + "start_date": row.start_date.isoformat() if row.start_date else None, + "expiry_date": row.expiry_date.isoformat() if row.expiry_date else None, + "sent_at": row.sent_at.isoformat() if row.sent_at else None, + "responded_at": row.responded_at.isoformat() if row.responded_at else None, + "closed_at": row.closed_at.isoformat() if row.closed_at else None, + "issued_by": str(row.issued_by) if row.issued_by else None, + "created_by": str(row.created_by) if row.created_by else None, + "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_offer_history(row) -> dict: + return { + "id": str(row.id) if row.id else None, + "offer_id": str(row.offer_id) if row.offer_id else None, + "from_status": row.from_status, + "to_status": row.to_status, + "valid_from": row.valid_from.isoformat() if row.valid_from else None, + "valid_to": row.valid_to.isoformat() if row.valid_to else None, + "changed_by": str(row.changed_by) if row.changed_by else None, + "actor_kind": row.actor_kind, + "change_reason": row.change_reason, + } diff --git a/backend/offer/views.py b/backend/offer/views.py new file mode 100644 index 0000000..9595ae0 --- /dev/null +++ b/backend/offer/views.py @@ -0,0 +1,146 @@ +import uuid +from datetime import datetime,timezone + +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from offer.models import Offers,OfferStatusHistory +from offer.serializers import serialize_offer +from offer.plugins import non_validation_values + +def _as_uuid(value): + if value in (None,""): + return None + try: + return uuid.UUID(str(value)) + except (TypeError,ValueError): + return None + + +def _user_id(current_user): + if not current_user or not current_user.get("id"): + raise HTTPException(status_code=401,detail="Not authenticated") + uid=_as_uuid(current_user["id"]) + if uid is None: + raise HTTPException(status_code=401,detail="Invalid user id") + return uid + + +class Offer: + def __init__(self,session:AsyncSession): + self.session=session + + async def get_offers(self,offer_id=None,status=None,inbox_id=None,top=None,skip=0): + if offer_id is not None: + row=await Offers.get_offer_by_id(self.session,offer_id) + if not row: + raise HTTPException(status_code=404,detail="Offer not found") + return serialize_offer(row),1 + rows,total=await Offers.fetch_offers( + self.session, + status=status, + inbox_id=inbox_id, + top=top, + skip=skip or 0, + ) + return [serialize_offer(r) for r in rows],total + + async def create_offer(self,payload,current_user): + if not payload.get("inbox_id"): + raise HTTPException(status_code=422,detail="inbox_id is required") + job_post_id=_as_uuid(payload.get("job_post_id")) + if job_post_id is None: + raise HTTPException(status_code=422,detail="job_post_id is required") + candidate_user_id=_as_uuid(payload.get("candidate_user_id")) + if candidate_user_id is None: + raise HTTPException(status_code=422,detail="candidate_user_id is required") + created_by=_user_id(current_user) + status=payload.get("status") or "draft" + + fields={ + "inbox_id": int(payload["inbox_id"]), + "job_post_id": job_post_id, + "candidate_user_id": candidate_user_id, + "created_by": created_by, + "status": status, + } + for key in non_validation_values(): + if key in payload and payload[key] is not None: + fields[key]=payload[key] + + row=await Offers.insert_offer(self.session,fields) + history_data={ + "offer_id": row.id, + "from_status": None, + "to_status": status, + "changed_by": created_by, + "actor_kind": "user", + "change_reason": payload.get("change_reason"), + } + await OfferStatusHistory.insert_history(self.session,history_data) + return serialize_offer(row) + + async def update_offer(self,offer_id,payload,current_user): + row=await Offers.get_offer_by_id(self.session,offer_id) + if not row: + raise HTTPException(status_code=404,detail="Offer not found") + changed_by=_user_id(current_user) + fields={} + for key in ( + "status","base_salary","currency","salary_period","signing_bonus","annual_bonus_pct", + "equity_units","equity_instrument","start_date","expiry_date","sent_at", + "responded_at","closed_at","issued_by","inbox_id","job_post_id","candidate_user_id", + ): + if key not in payload: + continue + value=payload[key] + if key in ("job_post_id","candidate_user_id","issued_by") and value is not None: + value=_as_uuid(value) + if value is None: + raise HTTPException(status_code=422,detail=f"Invalid {key}") + fields[key]=value + + if not fields: + raise HTTPException(status_code=400,detail="No fields to update") + + new_status=fields.get("status") + if new_status is not None and new_status!=row.status: + await OfferStatusHistory.insert_history(self.session,{ + "offer_id": row.id, + "from_status": row.status, + "to_status": new_status, + "changed_by": changed_by, + "actor_kind": "user", + "change_reason": payload.get("change_reason"), + },commit=False) + + updated=await Offers.update_offer(self.session,offer_id,fields) + if not updated: + raise HTTPException(status_code=404,detail="Offer not found") + return serialize_offer(updated) + + async def issue_offer(self,offer_id,current_user): + row=await Offers.get_offer_by_id(self.session,offer_id) + if not row: + raise HTTPException(status_code=404,detail="Offer not found") + issued_by=_user_id(current_user) + now=datetime.now(timezone.utc) + from_status=row.status + fields={"issued_by": issued_by,"sent_at": now} + to_status=from_status + if from_status=="draft": + fields["status"]="sent" + to_status="sent" + updated=await Offers.update_offer(self.session,offer_id,fields) + if not updated: + raise HTTPException(status_code=404,detail="Offer not found") + history_data={ + "offer_id": updated.id, + "from_status": from_status, + "to_status": to_status, + "changed_by": issued_by, + "actor_kind": "user", + "change_reason": "issued", + } + await OfferStatusHistory.insert_history(self.session,history_data) + return serialize_offer(updated) diff --git a/backend/requirements.txt b/backend/requirements.txt index 63ad39f..dfb3ac5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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. diff --git a/backend/role/models.py b/backend/role/models.py index e30298a..2d06382 100644 --- a/backend/role/models.py +++ b/backend/role/models.py @@ -1,11 +1,15 @@ -from datetime import datetime +from datetime import datetime, timezone from enum import Enum -from sqlalchemy import Column, UniqueConstraint, func, or_ +from sqlalchemy import Column, DateTime, UniqueConstraint, func, or_ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, Relationship, SQLModel, select +def _now() -> datetime: + return datetime.now(timezone.utc) + + class EnumRoles(str, Enum): """Canonical keys for the eight seeded system roles. `Roles.role_name` is a varchar.""" @@ -30,8 +34,8 @@ class PermissionTags(SQLModel, table=True): module: str = Field(max_length=32, nullable=False, index=True) action: str = Field(max_length=32, nullable=False) description: str | None = Field(default=None) - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) is_active: bool = Field(default=True) is_deleted: bool = Field(default=False) @@ -104,8 +108,8 @@ class Permissions(SQLModel, table=True): description: str | None = Field(default=None) permission_tags: list | None = Field(default=None, sa_column=Column(JSONB)) is_system: bool = Field(default=False) - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) is_active: bool = Field(default=True) is_deleted: bool = Field(default=False) @@ -182,7 +186,7 @@ class Permissions(SQLModel, table=True): return None for key, value in fields.items(): setattr(row, key, value) - row.updated_at = datetime.now() + row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) @@ -195,7 +199,7 @@ class Permissions(SQLModel, table=True): return None row.is_deleted = True row.is_active = False - row.updated_at = datetime.now() + row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) @@ -210,8 +214,8 @@ class Roles(SQLModel, table=True): description: str | None = Field(default=None) permissions: list | None = Field(default=None, sa_column=Column(JSONB)) is_system: bool = Field(default=False) - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) is_active: bool = Field(default=True) is_deleted: bool = Field(default=False) @@ -280,7 +284,7 @@ class Roles(SQLModel, table=True): return None for key, value in fields.items(): setattr(row, key, value) - row.updated_at = datetime.now() + row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) @@ -293,7 +297,7 @@ class Roles(SQLModel, table=True): return None row.is_deleted = True row.is_active = False - row.updated_at = datetime.now() + row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) diff --git a/backend/users/models.py b/backend/users/models.py index d79f1f3..e6cb580 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,8 +1,8 @@ import uuid -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING,List,Optional -from sqlalchemy import func, or_ +from sqlalchemy import DateTime, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select @@ -14,6 +14,11 @@ if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this modul from inbox.models import Inbox from job.candidate.models import Feedback, Notes + +def _now() -> datetime: + return datetime.now(timezone.utc) + + class Users(SQLModel, table=True): __tablename__ = "users" @@ -27,9 +32,11 @@ class Users(SQLModel, table=True): # selectin, not joined: this is a one-to-many, so a joined load would repeat the # user row once per post. Without an explicit strategy the default is a lazy load, # which raises MissingGreenlet the moment anything touches it under asyncio. + # foreign_keys must match the other side: job_posts.current_recruiter_id is a + # second FK into this table, so this relation has to say it means created_by. job_posts: List[JobPosts] = Relationship( back_populates="user", - sa_relationship_kwargs={"lazy": "selectin"}, + sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"}, ) inbox: List["Inbox"] = Relationship( back_populates="user", @@ -50,8 +57,8 @@ class Users(SQLModel, table=True): password: str - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) is_active: bool = Field(default=False) is_deleted: bool = Field(default=False) @@ -140,7 +147,7 @@ class Users(SQLModel, table=True): return None for key, value in fields.items(): setattr(user, key, value) - user.updated_at = datetime.now() + user.updated_at = _now() session.add(user) await session.commit() await session.refresh(user) @@ -153,7 +160,7 @@ class Users(SQLModel, table=True): return None user.is_deleted = True user.is_active = False - user.updated_at = datetime.now() + user.updated_at = _now() session.add(user) await session.commit() await session.refresh(user) diff --git a/frontend/.vite/deps/_metadata.json b/frontend/.vite/deps/_metadata.json new file mode 100644 index 0000000..ecb3ea6 --- /dev/null +++ b/frontend/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "4acff9bb", + "configHash": "53a8a5ec", + "lockfileHash": "fac4afd8", + "browserHash": "5b5a9255", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/frontend/.vite/deps/package.json b/frontend/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/frontend/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/frontend/src/api/activity.js b/frontend/src/api/activity.js new file mode 100644 index 0000000..55cf996 --- /dev/null +++ b/frontend/src/api/activity.js @@ -0,0 +1,14 @@ +import { request } from '../lib/apiClient' + +/** + * Activity feed — backend/job/app.py `/activity/fetch` global mode (top/skip). + * Permissioned with CANDIDATES_VIEW. + */ + +export function feed({ top = 8, skip = 0 } = {}) { + return request('/activity/fetch', { params: { top, skip } }) +} + +export function listByInbox(inboxId) { + return request('/activity/fetch', { params: { inbox_id: inboxId } }) +} diff --git a/frontend/src/api/analytics.js b/frontend/src/api/analytics.js new file mode 100644 index 0000000..870677c --- /dev/null +++ b/frontend/src/api/analytics.js @@ -0,0 +1,63 @@ +import { request } from '../lib/apiClient' + +/** + * Dashboard analytics aggregates — backend/analytics/app.py. + * Permissioned with require_permission(ANALYTICS_VIEW). + */ + +export function kpis({ fromDate, toDate, department, recruiterId } = {}) { + return request('/analytics/kpis/fetch', { + params: { + from_date: fromDate, + to_date: toDate, + department, + recruiter_id: recruiterId, + }, + }) +} + +export function funnel({ fromDate, toDate, department, recruiterId } = {}) { + return request('/analytics/funnel/fetch', { + params: { + from_date: fromDate, + to_date: toDate, + department, + recruiter_id: recruiterId, + }, + }) +} + +export function hiringTrend({ months = 7, fromDate, toDate, department, recruiterId } = {}) { + return request('/analytics/hiring-trend/fetch', { + params: { + months, + from_date: fromDate, + to_date: toDate, + department, + recruiter_id: recruiterId, + }, + }) +} + +export function sourcePerformance({ fromDate, toDate, department, recruiterId } = {}) { + return request('/analytics/source-performance/fetch', { + params: { + from_date: fromDate, + to_date: toDate, + department, + recruiter_id: recruiterId, + }, + }) +} + +export function recruiterPerformance({ top = 5, fromDate, toDate, department, recruiterId } = {}) { + return request('/analytics/recruiter-performance/fetch', { + params: { + top, + from_date: fromDate, + to_date: toDate, + department, + recruiter_id: recruiterId, + }, + }) +} diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 1d6767f..26183ab 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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). diff --git a/frontend/src/api/interviews.js b/frontend/src/api/interviews.js new file mode 100644 index 0000000..98eef70 --- /dev/null +++ b/frontend/src/api/interviews.js @@ -0,0 +1,23 @@ +import { request } from '../lib/apiClient' + +/** + * Interviews — backend/job/app.py `/interview/*`. + * Range mode (from_date / to_date / status / top) is additive; per-inbox + * fetch still works when inbox_id is set. + */ + +export function listRange({ fromDate, toDate, status, top, skip } = {}) { + return request('/interview/fetch', { + params: { + from_date: fromDate, + to_date: toDate, + status, + top, + skip, + }, + }) +} + +export function listByInbox(inboxId) { + return request('/interview/fetch', { params: { inbox_id: inboxId } }) +} diff --git a/frontend/src/api/offers.js b/frontend/src/api/offers.js new file mode 100644 index 0000000..c5c499b --- /dev/null +++ b/frontend/src/api/offers.js @@ -0,0 +1,38 @@ +import { request } from '../lib/apiClient' + +/** + * Offers — backend/offer/app.py. + * Permissioned with OFFERS_VIEW / OFFERS_CREATE / OFFERS_EDIT / OFFERS_APPROVE. + */ + +export function list({ offerId, status, inboxId, top, skip } = {}) { + return request('/offers/fetch', { + params: { + offer_id: offerId, + status, + inbox_id: inboxId, + top, + skip, + }, + }) +} + +export function create(body) { + return request('/offers/create', { method: 'POST', body }) +} + +export function update(offerId, body) { + return request('/offers/update', { + method: 'PATCH', + params: { offer_id: offerId }, + body, + }) +} + +export function issue(offerId, body = {}) { + return request('/offers/issue', { + method: 'POST', + params: { offer_id: offerId }, + body, + }) +} diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 007f409..044a2bc 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -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,26 @@ 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], + funnel: (p = {}) => ['analytics', 'funnel', p], + trend: (p = {}) => ['analytics', 'trend', p], + sources: (p = {}) => ['analytics', 'sources', p], + recruiters: (p = {}) => ['analytics', 'recruiters', p], + }, + offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] }, + interviews: { all: () => ['interviews'], range: (p = {}) => ['interviews', 'range', p] }, + activity: { all: () => ['activity'], feed: (p = {}) => ['activity', 'feed', p] }, // --- seed-backed buckets --- // These are not "server state" — the cache IS the store for them, so every diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index a741dc6..2ba242e 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -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 > {favorite ? 'Favorited' : 'Favorite'} + {canScoreAts && ( + + )} @@ -194,7 +213,7 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
- +
AI Match
diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index df68061..2e18ea3 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -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 , 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 (
@@ -261,12 +221,12 @@ export default function Candidates() {

- + @@ -278,25 +238,12 @@ export default function Candidates() { Recently viewed: {recentChips.map((c) => ( ))}
)} - {selected.size > 0 && ( -
- - {selected.size} selected -
- - - - - -
- )} -
@@ -322,156 +269,150 @@ export default function Candidates() { className="filter-panel" style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }} > - setFilter('job', v)} any="Any Job" options={jobTitles} /> - setFilter('skill', v)} any="Any Skill" options={skillsPool} /> - setFilter('dept', v)} any="Any Dept" options={departments} /> - setFilter('location', v)} any="Any Location" options={locations} /> - setFilter('exp', v)} any="Any Exp" options={EXP_BUCKETS} /> - setFilter('edu', v)} any="Any" options={educationLevels} /> - setFilter('recruiter', v)} any="Any Recruiter" options={recruiters.map((r) => r.name)} /> - setFilter('manager', v)} any="Any Manager" options={managers.map((m) => m.name)} /> - setFilter('source', v)} any="Any Source" options={sources} /> + setFilter('job', v)} any="Any Job" options={jobOptions} /> + setFilter('skill', v)} any="Any Skill" options={skillOptions} /> + setFilter('source', v)} any="Any Source" options={['upload', 'inbox']} labels={SOURCE_LABEL} /> setFilter('ats', v)} any="Any Score" options={ATS_BANDS} /> - setFilter('stage', v)} any="Any Stage" options={stages} /> - setFilter('interview', v)} any="Any" options={INTERVIEW_STATES} /> - setFilter('notice', v)} any="Any" options={NOTICE} /> - setFilter('availability', v)} any="Any" options={AVAILABILITY} /> + setFilter('status', v)} any="Any Status" options={['Scored', 'Failed']} />
)}
-
-
- - - - {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 ( - - ) - })} - - - - {t.pageRows.length === 0 ? ( - - ) : ( - t.pageRows.map((c) => ( - - + + + + + + + + + + )) + )} + +
t.toggleSort(c.key) : undefined} - > - {c.label} - {c.sortable && ( - {isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'} - )} -
- 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 && ( +
+ Fetching candidates from the server. +
+ )} + {candidatesQuery.isError && ( +
+ + {friendlyAuthError(candidatesQuery.error, 'Request failed')} + +
+ )} + + {candidatesQuery.isSuccess && ( +
+
+ + + + {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 ( + - - - - - - - + + + {t.pageRows.length === 0 ? ( + + - )) - )} - -
t.toggleSort(c.key) : undefined} > - - - - -
- -
-
- {c.name}{' '} - {c.favorite && ( - - )} -
-
{c.currentTitle} · {c.location}
-
-
-
-
{c.jobTitle}
-
{c.department}
-
{c.experience}y - - {relevance(c)}% - - {c.stage} - setAtsFor(c)}> - - - - {c.availability} -
{c.noticePeriod} notice
-
-
- - - - -
+ {c.label} + {c.sortable && ( + {isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'} + )} + + ) + })} +
+ + Score resumes in CV Import to fill this table. +
+ ) : ( + t.pageRows.map((c) => ( +
+
+ +
+
{c.name}
+
+ {c.currentTitle ?? c.filename} + {c.currentCompany ? ` · ${c.currentCompany}` : ''} +
+
+
+
+
{jobTitleOf(c)}
+
{SOURCE_LABEL[c.source] ?? c.source}
+
+ {c.experience != null ? <>{c.experience}y : '—'} + + {c.scoringStatus === 'completed' ? ( + + {relevance(c)}% + + ) : '—'} + + {c.scoringStatus === 'completed' + ? Scored + : {c.errorCode ?? 'Failed'}} + + {c.aiScore != null ? ( + openAts(c)}> + + + ) : '—'} + + + {c.applied ? c.applied.toLocaleDateString() : '—'} + + +
+ + +
+
+
+
- -
+ )}
- {atsFor && setAtsFor(null)} onProfile={(c) => { setAtsFor(null); openProfile(c) }} />} + {atsFor && ( + setAtsFor(null)} + onProfile={(c) => { setAtsFor(null); openProfile(c) }} + /> + )} {profileFor && ( c.id === profileFor.id) ?? profileFor} + jobTitle={jobTitleOf(profileFor)} onClose={() => setProfileFor(null)} - onAdvance={advance} - onToggleFav={toggleFav} - onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }} - /> - )} - - {bulkAssigning && ( - 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 && ( 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 (
) } /** 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 }) => ( -
- {label} -
- {val}% -
- ) - return (
- +
-
{c.recommendation}
-
{c.name} for {c.jobTitle}
+
{recommendation}
+
{c.name}{jobTitle ? ` for ${jobTitle}` : ''}
@@ -542,12 +475,8 @@ export function AtsMatch({ candidate: c, onClose, onProfile }) {
- - - - - - +
Assessment
+

{c.critique ?? '—'}

@@ -575,37 +504,14 @@ export function AtsMatch({ candidate: c, onClose, onProfile }) {

- Score computed from JD keywords, resume parsing, experience, - education, location and salary alignment. Connect an AI model to refine with semantic matching. + 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.

) } -function BulkAssign({ count, recruiters, onClose, onSave }) { - const [name, setName] = useState(recruiters[0]?.name ?? '') - return ( - - - - - } - > -
- - -
-
- ) -} - /** * 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) diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index f0938c4..e188e4f 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -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 (

CV Import

-

Upload resumes — we parse, score, match, and dedupe automatically

+

Upload resume PDFs — parsed, scored against a job, and saved automatically

- AI Resume Parser · Ready + AI Resume Scoring · Live
@@ -171,45 +137,59 @@ export default function CvImport() {
+
+ Score against + +
+ {jobsQuery.isError && ( +

+ {friendlyAuthError(jobsQuery.error, 'Could not load job posts')} +

+ )} +
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) }} > + { handleFiles(e.target.files); e.target.value = '' }} + />

Drag & drop resumes here

- 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

- {['PDF', 'DOC', 'DOCX', 'ZIP'].map((t) => ( - {t} - ))} + PDF + DOC / DOCX support coming later
-
- - - - Files are processed locally in this demo - -
@@ -219,12 +199,11 @@ export default function CvImport() {

Processing Queue

- {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}` : ''}
-
{queue.map((i) => ( @@ -233,46 +212,39 @@ export default function CvImport() {
{i.name} - {i.duplicate && ( - - DUPLICATE - - )}
{i.file} · {i.size}
- {i.status === 'Uploading' || i.status === 'Parsing' ? ( + {i.status === 'Scoring' && (
-
-
- ) : ( -
- Best match: {i.job?.title} +
)} + {i.status === 'Ready' && i.critique && ( +
{i.critique}
+ )} + {i.status === 'Failed' && ( +
Could not be scored
+ )}
- {i.status === 'Ready' ? ( - - ) : ( - - {i.status}{i.status === 'Uploading' ? ` ${i.progress}%` : ''} - - )} + {i.status === 'Ready' && } + {i.status === 'Scoring' && Scoring…} + {i.status === 'Failed' && {i.error}}
- {i.imported ? ( - Imported - ) : i.status === 'Ready' ? ( - - ) : ( - - )} + {i.status === 'Ready' && Saved}
))}
)} + + {queue.length === 0 && jobsQuery.isSuccess && jobs.length === 0 && ( + + Create a job post first — resumes are always scored against a job. + + )}
@@ -290,44 +262,6 @@ export default function CvImport() {
- - {duplicateFor && ( - setDuplicateFor(null)} - footer={ - <> - - - - - } - > -
- - - -
-

A similar candidate already exists

-

- {duplicateFor.name} matches an existing profile (95% similarity on name + email). - Importing will create a duplicate. -

-
-
-
- )} ) } diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index 56c3150..3da77de 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -4,47 +4,236 @@ import { useQuery } from '@tanstack/react-query' import Chart, { ChartLegend } from '../ui/Chart' import Charts from '../lib/charts' -import { Avatar, Icon, KpiCard, ScoreChip } from '../ui/primitives' -import { seedQuery } from '../data/seedQueries' -import { analytics, fmtShort, kpis, money, relTime } from '../data/seed' +import { Avatar, EmptyState, Icon, KpiCard, ScoreChip } from '../ui/primitives' +import { useAuth } from '../auth/AuthContext' +import { qk } from '../lib/queryKeys' +import { friendlyAuthError } from '../lib/errors' +import { fmtShort, money, relTime, initials as initialsOf, avatarColor } from '../data/seed' +import * as analyticsApi from '../api/analytics' +import * as interviewsApi from '../api/interviews' +import * as activityApi from '../api/activity' +import * as candidatesApi from '../api/candidates' + +const POLL_MS = 60_000 + +function asObject(data) { + return data && typeof data === 'object' && !Array.isArray(data) ? data : null +} + +function asList(data) { + return Array.isArray(data) ? data : [] +} + +function pctDelta(cur, prior) { + if (cur == null || prior == null || prior === 0) return null + const d = ((Number(cur) - Number(prior)) / Math.abs(Number(prior))) * 100 + if (!Number.isFinite(d)) return null + return `${d >= 0 ? '+' : ''}${Math.round(d)}%` +} + +function dayDelta(cur, prior) { + if (cur == null || prior == null) return null + const d = Math.round(Number(prior) - Number(cur)) + if (!Number.isFinite(d) || d === 0) return null + return `${d > 0 ? '-' : '+'}${Math.abs(d)} days` +} + +function greetingFor(now = new Date()) { + const h = now.getHours() + if (h < 12) return 'Good morning' + if (h < 17) return 'Good afternoon' + return 'Good evening' +} + +function formatDashDate(d = new Date()) { + return d.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }) +} + +function initialsFrom(name) { + if (!name) return '?' + try { + return initialsOf(name) + } catch { + return String(name).slice(0, 2).toUpperCase() + } +} + +function mapInterviewRow(iv) { + const whenRaw = iv.interview_date || iv.interview_time + const when = whenRaw ? new Date(whenRaw) : null + const name = iv.candidate_name || 'Candidate' + return { + id: iv.id, + candidate: name, + candInitials: initialsFrom(name), + color: avatarColor(name), + type: iv.interview_type || 'Interview', + jobTitle: iv.job_title || '', + when, + status: iv.interview_status || '', + } +} + +function mapCandidateRow(c) { + const name = c.name || 'Candidate' + const appliedRaw = c.created_at || c.applied + return { + id: c.user_id || c.inbox_id || name, + userId: c.user_id, + name, + initials: initialsFrom(name), + color: avatarColor(name), + jobTitle: c.current_employment || c.experience || '—', + aiScore: c.ats_score ?? c.ai_score ?? null, + applied: appliedRaw ? new Date(appliedRaw) : new Date(0), + } +} + +function mapActivityRow(a) { + const desc = a.description || a.activity_type || 'Activity' + const actor = a.actor_name + const parts = actor + ? [{ b: actor }, ` ${desc}`] + : [desc] + const whenRaw = a.activity_date || a.activity_time + const when = whenRaw ? new Date(whenRaw) : null + const mins = when && !Number.isNaN(when.getTime()) + ? Math.max(0, Math.round((Date.now() - when.getTime()) / 60000)) + : 0 + const type = (a.activity_type || '').toLowerCase() + let icon = 'file' + let color = 'i-indigo' + if (type.includes('interview')) { icon = 'calendar'; color = 'i-blue' } + else if (type.includes('offer')) { icon = 'check'; color = 'i-teal' } + else if (type.includes('hire') || type.includes('stage')) { icon = 'user-plus'; color = 'i-green' } + return { + id: a.id, + icon, + color, + parts, + time: mins, + candidateId: a.inbox_id, + } +} + +function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) { + if (query.isPending) { + return ( + + Fetching from the server… + + ) + } + if (query.isError) { + return ( + + {friendlyAuthError(query.error, `The server did not return ${title}.`)} + {' '}This widget needs the {permission} permission. + + ) + } + const rows = asList(query.data) + if (rows.length === 0) { + return ( + + {emptyHint || 'Nothing to show for this window.'} + + ) + } + return children(rows) +} export default function Dashboard() { const navigate = useNavigate() - const { data: interviews = [] } = useQuery(seedQuery('interviews')) - const { data: candidates = [] } = useQuery(seedQuery('candidates')) - const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) - const { data: activity = [] } = useQuery(seedQuery('activity')) + const { user } = useAuth() + const firstName = (user?.name || 'there').split(' ')[0] + const todayLabel = formatDashDate() - const k = kpis + const kpisQuery = useQuery({ + queryKey: qk.analytics.kpis(), + queryFn: async () => asObject((await analyticsApi.kpis()).data), + refetchInterval: POLL_MS, + }) + const trendQuery = useQuery({ + queryKey: qk.analytics.trend({ months: 7 }), + queryFn: async () => asObject((await analyticsApi.hiringTrend({ months: 7 })).data) || { + labels: [], + applications: [], + hires: [], + }, + refetchInterval: POLL_MS, + }) + const funnelQuery = useQuery({ + queryKey: qk.analytics.funnel(), + queryFn: async () => asList((await analyticsApi.funnel()).data), + refetchInterval: POLL_MS, + }) + const sourcesQuery = useQuery({ + queryKey: qk.analytics.sources(), + queryFn: async () => asList((await analyticsApi.sourcePerformance()).data), + refetchInterval: POLL_MS, + }) + const recruitersQuery = useQuery({ + queryKey: qk.analytics.recruiters({ top: 5 }), + queryFn: async () => asList((await analyticsApi.recruiterPerformance({ top: 5 })).data), + refetchInterval: POLL_MS, + }) - // Chart payloads must be referentially stable, or re-runs its effect - // and re-animates on every parent render. - const trendData = useMemo( - () => ({ - labels: analytics.hiringTrend.labels, + const interviewsQuery = useQuery({ + queryKey: qk.interviews.range({ status: 'Scheduled', top: 5 }), + queryFn: async () => asList((await interviewsApi.listRange({ status: 'Scheduled', top: 5 })).data) + .map(mapInterviewRow), + }) + + const candidatesQuery = useQuery({ + queryKey: qk.candidates.list({ limit: 5 }), + queryFn: async () => { + const rows = candidatesApi.toRows(await candidatesApi.list({ limit: 5, offset: 0 })) + return asList(rows).map(mapCandidateRow).sort((a, b) => b.applied - a.applied).slice(0, 5) + }, + }) + + const activityQuery = useQuery({ + queryKey: qk.activity.feed({ top: 8 }), + queryFn: async () => asList((await activityApi.feed({ top: 8 })).data).map(mapActivityRow), + }) + + const k = kpisQuery.data + + const trendData = useMemo(() => { + const t = trendQuery.data || { labels: [], applications: [], hires: [] } + return { + labels: t.labels || [], area: true, datasets: [ - { label: 'Applications', data: analytics.hiringTrend.applications, color: Charts.PALETTE[4] }, - { label: 'Hires', data: analytics.hiringTrend.hires, color: Charts.PALETTE[0] }, + { label: 'Applications', data: t.applications || [], color: Charts.PALETTE[4] }, + { label: 'Hires', data: t.hires || [], color: Charts.PALETTE[0] }, ], - }), - [], - ) - const pipelineData = useMemo( - () => ({ - labels: analytics.pipeline.map((p) => p.stage), - data: analytics.pipeline.map((p) => p.count), + } + }, [trendQuery.data]) + + const pipelineData = useMemo(() => { + const rows = asList(funnelQuery.data) + return { + labels: rows.map((p) => p.stage), + data: rows.map((p) => p.count), colors: Charts.PALETTE, - }), - [], - ) - const sourceData = useMemo( - () => ({ - labels: analytics.sources.map((s) => s.source), - data: analytics.sources.map((s) => s.count), - }), - [], - ) + } + }, [funnelQuery.data]) + + const sourceData = useMemo(() => { + const rows = asList(sourcesQuery.data) + return { + labels: rows.map((s) => s.source), + data: rows.map((s) => s.count), + } + }, [sourcesQuery.data]) + const legend = useMemo( () => [ { label: 'Applications', color: Charts.PALETTE[4] }, @@ -53,30 +242,101 @@ export default function Dashboard() { [], ) - const upcoming = interviews.filter((iv) => iv.status === 'Scheduled').slice(0, 5) - const recentApps = [...candidates].sort((a, b) => b.applied - a.applied).slice(0, 5) - const topRecruiters = [...recruiters].sort((a, b) => b.hires - a.hires).slice(0, 5) + const pending = kpisQuery.isPending + const dash = (v) => (pending || v == null || v === '' ? '—' : v) const row1 = [ - { label: 'Open Jobs', value: k.openJobs, icon: 'briefcase', tone: 'i-indigo', trend: '+8%', dir: 'up', foot: 'vs last month' }, - { label: 'Total Candidates', value: k.totalCandidates, icon: 'users', tone: 'i-blue', trend: '+12%', dir: 'up', foot: 'active in pipeline' }, - { label: 'Interviews Today', value: k.interviewsToday, icon: 'calendar', tone: 'i-purple', trend: '3 upcoming', dir: 'flat', foot: 'next at 2:00 PM' }, - { label: 'Offers Accepted', value: k.offersAccepted, icon: 'check-circle', tone: 'i-green', trend: '+5%', dir: 'up', foot: `of ${k.offersSent} sent` }, + { + label: 'Open Jobs', + value: dash(k?.open_jobs), + icon: 'briefcase', + tone: 'i-indigo', + trend: pctDelta(k?.open_jobs, k?.open_jobs_prior) || '—', + dir: Number(k?.open_jobs) >= Number(k?.open_jobs_prior) ? 'up' : 'down', + foot: 'vs prior window', + }, + { + label: 'Total Candidates', + value: dash(k?.total_candidates), + icon: 'users', + tone: 'i-blue', + trend: pctDelta(k?.total_candidates, k?.total_candidates_prior) || '—', + dir: Number(k?.total_candidates) >= Number(k?.total_candidates_prior) ? 'up' : 'down', + foot: 'active in pipeline', + }, + { + label: 'Interviews Today', + value: dash(k?.interviews_today), + icon: 'calendar', + tone: 'i-purple', + trend: k?.interviews_upcoming != null ? `${k.interviews_upcoming} upcoming` : '—', + dir: 'flat', + foot: k?.next_interview_at + ? `next at ${new Date(k.next_interview_at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}` + : 'no upcoming', + }, + { + label: 'Offers Accepted', + value: dash(k?.offers_accepted), + icon: 'check-circle', + tone: 'i-green', + trend: pctDelta(k?.offers_accepted, k?.offers_accepted_prior) || '—', + dir: Number(k?.offers_accepted) >= Number(k?.offers_accepted_prior) ? 'up' : 'down', + foot: k?.offers_sent != null ? `of ${k.offers_sent} sent` : '—', + }, ] + const row2 = [ - { label: 'Time to Hire', value: `${k.timeToHire} days`, icon: 'clock', tone: 'i-teal', trend: '-3 days', dir: 'up', foot: 'faster than target' }, - { label: 'Time to Fill', value: `${k.timeToFill} days`, icon: 'target', tone: 'i-amber', trend: '-2 days', dir: 'up', foot: '41 day average' }, - { label: 'Cost per Hire', value: money(k.costPerHire), icon: 'dollar', tone: 'i-red', trend: '+4%', dir: 'down', foot: 'above budget' }, - { label: 'Closed Jobs', value: k.closedJobs + k.hires, icon: 'award', tone: 'i-purple', trend: '+15%', dir: 'up', foot: 'this quarter' }, + { + label: 'Time to Hire', + value: k?.time_to_hire != null && !pending ? `${Math.round(k.time_to_hire)} days` : '—', + icon: 'clock', + tone: 'i-teal', + trend: dayDelta(k?.time_to_hire, k?.time_to_hire_prior) || '—', + dir: Number(k?.time_to_hire) <= Number(k?.time_to_hire_prior) ? 'up' : 'down', + foot: k?.time_to_hire == null ? 'no hires in window' : 'vs prior window', + }, + { + label: 'Time to Fill', + value: k?.time_to_fill != null && !pending ? `${Math.round(k.time_to_fill)} days` : '—', + icon: 'target', + tone: 'i-amber', + trend: dayDelta(k?.time_to_fill, k?.time_to_fill_prior) || '—', + dir: Number(k?.time_to_fill) <= Number(k?.time_to_fill_prior) ? 'up' : 'down', + foot: k?.time_to_fill == null ? 'no closes in window' : 'vs prior window', + }, + { + label: 'Cost per Hire', + value: k?.cost_per_hire != null && !pending ? money(Math.round(k.cost_per_hire)) : '—', + icon: 'dollar', + tone: 'i-red', + trend: pctDelta(k?.cost_per_hire, k?.cost_per_hire_prior) || '—', + dir: Number(k?.cost_per_hire) <= Number(k?.cost_per_hire_prior) ? 'up' : 'down', + foot: k?.cost_per_hire == null ? 'no cost data yet' : 'vs prior window', + }, + { + label: 'Closed Jobs', + value: dash( + k == null ? null : Number(k.closed_jobs || 0) + Number(k.hires || 0), + ), + icon: 'award', + tone: 'i-purple', + trend: pctDelta( + Number(k?.closed_jobs || 0) + Number(k?.hires || 0), + Number(k?.closed_jobs_prior || 0) + Number(k?.hires_prior || 0), + ) || '—', + dir: 'up', + foot: 'this window', + }, ] return (
-

Good morning, Asfand 👋

+

{greetingFor()}, {firstName} 👋

- Here’s what’s happening with your hiring today — Thursday, July 9, 2026 + Here’s what’s happening with your hiring today — {todayLabel}

@@ -101,7 +361,9 @@ export default function Dashboard() {

Hiring Trend

- Hires vs applications over the last 7 months + + {trendQuery.isPending ? 'Loading…' : 'Hires vs applications over the last 7 months'} +
7M @@ -109,23 +371,43 @@ export default function Dashboard() {
-
- -
- + {trendQuery.isError ? ( + + {friendlyAuthError(trendQuery.error, 'The server did not return the trend.')} + {' '}This widget needs the analytics.view permission. + + ) : ( + <> +
+ +
+ + + )}

Candidate Pipeline

- Active by stage + {funnelQuery.isPending ? 'Loading…' : 'Active by stage'}
-
- -
+ {funnelQuery.isError ? ( + + {friendlyAuthError(funnelQuery.error, 'The server did not return the funnel.')} + {' '}This widget needs the analytics.view permission. + + ) : asList(funnelQuery.data).length === 0 && funnelQuery.isSuccess ? ( + + Stage counts appear once applications are in the system. + + ) : ( +
+ +
+ )}
@@ -141,10 +423,14 @@ export default function Dashboard() {
- {upcoming.length === 0 ? ( -
No upcoming interviews
- ) : ( - upcoming.map((iv) => ( + + {(upcoming) => upcoming.map((iv) => (
{iv.candidate}
-
{iv.type} · {iv.jobTitle}
+
{iv.type} · {iv.jobTitle || '—'}
-
{fmtShort(iv.when)}
+
{iv.when ? fmtShort(iv.when) : '—'}
- {iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} + {iv.when + ? iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + : '—'}
- )) - )} + ))} +
@@ -172,13 +460,26 @@ export default function Dashboard() {

Source Analytics

- Where candidates come from + + {sourcesQuery.isPending ? 'Loading…' : 'Where candidates come from'} +
-
- -
+ {sourcesQuery.isError ? ( + + {friendlyAuthError(sourcesQuery.error, 'The server did not return source analytics.')} + {' '}This widget needs the analytics.view permission. + + ) : asList(sourcesQuery.data).length === 0 && sourcesQuery.isSuccess ? ( + + Source channels appear after applications are tagged. + + ) : ( +
+ +
+ )}
@@ -191,21 +492,30 @@ export default function Dashboard() {
- {recentApps.map((c) => ( -
navigate('/candidates', { state: { openCandidate: c.id } })} - > - -
-
{c.name}
-
{c.jobTitle}
+ + {(recentApps) => recentApps.map((c) => ( +
navigate('/candidates', { state: { openCandidate: c.userId || c.id } })} + > + +
+
{c.name}
+
{c.jobTitle}
+
+
+ {c.aiScore != null ? : '—'} +
-
-
- ))} + ))} +
@@ -214,19 +524,32 @@ export default function Dashboard() {

Recruiter Performance

- {topRecruiters.map((r) => ( -
- -
-
{r.name}
-
{r.openReqs} open reqs · {r.avgTimeToHire}d avg
-
-
-
{r.hires}
-
hires
-
-
- ))} + + {(topRecruiters) => topRecruiters.map((r) => { + const name = r.name || 'Recruiter' + return ( +
+ +
+
{name}
+
+ {r.open_reqs ?? 0} open reqs · {r.avg_time_to_hire != null ? `${Math.round(r.avg_time_to_hire)}d` : '—'} avg +
+
+
+
{r.hires ?? 0}
+
hires
+
+
+ ) + })} +
@@ -235,19 +558,26 @@ export default function Dashboard() {

Recent Activity

- {activity.slice(0, 8).map((a, i) => ( -
- - - -
-
- {a.parts.map((p, j) => (typeof p === 'string' ? p : {p.b}))} + + {(activity) => activity.map((a, i) => ( +
+ + + +
+
+ {a.parts.map((p, j) => (typeof p === 'string' ? p : {p.b}))} +
+
{relTime(a.time)}
-
{relTime(a.time)}
-
- ))} + ))} +
diff --git a/frontend/src/screens/ScoredCandidateProfile.jsx b/frontend/src/screens/ScoredCandidateProfile.jsx new file mode 100644 index 0000000..61182ff --- /dev/null +++ b/frontend/src/screens/ScoredCandidateProfile.jsx @@ -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 ( + + + + + } + > +
+ +
+
{c.name}
+
+ {c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''} +
+
+ {c.scoringStatus && (scored ? Scored : {c.errorCode ?? 'Failed'})} + {SOURCE_LABEL[c.source] ?? c.source} + {c.experience != null && ( + {c.experience} yrs exp + )} +
+
+ {c.aiScore != null && ( +
+ +
AI Match
+
+ )} +
+ +
+ ({ key: t, label: t }))} /> +
+ +
+ {tab === 'Overview' && ( + <> +
+
Scored For
{jobTitle ?? '—'}
+
Current Title
{c.currentTitle ?? '—'}
+
Current Company
{c.currentCompany ?? '—'}
+
Experience
{c.experience != null ? `${c.experience} years` : '—'}
+
Source
{SOURCE_LABEL[c.source] ?? c.source}
+
Added On
{c.applied ? fmtDate(c.applied) : '—'}
+
+ {scored && ( + <> +
Matched Skills
+
+ {c.matchedSkills.length + ? c.matchedSkills.map((s) => {s}) + : } +
+ + )} + + )} + + {tab === 'Scoring' && ( + scored ? ( + <> +
AI Assessment
+

{c.critique ?? '—'}

+
+ Matched Skills ({c.matchedSkills.length}) +
+
+ {c.matchedSkills.length + ? c.matchedSkills.map((s) => ( + {s} + )) + : } +
+
+ Missing Skills ({c.missingSkills.length}) +
+
+ {c.missingSkills.length + ? c.missingSkills.map((s) => ( + {s} + )) + : None — full match} +
+ + ) : ( + + {c.errorMessage ?? 'This CV could not be processed.'} + + ) + )} + + {tab === 'File' && ( +
+
File Name
{c.filename}
+
Source
{SOURCE_LABEL[c.source] ?? c.source}
+ {c.inboxMessageId && ( +
Inbox Message
{c.inboxMessageId}
+ )} + {!scored && ( + <> +
Error
{c.errorCode ?? '—'}
+
Detail
{c.errorMessage ?? '—'}
+ + )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index 1a9d215..32d59ed 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -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, } } From 6dadbbc472591d9ee964b740be0c917d97414a7c Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 12 Aug 2026 16:45:37 +0500 Subject: [PATCH 2/4] add Readme.md update --- backend/README.md | 437 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 425 insertions(+), 12 deletions(-) diff --git a/backend/README.md b/backend/README.md index 858277d..94128f9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -11,6 +11,7 @@ Everything in this document describes `backend/` only. ## Table of contents +- [Answering your questions](#answering-your-questions) - [Architecture](#architecture) - [Tech stack](#tech-stack) - [Directory layout](#directory-layout) @@ -21,6 +22,7 @@ Everything in this document describes `backend/` only. - [Authentication and RBAC](#authentication-and-rbac) - [Background jobs](#background-jobs) - [The matching agent](#the-matching-agent) +- [The ATS scoring engine](#the-ats-scoring-engine) - [External integrations](#external-integrations) - [Configuration](#configuration) - [Running locally](#running-locally) @@ -31,6 +33,75 @@ Everything in this document describes `backend/` only. --- +## Answering your questions + +A short orientation on the ATS work that arrived with the dashboard branch, for anyone opening +this repo for the first time. Every claim links to the section that carries the detail. + +### Is the ATS linked to the tables, or just an agentic flow? + +**Both, and the split is the important part.** The engine is `app/` at the **repo root** — not +under `backend/` — and it is stateless: no SQLAlchemy, no session, no table. It is imported as +a **library** (`pip install -e ..`), *not* called over HTTP; `app/api/routes.py` and +`app/main.py` are dead weight here. The linkage is +`job/candidate/views.py::CandidateScoring`, which builds the JD, calls the engine, and +persists everything to **`candidates`**. + +So: agentic scoring, fully relational output. Full detail in +[The ATS scoring engine](#the-ats-scoring-engine). + +### What it gets from the engine + +`ATSScore`, eight validated fields: `candidate_name`, `job_title`, `current_company`, +`years_experience` (0–60), `match_score` (0–100, required), `matched_keywords` / +`missing_keywords` (≤30, deduplicated), `summary_critique` (1–500 chars). +See [What the engine gives back](#what-the-engine-gives-back). + +### What it requires from the system + +A `job_description` string built **only** from `JobPosts` columns in a fixed order +(`build_job_description`, which excludes `post_text` and `salary` to stay byte-stable for +prompt caching), plus résumé bytes from either an upload or `inbox_messages.file_path`. +See [What the system gives the engine](#what-the-system-gives-the-engine). + +### Where the values land + +1:1 into `candidates`, plus context the engine never sees (`job_id`, `source`, +`inbox_message_id`, `content_sha256`, `created_by`, `model`). Results merge **by slot index, +never by filename**, since inbox attachments routinely collide on `resume.pdf`. +See [Where the values land](#where-the-values-land). + +### Routing + +Two manual routes (`/candidate/score`, `/candidate/score_inbox`) and two automatic triggers +(after every CV match in `inbox/tasks.py`, and on `PATCH /inbox/{id}/assign-job-post`), all +idempotent, both automatic paths wrapped so a scoring failure never fails the match. +See [Routing in code](#routing-in-code). + +### What is missing — the part worth acting on + +| Gap | Effect | +|---|---| +| **`ats_results` is a dead table** | Declared with a full supersede chain, migrated, 0 rows, no reader, no writer anywhere in `backend/` | +| **`inbox_messages.ats_score` / `ats_band` are read but never written** | `serialize_application` returns them, so the Applications tab shows `null` even for candidates that *do* have a completed score | +| **Re-scoring destroys history** | `upsert_candidate` updates in place on (`job_id`, `content_sha256`) | +| **`.gitignore` line 56 (`**_**_**.py`) ignores generated migrations** | 14 of 18 on disk are untracked, so a fresh clone cannot reach head; with `DB_AUTOGENERATE=true` every developer invents their own revision ids for the same change | + +The full list, including the DOC/DOCX limitation and the missing wrapper tests, is under +[What is missing](#what-is-missing) and [Known gaps and gotchas](#known-gaps-and-gotchas). + +### How this document was verified + +The route tables were written from source and then checked against the running service: +**all 59 documented route rows match `/openapi.json`**, every internal anchor resolves, and all +13 permission tags resolve against `PermissionTag`. That check caught four real errors worth +repeating, since the same mistakes are easy to make from reading alone: +`/candidate/stage/fetch` does not exist (it is `/pipeline/transitions/fetch`), +`/offers/update` is `PATCH` not `PUT`, `/offers/issue` was missing entirely, and the pipeline +and assignment guards are `pipeline.*` / `jobs.*`, **not** `candidates.*` / `job_board.*`. + +--- + ## Architecture ```mermaid @@ -73,11 +144,22 @@ flowchart TB 4. The worker extracts the résumé text, hands it plus the active job posts to the LangGraph agent, and writes `suggested_job_post_ids`, `match_summary`, `match_reasoning` and `experience` back onto the row. -5. New candidate accounts land inactive and are mailed a confirmation link; the link is what +5. **Still inside the same task**, the ATS engine auto-scores that CV against one job — + the assigned post if there is one, otherwise the agent's top suggestion — and writes a + row to `candidates`. See [The ATS scoring engine](#the-ats-scoring-engine). +6. New candidate accounts land inactive and are mailed a confirmation link; the link is what flips `is_active`. -6. A cron task sweeps Outlook read-status deltas back onto `inbox_messages.message_read`. -7. Recruiters read all of it through `/inbox/all-applications` and publish new roles with +7. A cron task sweeps Outlook read-status deltas back onto `inbox_messages.message_read`. +8. Recruiters read all of it through `/inbox/all-applications` and publish new roles with `POST /job/post-job`, which renders the ad copy and pushes it to Buffer. +9. The dashboard reads `analytics/` (KPIs, hiring trend, funnel, recruiter and source + performance), which aggregates over `inbox`, `application_stage_transitions`, `offers`, + `job_posts`, `hiring_costs` and `job_assignments`. + +**Two different LLM passes, often confused.** The *matching agent* answers "which of our open +jobs is this CV for?" and writes onto `inbox_messages`. The *ATS scoring engine* answers "how +well does this CV fit **one** chosen job, 0-100?" and writes to `candidates`. They run +back-to-back in the same task but are separate codebases with separate prompts. --- @@ -131,6 +213,22 @@ backend/ └── taskiq_management/ # broker, scheduler, DLQ middleware, smoke task ``` +One dependency lives **outside** `backend/`: the bulk-ATS scoring engine at the repo root. + +``` +/ +├── app/ # the bulk-ATS engine — imported as a library, never over HTTP +│ ├── models/scoring.py # ATSScore / CompletedCandidate / FailedCandidate +│ ├── services/pdf.py # extract_resume +│ ├── services/llm.py # OpenAIScorer (the Scorer protocol) +│ ├── services/scoring.py # score_batch, verify_matched_keywords +│ └── api/, main.py # its standalone FastAPI app — UNUSED by this backend +├── tests/ # tests for app/ only; nothing covers the backend wrapper +└── CLAUDE.md # the engine's own spec +``` + +Install it once per environment, from `backend/`: `pip install -e ..` + There are **no `__init__.py` files**. The service is run from `backend/`, so imports are top-level (`from users.app import router`, `from db_setup import get_session`). @@ -206,7 +304,23 @@ Two sub-domains behind one router: as `scheduled`, not `published`; only Buffer reporting `sent` promotes it. - **`candidate/`** — `FileRead` extracts text from an uploaded PDF (`pypdf`), and `match_inbox_cv` force-requeues an existing inbox message for matching. `CandidateView` - reads the candidate profile through the `inbox` join. + reads the candidate profile through the `inbox` join and fills `ai_score` / + `recommendation` from `candidates`. `CandidateScoring` is the ATS wrapper — see + [The ATS scoring engine](#the-ats-scoring-engine). `models.py` also owns `Activity`, + `Feedback`, `Interviews`, `Notes`, `Candidates` and `ApplicationStageTransitions`. +- **`pipeline/`** — `Pipeline.change_stage`, the single writer of + `application_stage_transitions`. Nothing else may move an application between stages. +- **`assignment/`** — `job_assignments` and `application_assignments`, both temporal + (`valid_to IS NULL` = current). +- **`cost/`** — `hiring_costs`, the numerator of cost-per-hire. + +### `analytics/` +Read-only aggregation for the dashboard — **views and serializers only, no tables of its own**. +Every window bound it builds is timezone-aware UTC, which is why every timestamp column it +touches must be `timestamptz`. + +### `offer/` +`offers` plus `offer_status_history`, same temporal shape as the stage transitions. ### `notifications/` and `forget_password/` Two parallel token flows, deliberately kept separate so each owns its own mail copy and env @@ -247,12 +361,44 @@ indexes, constraints and foreign keys. `SQLModel.metadata` is pointed at `Base.m | `password_reset_codes` | `id`, `email`, `code_hash`, `expires_at`, `attempts`, `is_used`, `verified_at` | | | `email_confirmation_tokens` | `id`, `user_id`, `email`, `token_hash`, `expires_at`, `is_used`, `confirmed_at` | | -`application_status` is a `str` enum: `PROCESS`, `PENDING`, `APPROVED`, `REJECTED`, `ONHOLD`, -`CLOSED`. +### Tables added by the dashboard + ATS work + +Nine tables landed together with `analytics/`, `offer/`, `job/assignment/`, `job/cost/` and +`job/pipeline/`. Owning module in brackets. + +| Table | Key columns | Notes | +|---|---|---| +| `candidates` *(job/candidate)* | `id`, `job_id` → `job_posts.id`, `source` (`upload`\|`inbox`), `inbox_message_id` → `inbox_messages.id`, `filename`, `file_path`, `content_sha256`, `candidate_name`, `job_title`, `current_company`, `years_experience`, `match_score`, `matched_keywords`/`missing_keywords` (JSON), `summary_critique`, `status`, `error_code`, `error_message`, `model`, `created_by` | **The ATS result table.** Unique on (`job_id`, `content_sha256`) so re-scoring the same bytes against the same job updates in place. `status` is `completed` \| `failed`; a failed row keeps `match_score` NULL and carries the error instead | +| `application_stage_transitions` *(job/candidate)* | `id`, `inbox_id` → `inbox.id`, `from_stage`, `to_stage`, `valid_from`, `valid_to`, `changed_by`, `actor_kind`, `change_reason` | Temporal history of `inbox_messages.application_status`. `valid_to IS NULL` = current stage; `from_stage IS NULL` = pipeline entry. Time-in-stage is a subtraction, not a window function. **Single writer: `job/pipeline/views.py::Pipeline.change_stage`** | +| `job_assignments` *(job/assignment)* | `id`, `job_post_id`, `user_id`, `assignment_role`, `valid_from`, `valid_to` | Who owns a requisition. Open rows (`valid_to IS NULL`) are what Recruiter Performance counts as open reqs | +| `application_assignments` *(job/assignment)* | `id`, `inbox_id`, `user_id`, `assignment_role`, `valid_from`, `valid_to` | Same temporal shape, per application | +| `hiring_costs` *(job/cost)* | `id`, `job_post_id`, `cost_type`, `amount`, `currency`, `incurred_at`, `created_by` | Numerator of the cost-per-hire KPI | +| `offers` *(offer)* | `id`, `inbox_id`, `job_post_id`, `status`, `salary`, `start_date`, `expiry_date`, `sent_at`, `responded_at`, `closed_at` | Feeds `offers_sent` / `offers_accepted` | +| `offer_status_history` *(offer)* | `id`, `offer_id`, `from_status`, `to_status`, `valid_from`, `valid_to` | Temporal history of `offers.status` | +| `source_channels` *(inbox)* | `id`, `key` (unique), `label`, `is_active` | The eleven BRD sourcing channels, seeded by `migrations/manual/001` | +| `ats_results` *(inbox)* | `id`, `inbox_id`, `job_post_id`, `overall_score`, `band`, `is_current`, `superseded_by_id`, `model_name`, `computed_at` | **Declared but unused — no code reads or writes it.** See [Known gaps](#known-gaps-and-gotchas) | + +`inbox_messages` also gained denormalised dashboard columns: `ats_score`, `ats_band`, +`recruiter_id`, `is_duplicate`, `source_channel_id`, `processing_state`. **`ats_score` and +`ats_band` are read by `serialize_application` but never written by anything** — the real score +is joined from `candidates` at read time. + +`application_status` is a `str` enum, extended by `migrations/manual/001`: `PROCESS`, +`PENDING`, `APPROVED`, `REJECTED`, `ONHOLD`, `CLOSED`, `SCREENING`, `ASSESSMENT`, `INTERVIEW`, +`OFFER`, `HIRED`. `match_status` is free-form text written by the worker: `processing`, `matched`, `skipped`, `no_text`, `failed`, `dlq`. +**Every timestamp column in the `app` schema is `timestamptz`.** Model defaults are +`_now()` = `datetime.now(timezone.utc)`, never bare `datetime.now()`, which returns the +writing host's local wall clock. This is load-bearing rather than stylistic: `analytics/` +builds its window bounds as aware UTC, and binding an aware datetime against a naive column +makes asyncpg raise `DataError: can't subtract offset-naive and offset-aware datetimes` in its +parameter encoder — the statement never reaches Postgres. A naive default written into an +already-`timestamptz` column is worse, because it does not raise at all: asyncpg reads the +local value as UTC and silently backdates the row. + --- ## API reference @@ -317,12 +463,62 @@ Base URL: `http://localhost:8000`. Interactive docs at `/docs`. | GET | `/job/buffer/channels` | `job_board.view` | Connected Buffer channels across all organizations | | POST | `/candidate/cv_upload` | `candidates.create` | Upload a PDF; extract email, persist like an emailed CV, enqueue matching on the CV stream | | POST | `/candidate/inbox-match?inbox_message_id=` | `candidates.edit` | Queue a forced re-match for a stored message | -| GET | `/candidate/fetch?user_id=` | `candidates.view` | Candidate profile via the `inbox` join | +| GET | `/candidate/fetch?user_id=` | `candidates.view` | Candidate profile via the `inbox` join, with `ai_score` / `recommendation` joined from `candidates` | +| GET | `/job/fetch` | `job_board.view` **or** `candidates.view` | List job posts. Either tag suffices — a recruiter scoring CVs needs a job to score against | `POST /job/post-job` takes `mode` ∈ `addToQueue` | `shareNow` | `customScheduled`; the `customScheduled` mode requires `scheduler_date` (and optionally `scheduler_time`), which the route combines into a UTC `due_at`. +### ATS scoring — `job/app.py` + +| Method | Path | Required tag | Purpose | +|---|---|---|---| +| POST | `/candidate/score` | `candidates.create` | Multipart `job_id` + `files[]`; score uploaded PDFs, persist, return the leaderboard | +| POST | `/candidate/score_inbox` | `candidates.create` | JSON `job_id` + `message_ids[]` (**`inbox_messages` PK uuids, not Graph ids**); score decoded attachments | +| GET | `/candidate/scored/fetch?job_id=` | `candidates.view` | Persisted leaderboard; omit `job_id` for the whole pool | +| GET | `/candidate/fetch_by_id?candidate_id=` | `candidates.view` | One `candidates` row | + +### Pipeline, assignments, costs — `job/app.py` + +| Method | Path | Required tag | Purpose | +|---|---|---|---| +| GET | `/pipeline/transitions/fetch` | `pipeline.view` | Stage history by `transition_id` or `inbox_id` | +| PATCH | `/candidate/stage` | `pipeline.edit` | Move an application to a stage. **The only writer of `application_stage_transitions`** — closes the open row, writes the new one, updates `application_status`. 400 if already at that stage, 422 on an invalid `to_stage` | +| GET | `/job/assignments/fetch` | `jobs.view` | Requisition assignments | +| POST | `/job/assignments/create` | `jobs.edit` | Assign a user to a requisition | +| GET | `/candidate/assignments/fetch` | `candidates.view` | Application assignments | +| POST | `/candidate/assignments/create` | `candidates.edit` | Assign a user to an application | +| GET | `/job/costs/fetch` | `jobs.view` | Hiring costs; filters `job_post_id`, `from_date`, `to_date` | +| POST | `/job/costs/create` | `jobs.edit` | Record a hiring cost | +| GET | `/activity/fetch` | `candidates.view` | Activity feed — `activity_id`, `inbox_id`, or `top`/`skip` for the global feed | +| POST | `/activity/create` | `candidates.create` | Write an activity row; links via `inbox_id`, `message_id`, or `user_id` | + +### Analytics — `analytics/app.py` + +All require `analytics.view`. Common query params: `from_date`, `to_date`, `department`, +`recruiter_id`. + +| Method | Path | Purpose | +|---|---|---| +| GET | `/analytics/kpis/fetch` | The KPI cards, each with a prior-period comparison | +| GET | `/analytics/hiring-trend/fetch?months=7` | Applications vs hires by month | +| GET | `/analytics/funnel/fetch` | Candidate count per stage | +| GET | `/analytics/recruiter-performance/fetch?top=5` | Per recruiter: hires, open reqs, avg time-to-hire | +| GET | `/analytics/source-performance/fetch` | Applications per source channel | + +Recruiter Performance iterates **users whose role is `recruiter`**; with no such user the list +is empty regardless of the rest of the data. + +### Offers — `offer/app.py` + +| Method | Path | Required tag | +|---|---|---| +| GET | `/offers/fetch` | `offers.view` | +| POST | `/offers/create` | `offers.create` | +| PATCH | `/offers/update` | `offers.edit` | +| POST | `/offers/issue` | `offers.approve` | + --- ## Authentication and RBAC @@ -377,11 +573,17 @@ Taskiq over **Redis Streams**, with a result backend and a Redis-backed schedule | Task | Trigger | What it does | |---|---|---| -| `inbox.match_message` | enqueued by `/email/fetch`, `/inbox/{id}/match`, `/candidate/inbox-match` onto the `inbox` stream | Extract résumé text → run the agent → write match results | +| `inbox.match_message` | enqueued by `/email/fetch`, `/inbox/{id}/match`, `/candidate/inbox-match` onto the `inbox` stream | Extract résumé text → run the agent → write match results → **auto-score with the ATS** | | `inbox.match_message` (CV broker) | enqueued by `/candidate/cv_upload` onto the `cv_upload` stream | Same matcher as above; isolated so uploads never sit behind `/email/fetch` backlog | +| `inbox.score_message` | enqueued by `PATCH /inbox/{id}/assign-job-post` onto the `inbox` stream | ATS-score one message against one job. Idempotent — a completed (message, job) pair returns `already_scored` without paying for a second call | | `inbox.sync_read_status` | cron, `EMAIL_SYNC_CRON` (default every minute) | Pull read-status deltas from the Email API and apply them | | `ping` | manual | Framework smoke test | +**Auto-scoring never fails a match.** `match_inbox_message` commits the agent result first, +then scores inside its own `try`; a scoring exception is logged and swallowed. Likewise the +enqueue in `set_assigned_job_post` is wrapped — a broker outage logs a warning and leaves the +manual *Score with ATS* button as the fallback. + **Retries.** `SmartRetryMiddleware` with jitter and exponential delay, `TASKIQ_MAX_RETRIES` attempts, capped at `TASKIQ_MAX_DELAY`. Raising `PermanentTaskError` (missing record, no attachment, blank `record_id`) skips retries entirely. @@ -428,6 +630,184 @@ logs a warning and the API still serves. Only the database is a hard requirement --- +## The ATS scoring engine + +### Is it linked to the database, or just an agentic flow? + +**Both, and the distinction matters.** The engine itself is stateless and knows nothing about +this database; the backend wraps it and owns all persistence. + +- The engine — `app/` at the **repo root**, not under `backend/` — is the standalone bulk-ATS + service (its own `CLAUDE.md` at the repo root is its spec). It takes a job-description + *string* and résumé *bytes* and returns validated Pydantic objects. No SQLAlchemy, no + session, no table. +- The backend imports it as a library, installed editable from the repo root: + `pip install -e ..` → `import app.*` (see the tail of `requirements.txt`). It is **not** + called over HTTP, and `app/api/routes.py` and `app/main.py` are unused here. +- `job/candidate/views.py::CandidateScoring` is the seam: it builds the JD from a `JobPosts` + row, feeds the engine, and persists every result to **`candidates`**. + +So the scoring is agentic, but the output is fully relational. What is *not* linked: +`ats_results` and `inbox_messages.ats_score` / `ats_band`, which exist as columns but have no +writer — see [what is missing](#what-is-missing). + +### What the system gives the engine + +| Input | Built from | Where | +|---|---|---| +| `job_description` (str) | `JobPosts` columns only — `title`, `employment_type`, `location`, `experience_min`/`max`, `description`, `requirements`, `optional_skills`, in that fixed order | `job/candidate/plugins.py::build_job_description` | +| résumé bytes | uploaded `UploadFile`, or the decoded attachment at `inbox_messages.file_path` | `CandidateScoring.score_uploads` / `score_inbox` | +| `scorer` | `OpenAIScorer` over **`llm_setup`'s shared `AsyncOpenAI` client** — one connection pool for the whole process, not a second one | `plugins.py::get_scorer` | +| `concurrency` | `SCORING_CONCURRENCY` | `plugins.py::get_scoring_settings` | + +`build_job_description` deliberately excludes `post_text` and `salary`, and is byte-stable per +job: OpenAI prompt caching keys on an exact prefix match, so one volatile byte (an id, a +timestamp) would stop the whole batch reusing the cached JD prefix. + +Résumé text is run through `normalize_spaced_text` **before** scoring, so that keyword +verification sees exactly the text the model saw. Designer-made CVs position every glyph +individually and `pypdf` returns `S K I L L S`; the `despace_line` decorator rebuilds those. + +### What the engine gives back + +`ATSScore` (`app/models/scoring.py`) — eight fields, all validated before they reach the DB: + +| Field | Type | Constraint | +|---|---|---| +| `candidate_name` | `str \| None` | ≤120 chars; null when the CV does not state it | +| `job_title` | `str \| None` | ≤120; most recent employment entry, verbatim | +| `current_company` | `str \| None` | ≤120 | +| `years_experience` | `int \| None` | 0–60; a stated total wins, else computed from explicit dates, else null | +| `match_score` | `int` | **0–100, required** | +| `matched_keywords` | `list[str]` | ≤30, deduplicated case-insensitively | +| `missing_keywords` | `list[str]` | ≤30, JD-side wording | +| `summary_critique` | `str` | 1–500 chars, one sentence | + +Results come back as a discriminated union — `CompletedCandidate` or `FailedCandidate` +(`filename`, `error_code`, `error_message`) — so a partial batch cannot reach an invalid state. + +`matched_keywords` are server-verified after parsing: `verify_matched_keywords` drops any +keyword with no case-, separator- and plural-insensitive occurrence in the résumé text, +because a matched keyword is an evidence pointer a recruiter reads as "this is in the CV". + +### Where the values land + +Every field maps 1:1 onto `candidates` (`CandidateScoring._score_and_persist`): + +``` +ATSScore.candidate_name -> candidates.candidate_name +ATSScore.job_title -> candidates.job_title +ATSScore.current_company -> candidates.current_company +ATSScore.years_experience -> candidates.years_experience +ATSScore.match_score -> candidates.match_score +ATSScore.matched_keywords -> candidates.matched_keywords (JSON) +ATSScore.missing_keywords -> candidates.missing_keywords (JSON) +ATSScore.summary_critique -> candidates.summary_critique + candidates.status = "completed" + +FailedCandidate.error_code/_message -> candidates.error_code/error_message + candidates.status = "failed", match_score NULL +``` + +plus context the engine never sees: `job_id`, `source` (`upload`\|`inbox`), +`inbox_message_id`, `filename` (sanitised), `file_path`, `content_sha256`, `created_by`, and +`model` (the `OPENAI_MODEL` that produced the score). + +Results merge back **by slot index, never by filename** — inbox attachments routinely share a +basename like `resume.pdf`. Per-file problems become persisted `status="failed"` rows rather +than sinking the batch, which is a deliberate deviation from the standalone engine's HTTP API +(that one rejects the whole request with 413/415). + +### Routing in code + +**Manual, from the UI:** + +``` +POST /candidate/score (multipart: job_id + files[]) job/app.py +POST /candidate/score_inbox (json: job_id + message_ids[]) job/app.py + -> CandidateScoring.score_uploads / .score_inbox job/candidate/views.py + -> _score_and_persist + build_job_description(job) job/candidate/plugins.py + extract_resume(...) -> normalize_spaced_text(...) app.services.pdf + plugins + score_batch(resumes, job_description=, scorer=, concurrency=) app.services.scoring + Candidates.upsert_candidate(...) per slot job/candidate/models.py + <- serialize_candidate[] sorted score desc, failures last +``` + +**Automatic, two triggers, both idempotent:** + +``` +(a) after every CV match + inbox/tasks.py::match_inbox_message + -> assigned_job_post_id, else suggested_job_post_ids[0] + -> score_message_against_job(record_id, job_id) inbox/tasks.py:25 + guard: a completed (message, job) row exists -> {"status": "already_scored"} + attributes rows to job.created_by (no request user in a worker) + scoring failure is caught and logged; the match result is already committed + +(b) on job assignment + PATCH /inbox/{record_id}/assign-job-post inbox/app.py + -> Email.set_assigned_job_post inbox/views.py:178 + -> enqueue task "inbox.score_message" on the `inbox` queue + broker down -> warning only; the manual button is the fallback +``` + +**Read paths:** + +``` +GET /candidate/scored/fetch?job_id= leaderboard for one job, or the whole pool +GET /candidate/fetch_by_id?candidate_id= +GET /candidate/fetch?user_id= talent-pool profile — CandidateView joins the + candidates rows on inbox_message_id and fills + ai_score / recommendation / scored_job_post_id +``` + +`_recommendation` bands the score to match the frontend: **≥82 Strong Match, ≥65 Potential +Match, else Weak Match**. Where a candidate has several scores, the one against the *assigned* +job post wins, else the most recently updated. + +All three write routes require `candidates.create`; read routes require `candidates.view`. + +### Configuration + +The engine reads its own settings through `app.core.config.get_settings()`, from the same +`.env`, so the shared names line up with what `llm_setup` uses: + +| Variable | Default | Used for | +|---|---|---| +| `OPENAI_MODEL` | `gpt-5.4-mini` | must support structured outputs | +| `OPENAI_MAX_OUTPUT_TOKENS` | `4000` | covers reasoning **and** visible tokens; too low truncates mid-JSON | +| `OPENAI_EFFORT` | `low` | omitted automatically for non-reasoning models | +| `OPENAI_ENABLE_PROMPT_CACHE` | `true` | | +| `SCORING_CONCURRENCY` | `5` | semaphore bound in `score_batch` | +| `MAX_RESUMES_PER_REQUEST` | `50` | 413 above this | +| `MAX_PDF_SIZE_MB` | `10` | per-file precheck | +| `MAX_JD_CHARS` | `30000` | 422 if the rendered JD is larger | +| `MAX_RESUME_CHARS` | `60000` | truncation boundary | + +### What is missing + +- **`ats_results` has no writer.** The table and the `AtsResults` model exist with a full + supersede chain (`is_current`, `superseded_by_id`), but nothing in `backend/` references it + beyond the class definition, and it holds 0 rows. Either wire it up as the score history + (`candidates` currently overwrites in place on re-score, so history is lost) or drop it. +- **`inbox_messages.ats_score` / `ats_band` are never written.** `serialize_application` + returns them on the Applications tab, so they are always `null` there, even for a candidate + that *has* a completed score in `candidates`. Either denormalise on write in + `_score_and_persist`, or have `serialize_application` join like `CandidateView` already does. +- **Re-scoring destroys the previous result.** `upsert_candidate` matches on + (`job_id`, `content_sha256`) and updates in place, so there is no record that the score + changed or which model produced the earlier one. +- **DOC/DOCX CVs cannot be scored.** They are decoded and stored, but `score_inbox` prechecks + them to `UNSUPPORTED_FILE_TYPE`; only PDFs reach the engine. +- **No `job_id` back-reference on the message.** The auto-score picks + `suggested_job_post_ids[0]` when nothing is assigned, but does not record which job it chose; + you have to read `candidates` to find out. +- **The engine's own test suite (repo-root `tests/`) does not cover the backend wrapper.** + Nothing tests `build_job_description`, the slot-merge, or the upsert. + +--- + ## External integrations | Service | Used by | Contract | @@ -622,7 +1002,23 @@ psql "$DATABASE_URL" -f migrations/manual/001_dashboard_rbac_and_enum.sql system roles that need the dashboard, seeds the eleven BRD `source_channels`, and backfills `source_channel_id` / stage-transition / requisition-status rows. -``` +> **Run it with the session timezone set to UTC.** The file writes `NOW()` into columns of +> both kinds. A client whose session timezone is not UTC stores a shifted wall clock in any +> naive column and a correct instant in the `timestamptz` ones, which is how the current dev +> data ended up with `source_channels` rows seven hours off from the +> `application_stage_transitions` rows written by the same transaction. The database's own +> default (`pg_settings.reset_val`) is UTC; it is the client that overrides it. +> ```bash +> PGTZ=UTC psql "$DATABASE_URL" -f migrations/manual/001_dashboard_rbac_and_enum.sql +> ``` + +> **`migrations/versions/*.py` is effectively git-ignored.** `.gitignore` line 56 carries the +> pattern `**_**_**.py`, which matches every generated revision filename +> (`20260812_1035-b3f1c2d4e5a6_inbox_timestamps_tz_aware.py` and friends). Only the four +> revisions committed before that rule landed are tracked — **14 of the 18 on disk are not**, +> so a fresh clone cannot reach head. Combined with `DB_AUTOGENERATE=true`, each developer's +> instance invents its own revision ids for the same schema change and the histories diverge. +> Fix the pattern and commit the missing revisions before anyone else clones this branch. Migrations run under a Postgres advisory lock, so several workers booting at once cannot migrate concurrently. Empty revisions are suppressed. Alembic's own `alembic_version` table is @@ -688,9 +1084,26 @@ Serializers always `str()` UUIDs, `.isoformat()` datetimes, and never emit `pass only against the upstream Email API token. - **`.doc` / `.docx` résumés are decoded and stored but not parsed.** `extract_resume_text` handles PDFs only and reports `no PDF attachment to extract` for the rest. -- **`serialize_application` returns `null` for `ats_score`, `phone`, `recruiter` and - `duplicate`** — `inbox_messages` has no columns for them yet, and `processing` is derived - from `message_read` alone, so it is only ever `"Read"` or `"Unread"`. +- **`serialize_application` returns `null` for `ats_score` / `ats_band`.** The columns now + exist on `inbox_messages` and the serializer reads them, but **nothing ever writes them** — + the ATS persists to `candidates` instead. The Applications tab therefore shows no score even + for candidates that have one. `processing` is still derived from `message_read` alone, so it + is only ever `"Read"` or `"Unread"`; `Imported`/`Processed`/`Rejected` need + `processing_state` to be written. +- **`ats_results` is a dead table** — declared, migrated, 0 rows, no reader and no writer. See + [The ATS scoring engine](#what-is-missing). +- **Recruiter Performance is empty until a user holds the `recruiter` role.** The query starts + from `Users JOIN Roles WHERE role_name = 'recruiter'`, so with no such user the widget + renders empty no matter how much other data exists. Its `hires` column additionally needs + `inbox_messages.recruiter_id`, for which **there is no endpoint** — the column is only ever + set from `created_by` while a candidate is being created. +- **`application_stage_transitions` rows created by `migrations/manual/001` are timestamp- + skewed** if the file was run under a non-UTC psql session — the backfill writes the naive + `inbox.created_at` into a `timestamptz` column. On the current dev database they sit 12 hours + off, which skews the *hires* series of the hiring-trend chart and every time-to-hire average. +- **The frontend's chart error state is misleading.** `Dashboard.jsx` appends "This widget + needs the `analytics.view` permission" to *every* error, including a 500, so a server fault + reads as a permissions problem. - **`Inbox.get_candidate_profile` filters on `cls.user.role_id`**, which is a relationship attribute rather than a joined column; the candidate-profile query needs a join before it behaves as intended. From c4104c0cfe1b3f4c44b50cc4418faca4d7a6cf1d Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 12 Aug 2026 16:49:09 +0500 Subject: [PATCH 3/4] . --- backend/README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/README.md b/backend/README.md index bd0d2f6..94128f9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1002,7 +1002,6 @@ psql "$DATABASE_URL" -f migrations/manual/001_dashboard_rbac_and_enum.sql system roles that need the dashboard, seeds the eleven BRD `source_channels`, and backfills `source_channel_id` / stage-transition / requisition-status rows. -<<<<<<< HEAD > **Run it with the session timezone set to UTC.** The file writes `NOW()` into columns of > both kinds. A client whose session timezone is not UTC stores a shifted wall clock in any > naive column and a correct instant in the `timestamptz` ones, which is how the current dev @@ -1020,9 +1019,6 @@ backfills `source_channel_id` / stage-transition / requisition-status rows. > so a fresh clone cannot reach head. Combined with `DB_AUTOGENERATE=true`, each developer's > instance invents its own revision ids for the same schema change and the histories diverge. > Fix the pattern and commit the missing revisions before anyone else clones this branch. -======= -``` ->>>>>>> c283ac0e50dbe497671957e7fb064edb75f5988b Migrations run under a Postgres advisory lock, so several workers booting at once cannot migrate concurrently. Empty revisions are suppressed. Alembic's own `alembic_version` table is From 7239b2ccad6ac287df9688ad7d87263a7e1cfda1 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 12 Aug 2026 16:51:41 +0500 Subject: [PATCH 4/4] update --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c9fae03..af3e05c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ Icon ? ._* - +dist/** # Editor / IDE .idea/ .vscode/