add Readme.md update

pull/12/head
ahmed.mujtaba 2026-08-12 16:45:37 +05:00
parent 6f7cfb42a7
commit 6dadbbc472
1 changed files with 425 additions and 12 deletions

View File

@ -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` (060), `match_score` (0100, required), `matched_keywords` /
`missing_keywords` (≤30, deduplicated), `summary_critique` (1500 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` | 060; a stated total wins, else computed from explicit dates, else null |
| `match_score` | `int` | **0100, required** |
| `matched_keywords` | `list[str]` | ≤30, deduplicated case-insensitively |
| `missing_keywords` | `list[str]` | ≤30, JD-side wording |
| `summary_critique` | `str` | 1500 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.