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})}