From 921d3bf3837043d84cdc3e5f9149fd69605df90f Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Thu, 3 Sep 2026 17:48:28 +0500 Subject: [PATCH] Inbox: label CVs the pipeline could not read, on the row itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/inbox-loading.test.mjs | 50 +++++++++++++++++++++++++++++++++ frontend/src/screens/Inbox.jsx | 46 +++++++++++++++++++++++++++++- frontend/src/ui/primitives.jsx | 10 +++++-- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/frontend/inbox-loading.test.mjs b/frontend/inbox-loading.test.mjs index 55e4a0b..18f276d 100644 --- a/frontend/inbox-loading.test.mjs +++ b/frontend/inbox-loading.test.mjs @@ -65,18 +65,23 @@ dom.window.localStorage.setItem('tf-auth', JSON.stringify({ })) // ---------------------------------------------------------------- fixtures +// resume_status is the pipeline's verdict on the attachment, collapsed by +// _RESUME_STATUS in backend/inbox/serializers.py. One healthy row and one whose +// PDF yielded no text, because the list is supposed to tell those apart. const EMAIL_ROWS = [ { id: '11111111-1111-1111-1111-111111111111', name: 'Ada Lovelace', email: 'ada@example.com', position: 'Backend Engineer', source: 'careers-rozee@example.com', received: '2026-09-02T10:00:00Z', unread: true, processing: 'Unread', + resume_status: 'Parsed', }, { id: '22222222-2222-2222-2222-222222222222', name: 'Grace Hopper', email: 'grace@example.com', position: 'Platform Engineer', source: 'careers-rozee@example.com', received: '2026-09-01T10:00:00Z', unread: false, processing: 'Read', + resume_status: 'Failed', }, ] @@ -171,6 +176,19 @@ const hasSkeleton = (html) => html.includes('skeleton-row') const hasRefreshBar = (html) => html.includes('inbox-refresh-bar') const rowCount = (html) => (html.match(/class="inbox-item/g) || []).length +/** The queue row for one candidate, as a live element — badges and all. */ +function rowFor(root, name) { + for (const el of root.querySelectorAll('.inbox-item')) { + if ((el.querySelector('.ii-name')?.textContent || '').includes(name)) return el + } + return null +} + +const badgesOn = (el) => [...el.querySelectorAll('.badge')].map((b) => b.textContent.trim()) +/** Only the parse-state labels; the read-state and processing badges are noise here. */ +const RESUME_LABELS = ['Parsed', 'Parsing', 'Failed', 'Pending'] +const resumeBadgesOn = (el) => badgesOn(el).filter((t) => RESUME_LABELS.includes(t)) + try { const mod = await import(pathToFileURL(outFile).href) mod.boot() @@ -224,6 +242,38 @@ try { view.text().includes('Updated just now'), ) + // ---- parse state on the row --------------------------------------------- + // A CV whose PDF yielded no text is never matched to a job and never scored. + // Before this, the list rendered it identically to a healthy one and the only + // way to find out was to click it. + const failedRow = rowFor(container, 'Grace Hopper') + const parsedRow = rowFor(container, 'Ada Lovelace') + const formRow = rowFor(container, 'Katherine Johnson') + + check( + 'a CV that could not be read is labelled on the row', + failedRow && resumeBadgesOn(failedRow).includes('Failed'), + `badges=${failedRow ? JSON.stringify(badgesOn(failedRow)) : 'row not found'}`, + ) + check( + 'the label carries a plain-language tooltip', + Boolean(failedRow?.querySelector('.badge.b-red')?.getAttribute('title')), + `title=${JSON.stringify(failedRow?.querySelector('.badge.b-red')?.getAttribute('title') || '')}`, + ) + check( + 'a healthy CV gets no badge, so the label stays a signal', + parsedRow && resumeBadgesOn(parsedRow).length === 0, + `badges=${parsedRow ? JSON.stringify(badgesOn(parsedRow)) : 'row not found'}`, + ) + // Weaker than the three above by nature: mapFormRow sets no resumeStatus, so + // this passes even with the kind guard removed. It is a tripwire for the day + // a form row gains such a field, not proof that the guard is doing work. + check( + 'Sheet Form rows are never labelled — they have no attachment to parse', + formRow && resumeBadgesOn(formRow).length === 0, + `badges=${formRow ? JSON.stringify(badgesOn(formRow)) : 'row not found'}`, + ) + const firstVisitEmailCalls = emailGate.calls await view.unmount() diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 95905f2..048c971 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -330,6 +330,31 @@ const RESUME_STATUS = { failed: 'Failed', dlq: 'Failed', skipped: 'Pending', } +const RESUME_STATUS_CLASS = { Parsed: 'b-green', Failed: 'b-red' } + +/** + * What each parse state means, in words a recruiter can act on. "Failed" alone + * says nothing about what to do next — the attachment is still there to open by + * hand, and that is the point worth making. + */ +const RESUME_STATUS_TIP = { + Parsed: 'Text was read from this CV.', + Parsing: 'Still reading the text from this CV.', + Failed: 'No text could be read from this CV, so it was never matched to a job. Open the attachment to read it by hand.', + Pending: 'This CV has not been matched to a job yet.', +} + +/** + * Parse state -> badge colour. Anything not settled one way or the other — + * Parsing, Pending, or a label the backend adds later — lands on amber. + * + * Shared by the queue row and the detail pane so the same state can never paint + * two different colours on the two halves of this screen. + */ +function resumeStatusClass(status) { + return RESUME_STATUS_CLASS[status] ?? 'b-amber' +} + const SHORTLIST_JOB_WARNING = 'Choose a matching job above to add this candidate to the shortlist.' /** @@ -1475,6 +1500,22 @@ export default function Inbox() {
{i.processing} + {/* Exceptions only: a healthy row shows nothing, so the + badge stays a signal rather than decoration. + The kind check is redundant TODAY — mapFormRow sets no + resumeStatus, so the truthiness test already excludes + sheet rows. It is kept because those applicants have no + mailbox attachment to parse at all, and a future field + named resumeStatus on a form row must not be read as a + parse verdict. */} + {i.kind !== 'form' && i.resumeStatus && i.resumeStatus !== 'Parsed' && ( + + {i.resumeStatus} + + )} {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( {formatRole(i.applicationStatus)} )} @@ -2075,7 +2116,10 @@ function ApplicationDetail({ {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( <>{formatRole(i.applicationStatus)}{' '} )} - + {i.resumeStatus} {' '} {loading && Loading details…} diff --git a/frontend/src/ui/primitives.jsx b/frontend/src/ui/primitives.jsx index baf8757..da48984 100644 --- a/frontend/src/ui/primitives.jsx +++ b/frontend/src/ui/primitives.jsx @@ -48,9 +48,15 @@ export const STATUS_CLASS = { // define an identical private copy. export const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' } -export function Badge({ children, className }) { +/** + * `title` is optional and usually unset. It exists for badges whose one-word + * label is not self-explanatory — "Failed" on an Inbox row, for instance, where + * the hover has to say that the CV text could not be read and the attachment is + * still there to open by hand. + */ +export function Badge({ children, className, title }) { const cls = className || STATUS_CLASS[children] || 'b-gray' - return {children} + return {children} } export function ScoreChip({ score }) {