CV Import: No-Job mode — store CVs in the CV bank
Deploy to S3 / deploy (push) Successful in 36s
Details
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
parent
ccec7d48db
commit
d1ca1e933d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue