Merge pull request 'Link_ATS_with_Sys' (#12) from Link_ATS_with_Sys into main
Reviewed-on: #12pull/13/head^2
commit
b500331fd7
|
|
@ -5,7 +5,7 @@
|
|||
Icon
|
||||
?
|
||||
._*
|
||||
|
||||
dist/**
|
||||
# Editor / IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
```
|
||||
<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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue