From d1ca1e933db6fe9c90b5840f3edc183e07cc573c Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Thu, 27 Aug 2026 19:11:13 +0500 Subject: [PATCH] =?UTF-8?q?CV=20Import:=20No-Job=20mode=20=E2=80=94=20stor?= =?UTF-8?q?e=20CVs=20in=20the=20CV=20bank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/job/candidate/views.py | 19 +++-- frontend/dist/index.html | 2 +- frontend/src/api/candidates.js | 13 +++ frontend/src/screens/CvImport.jsx | 135 +++++++++++++++++++++++++----- 4 files changed, 141 insertions(+), 28 deletions(-) diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index eeb29d5..b715b1c 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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, diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 81a6875..351c9de 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -24,7 +24,7 @@ - + diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index b2a6ed0..d1c854b 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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` diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index 5ae729b..9ad848a 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -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 (
AI Resume Scoring · Live} /> @@ -143,6 +202,7 @@ export default function CvImport() { onChange={(e) => setJobId(e.target.value)} > + {jobs.map((j) => )}
@@ -196,7 +256,9 @@ export default function CvImport() {

Processing Queue

- {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}` : ''} @@ -211,7 +273,7 @@ export default function CvImport() { {i.name}
{i.file} · {i.size}
- {i.status === 'Scoring' && ( + {(i.status === 'Scoring' || i.status === 'Storing') && (
@@ -219,17 +281,26 @@ export default function CvImport() { {i.status === 'Ready' && i.critique && (
{i.critique}
)} + {i.status === 'Stored' && i.email && ( +
Candidate email: {i.email}
+ )} {i.status === 'Failed' && ( -
Could not be scored
+
+ {i.error === 'NO_EMAIL' + ? 'No email found in this CV — add the candidate manually with an email instead' + : 'Could not be processed'} +
)}
{i.status === 'Ready' && } {i.status === 'Scoring' && Scoring…} + {i.status === 'Storing' && Storing…} {i.status === 'Failed' && {i.error}}
{i.status === 'Ready' && Saved} + {i.status === 'Stored' && Stored}
))} @@ -239,7 +310,7 @@ export default function CvImport() { {queue.length === 0 && jobsQuery.isSuccess && jobs.length === 0 && ( - 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. )} @@ -248,7 +319,7 @@ export default function CvImport() {

Auto-Processing

What happens on upload
- {STEPS.map((s) => ( + {(noJobMode ? STORE_STEPS : SCORE_STEPS).map((s) => (
{s.t}
@@ -260,10 +331,32 @@ export default function CvImport() {
- {/* 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. */} - + {/* No-Job mode has no scored grid — point at where the bank is browsed. */} + {noJobMode ? ( +
+
+ + + +
+
Stored CVs live in the CV bank
+
+ 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. +
+
+
+ Job Matching + Inbox +
+
+
+ ) : ( + /* 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. */ + + )}
) }