diff --git a/backend/job/app.py b/backend/job/app.py index acd57c8..8ae988c 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -698,7 +698,7 @@ async def fetch_pipeline_candidates( @router.get("/pipeline/candidate/score/fetch") async def fetch_pipeline_candidate_score( user_id:uuid.UUID=Query(...), - job_post_id:uuid.UUID=Query(...), + job_post_id:uuid.UUID=Query(None), current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)), session: AsyncSession = Depends(get_session), ): diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 2c57fc8..b2ef9a9 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,7 +23,7 @@ - + diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index 4f377b0..9cd7775 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -111,6 +111,38 @@ export function listTransitions({ inboxId, manualUploadId, transitionId } = {}) }) } +/** + * One candidate's CURRENT ATS score — GET /pipeline/candidate/score/fetch + * (pipeline.view). Envelope is `{ data: { manual, inbox }, total, status_code }`, + * where each side is `{overall_score, band, job_post_id, computed_at, + * candidate_id, user_id}` or null. + * + * Score only. It carries no matched/missing keywords and no critique — those + * live on the scored-candidate row (/candidate/scored/fetch). + * + * `jobPostId` pins the score to one application; omitted (as the talent pool + * does, where a candidate need not have an assigned post) the newest current + * score across the candidate's applications wins. + */ +export function fetchCandidateScore({ userId, jobPostId } = {}) { + return request('/pipeline/candidate/score/fetch', { + params: { user_id: userId, job_post_id: jobPostId }, + }) +} + +/** + * The two-sided envelope -> one score row, or null. + * + * A candidate is reached either through the inbox (they mailed us) or through + * Add Candidate (manual_upload); both sides resolve the same ats_results table, + * so whichever side answered is the score. Inbox wins a tie because an emailed + * application is the one the pipeline board is showing. + */ +export function toAtsScore(res) { + const data = res?.data ?? {} + return data.inbox ?? data.manual ?? null +} + function sourceFields(row, kind) { if (kind === 'manual') { return { diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 6501ffd..8b08670 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -43,6 +43,9 @@ export const qk = { all: () => ['pipeline'], board: (p = {}) => ['pipeline', 'board', p], transitions: (inboxId) => ['pipeline', 'transitions', inboxId], + // One candidate's current ats_results score. Fetched on demand (a card + // click), never as part of a list, so it is keyed per candidate + job. + candidateScore: (p = {}) => ['pipeline', 'candidate-score', p], }, analytics: { all: () => ['analytics'], diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 2ba242e..1c1ac44 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -102,7 +102,16 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) { }) } -export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) { +/** + * `atsScore` / `recommendation` are the CURRENT ats_results row, fetched by the + * caller (Talent Pool reads GET /pipeline/candidate/score/fetch on card click). + * They are optional: a caller that does not fetch it passes nothing and the hero + * falls back to the detail payload's denormalised ai_score, then to the row's. + * When present they WIN, because ats_results is the source the denorm copies. + */ +export default function CandidateProfile({ + candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch, +}) { const { toast } = useToast() const [tab, setTab] = useState('Overview') const { data: interviews = [] } = useQuery(seedQuery('interviews')) @@ -213,8 +222,10 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
- -
AI Match
+ + {/* The band caption replaces the static label only when the caller + actually fetched one — every other screen keeps "AI Match". */} +
{recommendation || 'AI Match'}
diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index 32d59ed..d2452f8 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -20,6 +20,14 @@ interviews / notes / activity / feedback collections, all writable from their own tabs. That switch happens inside CandidateProfile; passing `userId` is the whole trigger. + + A SECOND read fires on that same click: GET /pipeline/candidate/score/fetch, + the pipeline board's score endpoint, which returns the candidate's current + ats_results row. The two run concurrently — this screen owns the score call, + CandidateProfile owns the detail call — and the score wins over both the + list row's denormalised ai_score and the seed placeholder. It is deliberately + NOT fetched for the grid: 100 cards would be 100 requests, and the card only + ever needed a number good enough to sort by eye. ============================================================ */ import { useMemo, useState } from 'react' @@ -33,6 +41,7 @@ import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' +import * as pipelineApi from '../api/pipeline' import { avatarColor, departments, initials as initialsOf } from '../data/seed' /** The seed bucket holds 100 candidates; one template per person, no reuse. */ @@ -124,6 +133,28 @@ export default function TalentPool() { [query.data, templates], ) + /** + * The clicked candidate's current ATS score, from the pipeline board's own + * endpoint. `enabled` is the "only on click" rule: with no open profile there + * is no userId, and the query never runs. React Query caches it per user, so + * re-opening the same card repaints from cache. + * + * Sent WITHOUT job_post_id on purpose. The pool is a cross-job view — it has + * no job filter and most rows carry no assigned post — so pinning would only + * ever hide a score that exists under some other post. Unpinned, the endpoint + * answers with the newest current score the candidate has anywhere. + */ + const scoreQuery = useQuery({ + queryKey: qk.pipeline.candidateScore({ userId: profileFor?.userId ?? null }), + queryFn: () => pipelineApi.fetchCandidateScore({ userId: profileFor.userId }), + select: pipelineApi.toAtsScore, + enabled: Boolean(profileFor?.userId), + }) + + // A candidate with no ats_results row answers `null`, which must read as "no + // live score" and leave the existing value alone — not as a score of zero. + const atsScore = scoreQuery.data?.overall_score ?? null + const list = useMemo( () => pool.filter((c) => { @@ -242,6 +273,8 @@ export default function TalentPool() { {profileFor && ( setProfileFor(null)} onAdvance={advance} onToggleFav={toggleFav}