CV Import: No-Job mode — store CVs in the CV bank
Deploy to S3 / deploy (push) Successful in 36s Details

The job picker gains a No job / store in CV bank option: files upload
one-per-request through the existing POST /candidate/cv_upload pipeline
(email auto-detected from the CV, saved as an UNASSIGNED inbox item with
background job suggestions) instead of being scored. Queue rows show
Stored plus the detected email; a CV with no detectable email fails
alone with a clear message. In this mode the scored grid gives way to a
panel linking Job Matching and the Inbox, where stored CVs are browsed
and later assigned. Also guarded the matcher enqueue in ingest_upload:
with the broker down the upload used to 500 after the row was inserted.

E2E-verified: two CVs stored (emails detected), rows visible in DB with
assigned_job_post_id NULL, both surfaced in Job Matching and the Inbox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/29/head
Talha Ahmed 2026-08-27 19:11:13 +05:00
parent ccec7d48db
commit d1ca1e933d
4 changed files with 141 additions and 28 deletions

View File

@ -182,11 +182,18 @@ class FileRead:
)
created_at=datetime.now(timezone.utc).isoformat()
task=await match_uploaded_cv.kicker().with_labels(
created_at=created_at,
correlation_id=str(row.id),
queue=CV_QUEUE_NAME,
).kiq(str(row.id),force=False)
# Enqueue failure must not fail the upload: the CV row is already
# persisted, and with the broker down (optional locally) the kiq call
# raises a connection error. Suggestions just arrive later, or never.
try:
task=await match_uploaded_cv.kicker().with_labels(
created_at=created_at,
correlation_id=str(row.id),
queue=CV_QUEUE_NAME,
).kiq(str(row.id),force=False)
except Exception as e:
logger.warning("cv match enqueue skipped for %s: %s",row.id,e)
task=None
account_setup=None
if new_user_email:
@ -218,7 +225,7 @@ class FileRead:
return {
"queued":True,
"inbox_message_id":str(row.id),
"task_id":task.task_id,
"task_id":task.task_id if task else None,
"filename":parsed.get("filename"),
"num_pages":parsed.get("num_pages"),
"candidate_email":email,

View File

@ -24,7 +24,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-D-zYfH3L.js"></script>
<script type="module" crossorigin src="/assets/index-BkPc2-Gs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwYjpKNo.css">
</head>
<body>

View File

@ -53,6 +53,19 @@ export function scoreUploads(jobId, files) {
return request('/candidate/score', { method: 'POST', body: form })
}
/**
* Store one CV in the bank with NO job attached POST /candidate/cv_upload.
* Needs candidates.create. The backend saves the PDF, detects the candidate's
* email from the CV text (422 CANDIDATE_EMAIL_REQUIRED when none is found),
* and lands it as an UNASSIGNED inbox item; the async matcher only fills job
* suggestions. One file per request.
*/
export function uploadCv(file) {
const form = new FormData()
form.append('file', file, file.name)
return request('/candidate/cv_upload', { method: 'POST', body: form })
}
/**
* Score the decoded attachments of inbox messages against a job post.
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`

View File

@ -1,15 +1,20 @@
/* ============================================================
CV Import real upload score persist flow.
CV Import two modes behind one dropzone.
Files go to POST /candidate/score as one multipart batch: the backend
extracts each PDF, scores it against the selected job with the ATS engine,
and persists a row per file. Unreadable/oversized/non-PDF files come back
as status "failed" rows instead of failing the batch, and re-uploading the
same bytes updates the existing record (content-hash dedupe) so there is
no separate "import" step and no duplicate modal anymore.
Score mode: files go to POST /candidate/score as one multipart batch
each PDF is extracted, scored against the selected job, persisted a row
per file. Unreadable/oversized files come back as "failed" rows, and
re-uploading the same bytes updates the existing record.
No-Job mode ("store in CV bank"): each file goes to POST
/candidate/cv_upload individually parsed, the candidate email detected
from the CV text, and stored as an UNASSIGNED inbox item. Nothing is
scored; the async matcher only suggests jobs. Stored CVs are viewed in
Job Matching and the Recruitment Inbox.
============================================================ */
import { useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader'
@ -20,13 +25,23 @@ import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
const STEPS = [
/* Sentinel for the job picker: store CVs without scoring or assignment. */
const NO_JOB = '__none__'
const SCORE_STEPS = [
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
{ i: 'target', t: 'ATS scoring', d: 'LLM match score with matched & missing skills vs the selected job' },
{ i: 'users', t: 'Duplicate detection', d: 'Re-uploading the same file updates its existing record' },
{ i: 'user-plus', t: 'Saved to pool', d: 'Results persist — see Candidates and Talent Pool' },
]
const STORE_STEPS = [
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
{ i: 'users', t: 'Candidate identified', d: 'Email auto-detected from the CV — a CV without one is rejected' },
{ i: 'target', t: 'Job suggestions', d: 'Fitting jobs are suggested in the background — nothing is scored or assigned' },
{ i: 'user-plus', t: 'Saved to CV bank', d: 'Stored unassigned — view in Job Matching or the Recruitment Inbox' },
]
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
@ -93,11 +108,49 @@ export default function CvImport() {
},
})
/* No-Job mode: one request per file, so one unreadable CV (or one with no
detectable email) fails alone and the rest of the batch still lands. */
const storing = useMutation({
mutationFn: async ({ files, rowIds }) => {
const results = []
for (let k = 0; k < files.length; k++) {
try {
const res = await candidatesApi.uploadCv(files[k])
results.push({ rowId: rowIds[k], ok: true, email: res?.data?.candidate_email ?? null })
} catch (err) {
const code = err?.data?.detail?.error_code
results.push({ rowId: rowIds[k], ok: false, error: code === 'CANDIDATE_EMAIL_REQUIRED' ? 'NO_EMAIL' : 'FAILED' })
}
}
return results
},
onSuccess: (results) => {
setQueue((q) =>
q.map((item) => {
const r = results.find((x) => x.rowId === item.id)
if (!r) return item
return r.ok
? { ...item, status: 'Stored', email: r.email }
: { ...item, status: 'Failed', error: r.error }
}),
)
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
const ok = results.filter((r) => r.ok).length
const bad = results.length - ok
toast(
bad
? `${ok} stored, ${bad} failed — see the queue for details`
: `${ok} CV${ok === 1 ? '' : 's'} stored in the CV bank`,
bad ? 'warning' : 'success',
)
},
})
function handleFiles(fileList) {
const all = Array.from(fileList || [])
if (!all.length) return
if (!jobId) {
toast('Select a job to score against first', 'warning')
toast('Select a job — or "No job" to just store the CVs', 'warning')
return
}
const files = all.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
@ -105,29 +158,35 @@ export default function CvImport() {
if (skipped) toast(`Only PDF resumes are supported — ${skipped} file(s) skipped`, 'warning')
if (!files.length) return
const noJob = jobId === NO_JOB
const items = files.map((f) => ({
id: `UP-${++rowSeq}-${Date.now()}`,
name: f.name,
file: f.name,
size: fmtSize(f.size),
status: 'Scoring',
status: noJob ? 'Storing' : 'Scoring',
atsScore: null,
critique: null,
email: null,
error: null,
}))
setQueue((q) => [...q, ...items])
scoring.mutate({ job: jobId, files, rowIds: items.map((i) => i.id) })
const rowIds = items.map((i) => i.id)
if (noJob) storing.mutate({ files, rowIds })
else scoring.mutate({ job: jobId, files, rowIds })
}
const scored = queue.filter((i) => i.status === 'Ready').length
const stored = queue.filter((i) => i.status === 'Stored').length
const failed = queue.filter((i) => i.status === 'Failed').length
const selectedJob = jobs.find((j) => j.id === jobId)
const noJobMode = jobId === NO_JOB
return (
<div className="page">
<PageHeader
title="CV Import"
sub="Upload resume PDFs — parsed, scored against a job, and saved automatically"
sub="Upload resume PDFs — score them against a job, or store them in the CV bank"
actions={<span className="integration-status pending"><span className="pulse" />AI Resume Scoring · Live</span>}
/>
@ -143,6 +202,7 @@ export default function CvImport() {
onChange={(e) => setJobId(e.target.value)}
>
<option value="">Select a job post</option>
<option value={NO_JOB}>No job store in CV bank</option>
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select>
</div>
@ -196,7 +256,9 @@ export default function CvImport() {
<div>
<h3>Processing Queue</h3>
<span className="ch-sub">
{queue.length} file{queue.length === 1 ? '' : 's'} · {scored} scored
{queue.length} file{queue.length === 1 ? '' : 's'}
{scored ? ` · ${scored} scored` : ''}
{stored ? ` · ${stored} stored` : ''}
{failed ? ` · ${failed} failed` : ''}
{selectedJob ? ` · vs ${selectedJob.title}` : ''}
</span>
@ -211,7 +273,7 @@ export default function CvImport() {
<span className="fw-600 text-sm">{i.name}</span>
</div>
<div className="cell-sub">{i.file} · {i.size}</div>
{i.status === 'Scoring' && (
{(i.status === 'Scoring' || i.status === 'Storing') && (
<div className="upload-progress" style={{ marginTop: 6 }}>
<div className="upload-progress-fill" style={{ width: '66%' }} />
</div>
@ -219,17 +281,26 @@ export default function CvImport() {
{i.status === 'Ready' && i.critique && (
<div className="cell-sub" style={{ marginTop: 4 }}>{i.critique}</div>
)}
{i.status === 'Stored' && i.email && (
<div className="cell-sub" style={{ marginTop: 4 }}>Candidate email: {i.email}</div>
)}
{i.status === 'Failed' && (
<div className="cell-sub" style={{ marginTop: 4 }}>Could not be scored</div>
<div className="cell-sub" style={{ marginTop: 4 }}>
{i.error === 'NO_EMAIL'
? 'No email found in this CV — add the candidate manually with an email instead'
: 'Could not be processed'}
</div>
)}
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
{i.status === 'Ready' && <ScoreChip score={i.atsScore} />}
{i.status === 'Scoring' && <Badge className="b-blue">Scoring</Badge>}
{i.status === 'Storing' && <Badge className="b-blue">Storing</Badge>}
{i.status === 'Failed' && <Badge className="b-red">{i.error}</Badge>}
</div>
<div style={{ flexShrink: 0 }}>
{i.status === 'Ready' && <Badge className="b-green">Saved</Badge>}
{i.status === 'Stored' && <Badge className="b-green">Stored</Badge>}
</div>
</div>
))}
@ -239,7 +310,7 @@ export default function CvImport() {
{queue.length === 0 && jobsQuery.isSuccess && jobs.length === 0 && (
<EmptyState icon="briefcase" title="No job posts yet">
Create a job post first resumes are always scored against a job.
Create a job post to score against or pick No job store in CV bank above to just save CVs.
</EmptyState>
)}
</div>
@ -248,7 +319,7 @@ export default function CvImport() {
<div className="card-head"><div><h3>Auto-Processing</h3><span className="ch-sub">What happens on upload</span></div></div>
<div className="card-body">
<div className="timeline">
{STEPS.map((s) => (
{(noJobMode ? STORE_STEPS : SCORE_STEPS).map((s) => (
<div className="tl-item" key={s.t}>
<div className="tl-dot"><Icon name={s.i} /></div>
<div className="tl-title">{s.t}</div>
@ -260,10 +331,32 @@ export default function CvImport() {
</div>
</div>
{/* Everything ever scored against the selected job this batch, earlier
uploads and synced inbox CVs alike. The scoring mutation invalidates
qk.candidates.all(), so the grid refreshes as each batch lands. */}
<JobCandidates jobId={jobId} jobTitle={selectedJob?.title} />
{/* No-Job mode has no scored grid — point at where the bank is browsed. */}
{noJobMode ? (
<div className="card mt-18">
<div className="card-body flex items-center gap-16 flex-wrap">
<span className="kpi-icn i-teal" style={{ width: 44, height: 44, borderRadius: 12, flexShrink: 0 }}>
<Icon name="talent" />
</span>
<div style={{ flex: 1, minWidth: 220 }}>
<div className="fw-600">Stored CVs live in the CV bank</div>
<div className="text-muted text-sm">
They stay unassigned until you attach them to a job review them with suggested
matches in Job Matching, or browse them in the Recruitment Inbox.
</div>
</div>
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
<Link className="btn btn-secondary" to="/matching"><Icon name="target" /> Job Matching</Link>
<Link className="btn btn-secondary" to="/inbox"><Icon name="inbox" /> Inbox</Link>
</div>
</div>
</div>
) : (
/* Everything ever scored against the selected job this batch, earlier
uploads and synced inbox CVs alike. The scoring mutation invalidates
qk.candidates.all(), so the grid refreshes as each batch lands. */
<JobCandidates jobId={jobId} jobTitle={selectedJob?.title} />
)}
</div>
)
}