diff --git a/backend/README.md b/backend/README.md index 94128f9..bc4307f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -82,9 +82,7 @@ See [Routing in code](#routing-in-code). | 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`) | +| **Re-scoring overwrites the `candidates` row** | `upsert_candidate` updates in place on (`job_id`, `content_sha256`); the score history survives in `ats_results`, which every completed score (inbox and upload) appends to | | **`.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 @@ -192,7 +190,7 @@ backend/ ├── Dockerfile # image for the Taskiq worker / scheduler ├── alembic.ini # generated by alembic_setup.py, not hand-written ├── migrations/ # generated env.py + versions/ -│ └── manual/ # one-shot SQL (enum labels, RBAC seed, backfills) +│ └── manual/ # one-shot SQL (enum labels, RBAC seed, backfills) — auto-applied at startup ├── LLM_CONTEXT_PROMPT.md # house-style prompt to paste into an LLM before editing │ ├── users/ # accounts, login, signup, RBAC enforcement @@ -355,7 +353,7 @@ indexes, constraints and foreign keys. `SQLModel.metadata` is pointed at `Base.m | `permissions` | `id`, `name` (unique), `permission_tags` (JSONB int[]) | Named bundles | | `permission_tags` | `id`, `tag_name` (unique), `module`, `action` | Unique on (`module`, `action`) | | `inbox_messages` | `id` (uuid), `message_id` (upstream id, unique), `full_email_response` (JSONB), subject/body/from/to/cc/bcc, `message_read`, `attachment`, `file_name`, `file_path`, `application_status`, `resume_text`, `experience`, `suggested_job_post_ids` (JSONB), `match_summary`, `match_reasoning`, `match_status`, `match_error`, `matched_at` | One row per mail; agent output lands here | -| `inbox` | `id`, `user_id` → `users.id`, `message_id` → `inbox_messages.id`, `alert_id` | Join table linking a candidate to a message | +| `inbox` | `id`, `user_id` → `users.id`, `message_id` → `inbox_messages.id`, `alert_id`, `ats_id` → `ats_results.id` | Join table linking a candidate to a message. `ats_id` always points at the CURRENT `ats_results` row, repointed on every completed inbox score | | `inbox_alerts` | `id`, `alert_sender_name`, `alert_sender_email`, `is_read` | | | `job_posts` | `id` (uuid), `title`, `platform`, `channel_id`, `post_text`, `requirements`/`optional_skills` (JSON), `status`, `buffer_post_id`, `buffer_external_link`, `buffer_sent_at`, `buffer_error`, `created_by` → `users.id` | | | `password_reset_codes` | `id`, `email`, `code_hash`, `expires_at`, `attempts`, `is_used`, `verified_at` | | @@ -376,12 +374,13 @@ Nine tables landed together with `analytics/`, `offer/`, `job/assignment/`, `job | `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) | +| `ats_results` *(inbox)* | `id`, `inbox_id` (nullable), `candidate_id` → `candidates.id`, `job_post_id`, `overall_score`, `band`, `is_current`, `superseded_by_id`, `model_name`, `computed_at` | Score history for EVERY completed score. Inbox scores chain per application (`inbox_id`, via `_sync_inbox_ats`); upload scores chain per `candidates` row (`inbox_id` NULL, via `_sync_upload_ats`). The previous current row is superseded (`is_current=false`, `superseded_by_id`); `migrations/manual/002` backfills pre-existing scores | `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. +`recruiter_id`, `is_duplicate`, `source_channel_id`, `processing_state`. `ats_score` and +`ats_band` are written by `CandidateScoring._sync_inbox_ats` on every completed inbox-sourced +score (assigned-job score wins; bands: ≥82 Strong Match, ≥65 Potential Match, else Weak Match) +and read by `serialize_application` on the Applications tab. `application_status` is a `str` enum, extended by `migrations/manual/001`: `PROCESS`, `PENDING`, `APPROVED`, `REJECTED`, `ONHOLD`, `CLOSED`, `SCREENING`, `ASSESSMENT`, `INTERVIEW`, @@ -647,9 +646,10 @@ this database; the backend wraps it and owns all persistence. - `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). +So the scoring is agentic, but the output is fully relational. Inbox-sourced scores are +additionally denormalised by `_sync_inbox_ats` onto `inbox_messages.ats_score` / `ats_band` +and appended to `ats_results` as a supersede-chained history — see +[where the values land](#where-the-values-land). ### What the system gives the engine @@ -718,6 +718,26 @@ basename like `resume.pdf`. Per-file problems become persisted `status="failed"` 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). +**Every completed score also lands in `ats_results`.** Upload-sourced scores go through +`_sync_upload_ats`: `inbox_id` stays NULL (there is no inbox application), the row links via +`candidate_id`, and re-scoring the same bytes supersedes the previous current row — the chain +is stable because `upsert_candidate` keeps the same `candidates.id` for the same job+file. + +**Inbox scores additionally land on the inbox tables** (`_sync_inbox_ats`, called once per +message with its best completed score of the batch): + +- `inbox_messages.ats_score` / `ats_band` — the denormalised columns the Applications tab + reads. A score against the *assigned* job always wins them; a score against any other job + only lands while no completed assigned-job score exists (mirroring `_recommendation`). +- `ats_results` — one history row per scoring event, `is_current=true`; the previous current + row flips to `is_current=false` with `superseded_by_id` pointing at its successor, and + `inbox.ats_id` is repointed at the new row so the current score is one direct id join away. + Requires the `inbox` join row (the sender must be linked to a `users` account); without it + only the denormalised columns are written. +- A sync failure is rolled back and logged, never propagated — the `candidates` row is the + primary outcome and is already committed. Pre-existing scores are backfilled by + `migrations/manual/002_backfill_inbox_ats.sql`. + ### Routing in code **Manual, from the UI:** @@ -787,17 +807,9 @@ The engine reads its own settings through `app.core.config.get_settings()`, from ### 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. +- **`candidates` itself keeps only the latest result.** `upsert_candidate` matches on + (`job_id`, `content_sha256`) and updates in place; the full score history lives in + `ats_results`, which both `_sync_inbox_ats` and `_sync_upload_ats` append to. - **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 @@ -990,11 +1002,17 @@ python alembic_setup.py head Alembic autogenerate does **not** detect new PostgreSQL enum labels. Permission-tag rows and analytics role bundles are also seeded out-of-band. Those live in -`migrations/manual/` and must be run by hand in psql (autocommit for `ADD VALUE`): +`migrations/manual/` and **apply themselves at startup**: after upgrade + autogenerate, +`alembic_setup.run_manual_sql()` executes every `migrations/manual/*.sql` in filename order, +once per database, tracked in the `manual_migrations` table (filename PK, `applied_at`) and +serialised under the same advisory lock as the boot migration. Pulling the repo and booting +the API is enough — no psql session needed. The files stay idempotent regardless, so a +database where one was already run by hand simply absorbs one harmless re-run while it gets +recorded. ```bash -# After `python alembic_setup.py upgrade` has created the new tables/columns: -psql "$DATABASE_URL" -f migrations/manual/001_dashboard_rbac_and_enum.sql +# Equivalent manual run, only if ever needed: +PGTZ=UTC psql "$DATABASE_URL" -f migrations/manual/001_dashboard_rbac_and_enum.sql ``` `001_dashboard_rbac_and_enum.sql` extends `candidate_application_status`, seeds all 104 @@ -1002,15 +1020,12 @@ 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 -> ``` +> **Timezone.** The files write `NOW()` into columns of both kinds. The startup runner is +> safe here: asyncpg leaves the server's UTC default alone. The trap is manual psql runs — +> psql adopts the client OS timezone, storing 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. If you must run one by hand, set `PGTZ=UTC` as above. > **`migrations/versions/*.py` is effectively git-ignored.** `.gitignore` line 56 carries the > pattern `**_**_**.py`, which matches every generated revision filename diff --git a/backend/alembic_setup.py b/backend/alembic_setup.py index fd4c52c..8823770 100644 --- a/backend/alembic_setup.py +++ b/backend/alembic_setup.py @@ -171,6 +171,7 @@ def config(connection: Connection | None = None) -> Config: VERSION_TABLE = "alembic_version" +MANUAL_TABLE = "manual_migrations" def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool: @@ -178,7 +179,7 @@ def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to s = get_settings() if type_ != "table": return True - if name == VERSION_TABLE: # Alembic's own bookkeeping; never ours to alter + if name in (VERSION_TABLE, MANUAL_TABLE): # migration bookkeeping; never ours to alter return False return not s.db_schemas or (obj.schema or s.db_default_schema) in s.db_schemas @@ -258,6 +259,42 @@ async def autogenerate(message: str = "auto") -> str | None: return after if after != before else None +async def run_manual_sql() -> None: + """Apply `migrations/manual/*.sql` once per database, in filename order. + + This is what lets a developer just pull and boot: the one-shot data + migrations (enum labels, RBAC seed, backfills) apply themselves instead of + requiring a psql session. The files are written idempotent, but a tracking + table pins each to a single application per database; a file already run by + hand before this runner existed re-runs once (harmlessly) to get recorded. + + Runs on the raw asyncpg connection: the files are multi-statement psql + batches, which the prepared-statement path cannot execute. + """ + files = sorted(p for p in (MIGRATIONS / "manual").glob("*.sql") if p.is_file()) + if not files: + return + schema = get_settings().db_default_schema + table = f'"{schema}".{MANUAL_TABLE}' if schema else MANUAL_TABLE + async with get_engine().connect() as conn: + raw = await conn.get_raw_connection() + driver = raw.driver_connection + await driver.execute( + f"CREATE TABLE IF NOT EXISTS {table} (" + " filename text PRIMARY KEY," + " applied_at timestamptz NOT NULL DEFAULT now())" + ) + applied = {r["filename"] for r in await driver.fetch(f"SELECT filename FROM {table}")} + for path in files: + if path.name in applied: + continue + # utf-8-sig: Windows editors save SQL with a BOM, which would + # otherwise reach Postgres glued onto the first statement. + await driver.execute(path.read_text(encoding="utf-8-sig")) + await driver.execute(f"INSERT INTO {table} (filename) VALUES ($1)", path.name) + logger.info("applied manual migration %s", path.name) + + @asynccontextmanager async def _lock() -> AsyncIterator[None]: """Advisory lock, so only one worker migrates when several boot at once.""" @@ -271,12 +308,13 @@ async def _lock() -> AsyncIterator[None]: async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None: - """Apply pending revisions, then any fresh model drift, under the lock.""" + """Apply pending revisions, fresh model drift, then manual SQL, under the lock.""" should_autogen = get_settings().db_autogenerate if autogen is None else autogen async with _lock(): await upgrade() if should_autogen and await autogenerate(message): await upgrade() + await run_manual_sql() logger.info("database at revision %s", await current()) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 3b32dca..0077623 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -56,6 +56,12 @@ class Inbox(SQLModel, table=True): favorite: Optional[bool] = Field(default=False) rating: Optional[float] = Field(default=0.0) + # The CURRENT ats_results row for this application. Written together with the + # supersede chain in CandidateScoring._sync_inbox_ats: every completed inbox + # score inserts an ats_results row and repoints this at it, so the score is + # one direct id join away instead of a filter on ats_results.is_current. + ats_id: uuid.UUID | None = Field(default=None, foreign_key="ats_results.id") + # selectin on one-to-many: joined would repeat the inbox row per child interviews: List["Interviews"] = Relationship( back_populates="inbox", @@ -601,7 +607,10 @@ class AtsResults(SQLModel, table=True): __tablename__ = "ats_results" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - inbox_id: int = Field(index=True, foreign_key="inbox.id") + # Inbox applications link here; NULL for upload-sourced scores, which have no + # inbox row. candidate_id links every score to the candidates row it scored. + inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id") + candidate_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="candidates.id") job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") overall_score: float = Field(default=0.0) band: str = Field(default="") @@ -632,6 +641,18 @@ class AtsResults(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def get_current_for_candidate(cls, session: AsyncSession, candidate_id): + """Current row for an upload-sourced score, chained per candidates row — + stable across re-scores because upsert_candidate keeps the same id for + the same (job, content_sha256).""" + result = await session.execute( + select(cls) + .where(cls.candidate_id == candidate_id, cls.is_current == True) # noqa: E712 + .order_by(cls.computed_at.desc()) + ) + return result.scalars().first() + @classmethod async def insert_result(cls, session: AsyncSession, fields: dict): row = cls(**fields) diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 62d3167..b9f0cb1 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -12,7 +12,7 @@ from app.core.errors import ATSError,ErrorCode from app.models.scoring import CompletedCandidate from app.services.pdf import extract_resume,sanitize_filename from app.services.scoring import score_batch -from inbox.models import Inbox_Messages,Inbox +from inbox.models import Inbox_Messages,Inbox,AtsResults from job.candidate.models import Candidates from job.candidate.plugins import ( FILE_NOT_FOUND, @@ -407,6 +407,35 @@ class CandidateScoring: fields={**results_by_slot[slot],**common} rows.append(await Candidates.upsert_candidate(self.session,fields)) + # Every completed score lands in ats_results. Inbox scores additionally + # denormalise onto inbox_messages / inbox (one sync per message: a + # multi-attachment mail keeps its best completed score); upload scores + # chain per candidates row instead — they have no inbox application. + if source_kind=="inbox": + best={} + for row in rows: + if row.status=="completed" and row.inbox_message_id: + cur=best.get(row.inbox_message_id) + if cur is None or (row.match_score or 0)>(cur.match_score or 0): + best[row.inbox_message_id]=row + for message_id,row in best.items(): + try: + await self._sync_inbox_ats(message_id,job,row) + except Exception: + # The candidates row is the primary outcome and is already + # committed; a denorm failure must not fail the scoring call. + await self.session.rollback() + logger.exception("inbox ATS denorm failed for message %s",message_id) + else: + for row in rows: + if row.status!="completed": + continue + try: + await self._sync_upload_ats(job,row) + except Exception: + await self.session.rollback() + logger.exception("upload ATS history failed for candidate %s",row.id) + # Leaderboard order: completed by score desc, failures last, stable. rows.sort(key=lambda r:(0,-(r.match_score or 0)) if r.status=="completed" else (1,0)) return [serialize_candidate(row) for row in rows] @@ -437,6 +466,92 @@ class CandidateScoring: "summary_critique":None, } + async def _sync_inbox_ats(self,message_id,job,row): + """Land a completed score on the inbox tables (README "What is missing"). + + inbox_messages.ats_score / ats_band are what serialize_application renders + on the Applications tab; ats_results keeps the per-application history that + candidates' in-place upsert cannot. Precedence mirrors _recommendation: a + score against the assigned job always wins the denormalised columns, any + other job's score only lands while no completed assigned-job score exists. + The history row is appended regardless — it records the scoring event. + """ + msg=await Inbox_Messages.get_inbox_message_by_id(self.session,message_id) + if msg is None: + return + band=CandidateView._recommendation(row.match_score) or "" + + denorm=True + assigned=msg.assigned_job_post_id + if assigned and str(assigned)!=str(job.id): + outranked=await self.session.execute( + select(Candidates).where( + Candidates.inbox_message_id==msg.id, + Candidates.job_id==assigned, + Candidates.status=="completed", + ) + ) + denorm=outranked.scalars().first() is None + if denorm: + msg.ats_score=float(row.match_score) + msg.ats_band=band + self.session.add(msg) + + # ats_results hangs off the inbox JOIN row (int PK), which only exists once + # the sender is linked to a users account; without it there is no history row. + link=await Inbox.get_inbox_by_message_id(self.session,message_id) + if link is not None: + prev=await AtsResults.get_current_for_inbox(self.session,link.id) + entry=AtsResults( + inbox_id=link.id, + candidate_id=row.id, + job_post_id=job.id, + overall_score=float(row.match_score), + band=band, + model_name=row.model, + is_current=True, + ) + self.session.add(entry) + # Flush the INSERT before touching prev/link: with no relationship() + # edge the unit of work emits the UPDATEs first, and both FKs + # (superseded_by_id, inbox.ats_id) reject a pointer to a row that is + # not inserted yet. + await self.session.flush() + if prev is not None: + prev.is_current=False + prev.superseded_by_id=entry.id + self.session.add(prev) + # inbox.ats_id always points at the CURRENT score row. + link.ats_id=entry.id + link.updated_at=datetime.now(timezone.utc) + self.session.add(link) + await self.session.commit() + + async def _sync_upload_ats(self,job,row): + """History row for an upload-sourced score — no inbox application exists, + so inbox_id stays NULL and the supersede chain runs per candidates row + (stable across re-scores: upsert keeps the id for the same job+bytes).""" + band=CandidateView._recommendation(row.match_score) or "" + prev=await AtsResults.get_current_for_candidate(self.session,row.id) + entry=AtsResults( + inbox_id=None, + candidate_id=row.id, + job_post_id=job.id, + overall_score=float(row.match_score), + band=band, + model_name=row.model, + is_current=True, + ) + self.session.add(entry) + # Same flush-before-pointing rule as the inbox path: the FK on + # superseded_by_id must see the new row inserted first. + await self.session.flush() + if prev is not None: + prev.is_current=False + prev.superseded_by_id=entry.id + self.session.add(prev) + await self.session.commit() + class CandidateView: def __init__(self,session:AsyncSession): diff --git a/backend/migrations/manual/002_backfill_inbox_ats.sql b/backend/migrations/manual/002_backfill_inbox_ats.sql new file mode 100644 index 0000000..c8d1d4a --- /dev/null +++ b/backend/migrations/manual/002_backfill_inbox_ats.sql @@ -0,0 +1,143 @@ +-- 002_backfill_inbox_ats.sql +-- Manual one-shot: backfill the inbox-side ATS columns that gained a writer in +-- job/candidate/views.py::CandidateScoring._sync_inbox_ats. Before that change, +-- inbox_messages.ats_score / ats_band were never written and ats_results held +-- 0 rows, so messages scored earlier show null on the Applications tab. +-- +-- Idempotent: the UPDATE only touches rows still missing a score, and the +-- INSERT skips any application that already has an ats_results row. +-- Applied automatically at startup by alembic_setup.run_manual_sql() once the +-- schema is at head; recorded in manual_migrations. Safe to re-run by hand. + +-- Winning score per message, mirroring CandidateView._recommendation precedence: +-- the completed score against the ASSIGNED job wins, else the newest completed. +WITH ranked AS ( + SELECT + c.inbox_message_id, + c.job_id, + c.match_score, + c.model, + c.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY c.inbox_message_id + ORDER BY (c.job_id = m.assigned_job_post_id) DESC NULLS LAST, + c.updated_at DESC + ) AS rn + FROM app.candidates c + JOIN app.inbox_messages m ON m.id = c.inbox_message_id + WHERE c.status = 'completed' + AND c.inbox_message_id IS NOT NULL +) +UPDATE app.inbox_messages m +SET ats_score = r.match_score, + ats_band = CASE + WHEN r.match_score >= 82 THEN 'Strong Match' + WHEN r.match_score >= 65 THEN 'Potential Match' + ELSE 'Weak Match' + END +FROM ranked r +WHERE r.rn = 1 + AND m.id = r.inbox_message_id + AND m.ats_score IS NULL; + +-- One current ats_results row per already-scored application. computed_at takes +-- the candidates row's timestamp so the history reflects when the score happened. +-- inbox can hold several join rows per message; DISTINCT ON keeps the newest. +WITH ranked AS ( + SELECT + c.inbox_message_id, + c.job_id, + c.match_score, + c.model, + c.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY c.inbox_message_id + ORDER BY (c.job_id = m.assigned_job_post_id) DESC NULLS LAST, + c.updated_at DESC + ) AS rn + FROM app.candidates c + JOIN app.inbox_messages m ON m.id = c.inbox_message_id + WHERE c.status = 'completed' + AND c.inbox_message_id IS NOT NULL +), +links AS ( + SELECT DISTINCT ON (message_id) message_id, id AS inbox_id + FROM app.inbox + ORDER BY message_id, created_at DESC +) +INSERT INTO app.ats_results + (id, inbox_id, job_post_id, overall_score, band, is_current, + superseded_by_id, model_name, computed_at, created_at) +SELECT + gen_random_uuid(), + l.inbox_id, + r.job_id, + r.match_score, + CASE + WHEN r.match_score >= 82 THEN 'Strong Match' + WHEN r.match_score >= 65 THEN 'Potential Match' + ELSE 'Weak Match' + END, + true, + NULL, + r.model, + r.updated_at, + NOW() +FROM ranked r +JOIN links l ON l.message_id = r.inbox_message_id +WHERE r.rn = 1 + AND NOT EXISTS ( + SELECT 1 FROM app.ats_results a WHERE a.inbox_id = l.inbox_id + ); + +-- inbox.ats_id -> the CURRENT ats_results row for that application, so the +-- score is one direct id join away. Newest current row wins if several exist. +UPDATE app.inbox i +SET ats_id = a.id, + updated_at = NOW() +FROM ( + SELECT DISTINCT ON (inbox_id) inbox_id, id + FROM app.ats_results + WHERE is_current AND inbox_id IS NOT NULL + ORDER BY inbox_id, computed_at DESC +) a +WHERE a.inbox_id = i.id + AND i.ats_id IS DISTINCT FROM a.id; + +-- Upload-sourced scores get history rows too: inbox_id stays NULL (no inbox +-- application exists) and the row links via candidate_id instead. +INSERT INTO app.ats_results + (id, inbox_id, candidate_id, job_post_id, overall_score, band, is_current, + superseded_by_id, model_name, computed_at, created_at) +SELECT + gen_random_uuid(), + NULL, + c.id, + c.job_id, + c.match_score, + CASE + WHEN c.match_score >= 82 THEN 'Strong Match' + WHEN c.match_score >= 65 THEN 'Potential Match' + ELSE 'Weak Match' + END, + true, + NULL, + c.model, + c.updated_at, + NOW() +FROM app.candidates c +WHERE c.status = 'completed' + AND c.source = 'upload' + AND NOT EXISTS ( + SELECT 1 FROM app.ats_results a WHERE a.candidate_id = c.id + ); + +-- Older inbox history rows predate candidate_id; link the current ones back to +-- the candidates row that produced them. +UPDATE app.ats_results a +SET candidate_id = c.id +FROM app.inbox i +JOIN app.candidates c ON c.inbox_message_id = i.message_id AND c.status = 'completed' +WHERE a.inbox_id = i.id + AND a.candidate_id IS NULL + AND c.job_id = a.job_post_id; diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 6144436..b5afe7f 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -5,7 +5,8 @@ toCandidateView mapper. Facets, columns and actions that had no backing column (stage, recruiter, notice period, favourites…) are gone rather than rendered as placeholders — the Inbox screen set that precedent. Adding - candidates happens through CV Import (real scoring), not a manual form. + candidates happens through CV Import or the Add Candidate modal below — + both run the CV through the same persisted ATS scoring pipeline. ============================================================ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -201,6 +202,26 @@ export default function Candidates() { setAtsFor(c) } + /* After a manual add, the CV goes through the same persisted scoring pipeline + CV Import and the profile ATS match use (POST /candidate/score): the score + lands in the scored `candidates` table, and re-uploading the same bytes + against the same job updates that row rather than duplicating it. The + mutation lives here, not in AddCandidate, because the modal closes on save + and an unmounted component's mutation callbacks never fire. */ + const scoreCv = useMutation({ + mutationFn: ({ jobPostId, file }) => candidatesApi.scoreUploads(jobPostId, [file]), + onSuccess: (res) => { + const row = Array.isArray(res?.data) ? res.data[0] : null + if (row?.status === 'completed') { + toast(`CV scored ${row.match_score}/100 against the applied job — saved to the pool`, 'success') + } else { + toast(`CV could not be scored${row?.error_code ? ` — ${row.error_code}` : ''}`, 'warning') + } + qc.invalidateQueries({ queryKey: qk.candidates.all() }) + }, + onError: (err) => toast(friendlyAuthError(err, 'Candidate saved, but CV scoring failed'), 'error'), + }) + return (
@@ -377,9 +398,10 @@ export default function Candidates() { {adding && ( setAdding(false)} - onSave={() => { + onSave={({ jobPostId, file } = {}) => { setAdding(false) toast('Candidate added to pipeline', 'success') + if (jobPostId && file) scoreCv.mutate({ jobPostId, file }) }} onInvalid={() => toast('Please fix the highlighted fields', 'error')} /> @@ -477,6 +499,62 @@ export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) { ) } +/** + * One selectable role, drawn the way Job Matching draws its suggested roles + * (Matching.jsx::JobCard) minus the AI-rank tag and resume highlighting — the + * CV is parsed server-side after submit, so there is no extracted text to + * light requirement chips against yet. + */ +function RoleCard({ post, selected, onSelect, disabled }) { + const meta = [ + post?.employment_type, + post?.location, + post?.experience_min != null || post?.experience_max != null + ? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs` + : null, + ].filter(Boolean).join(' · ') + + return ( +
!disabled && onSelect(String(post.id))} + onKeyDown={(e) => { + if (disabled) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect(String(post.id)) + } + }} + style={{ + cursor: disabled ? 'default' : 'pointer', + borderColor: selected ? 'var(--primary)' : undefined, + boxShadow: selected ? 'var(--ring)' : undefined, + marginBottom: 8, + alignItems: 'flex-start', + }} + > +
+
+
{post.title}
+ {post.status || 'draft'} + {selected && } +
+ {meta &&
{meta}
} + {(post.requirements || []).length > 0 && ( +
+ {(post.requirements || []).slice(0, 8).map((req) => ( + {req} + ))} +
+ )} +
+
+ ) +} + /** * Add Candidate — the only writer on this screen that reaches the server. * @@ -492,6 +570,11 @@ export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) { * Manual rows do not pass through `inbox`, so /candidate/fetch may not surface * them immediately; the save still invalidates the candidates query so the * live-backed screens refetch and pick the row up once an application links it. + * + * On success the CV and job go back to the parent (onSave), which scores the + * file against that job via POST /candidate/score. The Matching-style role + * cards above the dropzone are that "what to score against" choice — which is + * why the picker sits with the CV rather than among the identity fields. */ function AddCandidate({ onClose, onSave, onInvalid }) { const { toast } = useToast() @@ -520,14 +603,28 @@ function AddCandidate({ onClose, onSave, onInvalid }) { // field from an effect would either loop or need a ref to guard it. const jobPostId = form.values.job || (posts[0] ? String(posts[0].id) : '') + const [roleSearch, setRoleSearch] = useState('') + // Client-side filter: the posts are already fetched, and a PickRoleModal-style + // server search would refetch on every keystroke for the same rows. + const visiblePosts = useMemo(() => { + const needle = roleSearch.trim().toLowerCase() + if (!needle) return posts + return posts.filter((p) => ( + (p.title || '').toLowerCase().includes(needle) + || (p.location || '').toLowerCase().includes(needle) + )) + }, [posts, roleSearch]) + const create = useMutation({ mutationFn: (vars) => candidatesApi.createManual(vars), onError: (err) => toast(friendlyAuthError(err, 'Could not add the candidate.'), 'error'), - onSuccess: () => { + onSuccess: (_res, vars) => { // The new user_id lands in /candidate/fetch's join the moment an // application exists for them, so let the live-backed screens refetch. qc.invalidateQueries({ queryKey: qk.candidates.all() }) - onSave() + // Hand the file and job back so the parent can score the CV — from the + // mutate vars, not local state, so a mid-flight field edit cannot skew it. + onSave({ jobPostId: vars.jobPostId, file: vars.file }) }, }) @@ -610,24 +707,6 @@ function AddCandidate({ onClose, onSave, onInvalid }) { {form.errors.email}
-
- - - {form.errors.job} -
@@ -658,7 +737,57 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
- {/* .req is scoped to `.form-field label .req`, so tint it here. */} + {/* Role selection sits directly above the CV because it is what the CV + gets scored against — same card UI as Job Matching's role list. + (.req is scoped to `.form-field label .req`, so tint it here.) */} +
+ Applied Job * +
+ {posts.length > 0 && ( +
+ + setRoleSearch(e.target.value)} + placeholder="Search title or location…" + /> +
+ )} + {postsQuery.isPending && ( + Fetching open job posts. + )} + {postsQuery.isError && ( + + {friendlyAuthError(postsQuery.error, 'Request failed')} + + )} + {postsQuery.isSuccess && posts.length === 0 && ( + + The candidate is saved without a role link — create a job post first to score CVs. + + )} + {visiblePosts.length > 0 && ( +
+ {visiblePosts.map((p) => ( + form.setField('job', id)} + disabled={create.isPending} + /> + ))} +
+ )} + {postsQuery.isSuccess && posts.length > 0 && visiblePosts.length === 0 && ( + Try a different search. + )} + {form.errors.job} +
CV / Resume *