Deploy to S3 / checks (push) Failing after 2m24sDetails
Deploy to S3 / deploy (push) Has been skippedDetails
The bank used to be write-only: a CV uploaded with no job carried only its
full text, so nothing could search or rank it. Now the employment agent's
extraction (title, company, education, plus new skills and years_experience,
both clamped to what the resume actually states) is stored on the row, and
the bank is ranked against a job the moment that job opens.
- New CV Bank screen at /cvbank replaces Talent Pool; the inline bank card
moves out of CV Import. One table, two populations: speculative uploads,
and silver medalists (rejected applicants scoring >= CV_BANK_SILVER_FLOOR,
read live from their application rather than copied).
- matching/ranking.py: the tier-1 keyword ranker moves out of
talent/plugins.py so Find Talent and the bank share one implementation;
talent/plugins.py re-exports it and its numbers are unchanged.
- Taskiq tasks in job.candidate.bank_tasks: backfill profiles for CVs
banked before extraction existed, and rank the bank when a job opens so
recruiters are told about matches above CV_BANK_SUGGEST_THRESHOLD.
Retention (CV_BANK_RETENTION_MONTHS) is stamped on the row at upload; the
sweep flags expired rows and never deletes.
- Migrations 029 (bank profile columns) and 030 (per-job bank matches).
- Routes: POST /candidate/cv-bank/score, GET /candidate/cv-bank/suggestions.
- README: The CV Bank, plus the retention and deletion policy.
Also in this change:
- Inbox, Sheet Forms: has_linkedin / has_resume filters, tri-valued so
"no link" is a real filter and NULL rows are kept in it; tab badge counts
now narrow with the list and the search box.
- Hiring-manager candidate rows carry the ATS score and band.
- Tests: analytics dashboard merge logic, employment extraction clamps,
form-data filters, manager candidate serializer, CV Bank mapper.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Added methods to retrieve application history by email across various sources (inbox, manual uploads, form data).
- Introduced new serializers for application history items and overall history.
- Updated candidate and inbox views to include application history in responses.
- Enhanced frontend components to display reapplication badges and previous application details.
- Adjusted API endpoints to support fetching application history based on email input.
Deploy to S3 / deploy (push) Successful in 34sDetails
The recruiter table was user-centric (GET /candidate/fetch/users), so it could
only ever render account fields - name, email, created date. Everything a
recruiter actually triages on lives on the application, not the user.
Point the table at GET /candidate/fetch and map application rows through a new
toApplicationListView, adding Job, ATS (score + band chip), Stage and Recruiter
columns. Stage and band become real filters; the dead Department facet is gone.
Manual uploads came back unscored because the list path never joined the ATS
results, so attach scores there and expose ai_score/recommendation from the
manager serializer, deriving the band from the score when the model omitted it.
Co-authored-by: Cursor <cursoragent@cursor.com>
The recruiter table was user-centric (GET /candidate/fetch/users), so it could
only ever render account fields - name, email, created date. Everything a
recruiter actually triages on lives on the application, not the user.
Point the table at GET /candidate/fetch and map application rows through a new
toApplicationListView, adding Job, ATS (score + band chip), Stage and Recruiter
columns. Stage and band become real filters; the dead Department facet is gone.
Manual uploads came back unscored because the list path never joined the ATS
results, so attach scores there and expose ai_score/recommendation from the
manager serializer, deriving the band from the score when the model omitted it.
Co-authored-by: Cursor <cursoragent@cursor.com>
A CV goes through four steps before it is usable: the intake classifier
decides it is an application, the PDF text is extracted, an agent matches it
against job posts, and only then is an ATS score written. A PDF with no
readable text stops at step two. It is never matched and never scored.
The list said nothing about any of this. The parse badge existed only in the
detail pane, so a dead row looked exactly like a good one until you clicked
it. Across eleven hundred applications that is not discoverable, and the
candidate whose file happened not to open is simply lost.
The row now carries the badge, with two deliberate limits:
- Exceptions only. Parsing, Failed and Pending show; Parsed shows nothing.
A green chip on the overwhelming majority of rows is decoration that
buries the two states worth spotting, and no badge already reads as fine.
The detail pane still shows every state, Parsed included, because there
the row is the whole subject.
- Email only. Sheet Form applicants have no mailbox attachment to parse.
The guard is redundant against today's mapFormRow, which sets no
resumeStatus at all, and is kept as a tripwire rather than a load-bearing
check — the comment says so rather than overclaiming.
"Failed" on its own tells a recruiter nothing to do next, so each state got a
plain-language tooltip saying what happened and that the attachment is still
there to open by hand. That needed Badge to forward `title`, which it did not.
The colour rule was duplicated inline in the detail pane; it is now one
helper both halves call, so the same state cannot paint two colours.
Four assertions added to inbox-loading.test.mjs. Three are proven sensitive
by reverting the change: removing the badge fails the label and tooltip
checks, and badging every state fails the exceptions-only check. The fourth,
that form rows stay unlabelled, passes even without the guard and is marked
in the test as a tripwire, not proof.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening the Recruitment Inbox showed skeleton placeholders every time, and
on the combined All channel it showed them stacked ABOVE rows that had
already arrived — the channel ORs its two sources' pending flags together,
so the email rows sat under placeholders while the sheet fetch finished.
The queue is a local table, not a live feed. Rows only appear when the Sync
worker writes them, and that path already invalidates qk.mailbox.all(). So
refetching on every visit bought nothing and cost a full-width skeleton each
time — worse on the All channel, which fetches BOTH sources unpaged and so
re-downloaded thousands of rows to render ten.
- LIST_CACHE / COUNT_CACHE: 10 min staleTime, 1 h gcTime, keepPreviousData.
A revisit inside the window paints rows immediately and issues no request.
- Placeholders now mean "nothing to show yet": gated on the list being
empty. A refetch over live rows is a 2px bar and the word "Updating…",
with the rows readable and clickable throughout.
- "Updated 4 min ago" + a Refresh button under the search box, because a
cached list that never admits its age reads as a broken list. Refresh
re-reads the DB; Sync pulls new mail from Outlook. Both tooltips now say
which is which — two circular arrows were indistinguishable.
- Search debounced to 300ms. Each keystroke used to mint a query key, a
request and a skeleton, so the list strobed while you typed.
- resume_text dropped from the list row mapping. serialize_application
ships the whole extracted CV on every row and the queue renders none of
it; caching a thousand of them for an hour is not a trade worth making.
The detail query fetches it for the one row actually open.
Trade-off: a sync run started elsewhere is now invisible here for up to ten
minutes. The age label and the Refresh button are the deliberate mitigation.
inbox-loading.test.mjs pins all of it, driving hand-gated fetches so the
frames a fast API flashes past become assertable: both sources pending, one
source home, and a second visit served from cache. Reverting the placeholder
rule reproduces the original defect as "skeleton=true rows=2". Wired into
npm run verify; needed a mountRoute export on the smoke harness, since
renderRoute tears the tree down before those intermediate frames can be read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Migration 027 moves live members of the UI-created Manager role onto
the seeded hiring_manager (which has carried the same
manager_candidates bundle since 024) and soft-deletes it - guarded and
idempotent, the seeded role is never matched.
Access Control no longer lists candidate: it is the applicant account
type every candidate user sits on, managed nowhere near a permission
matrix. The row stays in the DB.
New lib/format.js formatRole renders snake_case role names and
ALL-CAPS status enums as Title Case everywhere users see them -
Access Control, Settings user badges and role picker, the topbar
profile, Candidates role column and export, and the inbox
application-status badges (INTERVIEW -> Interview).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New shared lib/exportXlsx.js: every Export button now downloads a
dashboard-flavoured .xlsx - deep-green title band with lime text, mint
meta row (view, row count, date), ink-teal header row, zebra data rows,
frozen header and autofilter. exceljs is imported dynamically so its
~1MB chunk only downloads when an export is clicked.
- Inbox: exports the loaded view (DB-filtered; no extra request)
- Candidates: the Export button was a stub that only fired a toast -
it now really exports the filtered account list
- Talent Pool: upgraded from plain CSV to the same styled workbook
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Export downloads the current view - channel, tab and search respected,
fetched unpaged - as UTF-8 CSV (BOM for Excel) with both sources'
columns: name, contact, position, channel, source, received, status,
city, notice period, ATS score, assigned job.
All channel resilience: when exactly one source fails, the healthy
list stays with a one-line notice naming the gap; the full error state
now renders only when there are no rows at all (cached rows beat a
scary banner over a working list).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stacked split nested two scroll wells (list and detail each carried
max-height + overflow-y auto) inside the page scroll - scrollbars within
scrollbars on small screens. At <=900px both panes now flow naturally in
the one page scroll, and selecting an application swaps the list out for
the detail pane with a Back to list button (standard master-detail
collapse); selection also scrolls to the top so the detail header is in
view. Desktop keeps the two-pane split unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Standard card language instead of the email-head strips: Contact &
application, Profile and Matching roles are now proper cards with
card-heads. The roles panel collapses three overlapping controls into
one flow - a single Browse roles action, with Assign appearing only
once a selection exists (a permanently disabled primary button read as
broken UI). Sheet provenance moves off the floating header corner into
the Imported from ... row N caption, and the action bar gains a
divider (email pane too, for consistency).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stacked pager read as three loose rows with 'of N' printed twice.
Queue-scoped: drop the duplicate page-size total (the Showing line
already carries it), wrap page-controls with even gaps, and slim the
page buttons so all nine nav controls (4 chevrons + up to 5 numbers)
fit the ~350px split column on one row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The combined stream leads the channel pills (All / Email / Sheet Forms)
and is the channel the inbox opens on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third channel pill beside Email / Sheet Forms. Both sources are fetched
unpaged (their endpoints read a missing top/limit as no LIMIT), merged
newest-first, and paged client-side - per-source skip/top cannot compose
into a correct global page. Rows carry kind (email/form) so the detail
pane, mark-read (email rows only get checkboxes; sheet rows have no
mailbox read state) and the kind-aware state/duplicate mutations all
pick the right endpoint per row. Tabs collapse to the shared set (no
Unread), counts and totals sum both sources, and the sheet filter spans
every sheet tab on this channel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>