From 4297badb434ba27f83ca96b2cdf94db00f3c06aa Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 13 Aug 2026 18:51:07 +0500 Subject: [PATCH 1/2] insiode data ai match wired --- backend/job/app.py | 2 +- frontend/dist/index.html | 2 +- frontend/src/api/pipeline.js | 32 ++++++++++++++++++++++ frontend/src/lib/queryKeys.js | 3 +++ frontend/src/screens/CandidateProfile.jsx | 17 +++++++++--- frontend/src/screens/TalentPool.jsx | 33 +++++++++++++++++++++++ 6 files changed, 84 insertions(+), 5 deletions(-) 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} From 786c159ff614457eeba0b191bc8e2486caab65f6 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 13 Aug 2026 19:25:42 +0500 Subject: [PATCH 2/2] candidatye page --- .gitignore | 2 +- backend/inbox/plugins.py | 83 +++++++++++++++++++ backend/job/candidate/views.py | 17 +++- frontend/dist/index.html | 2 +- .../node_modules/.vite/deps/_metadata.json | 16 ++-- frontend/src/screens/CandidateProfile.jsx | 17 ++-- frontend/src/screens/TalentPool.jsx | 12 +-- 7 files changed, 127 insertions(+), 22 deletions(-) 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/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 b2ef9a9..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/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 1c1ac44..67e7dbe 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -221,12 +221,17 @@ export default function CandidateProfile({ {c.experience} yrs exp -
- - {/* The band caption replaces the static label only when the caller - actually fetched one — every other screen keeps "AI Match". */} -
{recommendation || 'AI Match'}
-
+ {/* No score anywhere -> the whole block goes, rather than a ring drawn + around a blank. Seed-backed callers still pass a number and are + unaffected; only live candidates the engine never scored drop out. */} + {(atsScore ?? live?.ai_score ?? c.aiScore) != null && ( +
+ + {/* 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 d2452f8..dee2bcb 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -91,10 +91,12 @@ function merge(row, template) { status: stage, currentTitle: title || template.currentTitle, jobTitle: title || template.jobTitle, - // Real ATS score (scoring engine, joined server-side by inbox message) - // wins over the seed placeholder; recommendation follows it. - aiScore: row.ai_score ?? template.aiScore, - recommendation: row.recommendation ?? template.recommendation, + // NO seed fallback. `ai_score` is the candidate's current ats_results row, + // resolved server-side; null means the scoring engine never scored this + // person, and the card renders nothing rather than a plausible fake number + // a recruiter would read as a real match. + aiScore: row.ai_score ?? null, + recommendation: row.recommendation ?? null, } } @@ -245,7 +247,7 @@ export default function TalentPool() {
{c.name}
{c.currentTitle}
- + {c.aiScore != null && }
{c.skills.slice(0, 4).map((s) => {s})}