diff --git a/.gitignore b/.gitignore index 8d92c69..9d60d3e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,7 @@ dist/**/* .backup-prebrand/ *.bak *.backup - +*/node_modules/* # Python __pycache__/ *.py[cod] diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index 45a2006..3a10e6d 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -230,6 +230,89 @@ async def get_ats_score_for_user(session:AsyncSession,user_id,job_post_id=None): return _ats_score_payload((await session.execute(qry)).scalars().first()) +async def get_ats_scores_for_users(session:AsyncSession,user_ids): + """{str(user_id): score payload} for a whole page of candidates. + + Same source and same three resolution paths as get_ats_score_for_user / + get_ats_score_for_manual_user, but three queries for the page instead of two + per row — a 100-card talent pool called the single-row helpers 200 times. + + Paths are applied in precedence order and a user found by an earlier one is + never overwritten: direct ats_results.user_id, then the inbox join for scores + whose email matched no user, then the manual_upload email join. Within a path + the newest current score wins, which is what the unpinned single-row helpers + return when a candidate has several applications. + """ + uids=[] + seen=set() + for raw in user_ids or []: + try: + uid=uuid.UUID(str(raw)) + except (TypeError,ValueError): + continue + if uid not in seen: + seen.add(uid) + uids.append(uid) + if not uids: + return {} + + scores={} + + def collect(pairs): + # computed_at DESC on every query, so the first row seen for a user is + # the newest, and a later path can never displace an earlier one. + for ats,owner in pairs: + key=str(owner) if owner else None + if key and key not in scores: + scores[key]=_ats_score_payload(ats) + + direct=( + select(AtsResults) + .where(AtsResults.user_id.in_(uids),AtsResults.is_current==True) # noqa: E712 + .order_by(AtsResults.computed_at.desc()) + ) + collect((r,r.user_id) for r in (await session.execute(direct)).scalars().all()) + + remaining=[u for u in uids if str(u) not in scores] + if remaining: + via_inbox=( + select(AtsResults,Inbox.user_id) + .join(Inbox,AtsResults.inbox_id==Inbox.id) + .join(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) + .where( + Inbox.user_id.in_(remaining), + Inbox_Messages.assigned_job_post_id.is_not(None), + AtsResults.job_post_id==Inbox_Messages.assigned_job_post_id, + AtsResults.is_current==True, # noqa: E712 + ) + .order_by(AtsResults.computed_at.desc()) + ) + collect((await session.execute(via_inbox)).all()) + + remaining=[u for u in uids if str(u) not in scores] + if remaining: + via_manual=( + select(AtsResults,Manual_UPLOAD_CANDIDATE.user_id) + .join(Candidates,AtsResults.candidate_id==Candidates.id) + .join( + Manual_UPLOAD_CANDIDATE, + (Candidates.job_id==Manual_UPLOAD_CANDIDATE.job_post_id) + &(Candidates.candidate_email==Manual_UPLOAD_CANDIDATE.candidate_email), + ) + .where( + Manual_UPLOAD_CANDIDATE.user_id.in_(remaining), + Manual_UPLOAD_CANDIDATE.apply_via=="manual_upload", + Candidates.status=="completed", + AtsResults.job_post_id==Manual_UPLOAD_CANDIDATE.job_post_id, + AtsResults.is_current==True, # noqa: E712 + ) + .order_by(AtsResults.computed_at.desc()) + ) + collect((await session.execute(via_manual)).all()) + + return scores + + async def get_ats_score_for_manual_user(session:AsyncSession,user_id,job_post_id=None): """Current ats_results overall score for an Add Candidate user -> dict, or None. 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/backend/job/candidate/views.py b/backend/job/candidate/views.py index 0ea18f8..9957c5e 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -644,14 +644,17 @@ class CandidateView: async def attach_job_posts(self,data): """Normalize list/single, serialize each record, attach full job_posts rows. - Two batched queries per page, not two per row — a 200-row pipeline board + Three batched queries per page, not two per row — a 200-row pipeline board issued 600+ sequential job_posts round trips before. """ + from inbox.plugins import get_ats_scores_for_users + single=not isinstance(data,list) records=[data] if single else list(data or []) payloads=[] wanted=[] + owners=[] for record in records: payload=serialize_candidate_profile(record) payload["job_posts"]=[] @@ -661,10 +664,22 @@ class CandidateView: payload["ai_score"]=score payload["recommendation"]=band payloads.append(payload) + if payload.get("user_id"): + owners.append(payload["user_id"]) if payload.get("assigned_job_post_id"): wanted.append(payload["assigned_job_post_id"]) wanted.extend(payload.get("suggested_job_post_ids") or []) + # ats_results is the source the inbox denorm above is copied FROM, so a + # live score wins over it. A candidate with neither keeps ai_score None — + # the list rows must not invent a number the scoring engine never produced. + ats=await get_ats_scores_for_users(self.session,owners) + for payload in payloads: + row=ats.get(str(payload.get("user_id") or "")) + if row and row.get("overall_score") is not None: + payload["ai_score"]=row["overall_score"] + payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"]) + posts=await self._job_posts_by_id(wanted) # Copy per attach: two candidates on the same post held independent dicts # back when every row re-serialized its own. diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 2c57fc8..af70aa1 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,7 +23,7 @@ - +
diff --git a/frontend/node_modules/.vite/deps/_metadata.json b/frontend/node_modules/.vite/deps/_metadata.json index ffa03b9..f6fcee4 100644 --- a/frontend/node_modules/.vite/deps/_metadata.json +++ b/frontend/node_modules/.vite/deps/_metadata.json @@ -7,49 +7,49 @@ "react": { "src": "../../react/index.js", "file": "react.js", - "fileHash": "0d8caf33", + "fileHash": "30cd4518", "needsInterop": true }, "react-dom": { "src": "../../react-dom/index.js", "file": "react-dom.js", - "fileHash": "e31b8bc6", + "fileHash": "3e8e68cc", "needsInterop": true }, "react/jsx-dev-runtime": { "src": "../../react/jsx-dev-runtime.js", "file": "react_jsx-dev-runtime.js", - "fileHash": "8d3fcfde", + "fileHash": "9f7d4b35", "needsInterop": true }, "react/jsx-runtime": { "src": "../../react/jsx-runtime.js", "file": "react_jsx-runtime.js", - "fileHash": "e0c49b5a", + "fileHash": "5e6dc2ef", "needsInterop": true }, "@tanstack/react-query": { "src": "../../@tanstack/react-query/build/modern/index.js", "file": "@tanstack_react-query.js", - "fileHash": "436099d2", + "fileHash": "f8ca42ca", "needsInterop": false }, "@tanstack/react-query-devtools": { "src": "../../@tanstack/react-query-devtools/build/modern/index.js", "file": "@tanstack_react-query-devtools.js", - "fileHash": "956f8b03", + "fileHash": "fd826471", "needsInterop": false }, "react-dom/client": { "src": "../../react-dom/client.js", "file": "react-dom_client.js", - "fileHash": "b0d019a2", + "fileHash": "55eb3962", "needsInterop": true }, "react-router-dom": { "src": "../../react-router-dom/dist/index.mjs", "file": "react-router-dom.js", - "fileHash": "26f4c951", + "fileHash": "fbd054cd", "needsInterop": false } }, 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 b33deeb..24b768a 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..67e7dbe 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')) @@ -212,10 +221,17 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT {c.experience} yrs exp -