diff --git a/.gitignore b/.gitignore index c9fae03..af3e05c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ Icon ? ._* - +dist/** # Editor / IDE .idea/ .vscode/ 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.