Inbox: label CVs the pipeline could not read, on the row itself

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>
pull/73/head^2
Talha Ahmed 2026-09-03 17:48:28 +05:00
parent db0652ab24
commit 921d3bf383
3 changed files with 103 additions and 3 deletions

View File

@ -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()

View File

@ -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() {
</div>
<div className="ii-meta">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
{/* 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' && (
<Badge
className={resumeStatusClass(i.resumeStatus)}
title={RESUME_STATUS_TIP[i.resumeStatus]}
>
{i.resumeStatus}
</Badge>
)}
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{formatRole(i.applicationStatus)}</Badge>
)}
@ -2075,7 +2116,10 @@ function ApplicationDetail({
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<><Badge>{formatRole(i.applicationStatus)}</Badge>{' '}</>
)}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
<Badge
className={resumeStatusClass(i.resumeStatus)}
title={RESUME_STATUS_TIP[i.resumeStatus]}
>
{i.resumeStatus}
</Badge>{' '}
{loading && <span className="cell-sub">Loading details</span>}

View File

@ -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 <span className={`badge ${cls}`}>{children}</span>
return <span className={`badge ${cls}`} title={title}>{children}</span>
}
export function ScoreChip({ score }) {