Merge pull request 'RecruiterHub' (#16) from RecruiterHub into main

Reviewed-on: #16
pull/17/head^2
ahmed.mujtaba 2026-08-17 08:21:18 +00:00
commit f35ccc1081
10 changed files with 206 additions and 22 deletions

2
.gitignore vendored
View File

@ -24,7 +24,7 @@ dist/**/*
.backup-prebrand/ .backup-prebrand/
*.bak *.bak
*.backup *.backup
*/node_modules/*
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@ -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()) 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): 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. """Current ats_results overall score for an Add Candidate user -> dict, or None.

View File

@ -698,7 +698,7 @@ async def fetch_pipeline_candidates(
@router.get("/pipeline/candidate/score/fetch") @router.get("/pipeline/candidate/score/fetch")
async def fetch_pipeline_candidate_score( async def fetch_pipeline_candidate_score(
user_id:uuid.UUID=Query(...), 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)), current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):

View File

@ -644,14 +644,17 @@ class CandidateView:
async def attach_job_posts(self,data): async def attach_job_posts(self,data):
"""Normalize list/single, serialize each record, attach full job_posts rows. """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. issued 600+ sequential job_posts round trips before.
""" """
from inbox.plugins import get_ats_scores_for_users
single=not isinstance(data,list) single=not isinstance(data,list)
records=[data] if single else list(data or []) records=[data] if single else list(data or [])
payloads=[] payloads=[]
wanted=[] wanted=[]
owners=[]
for record in records: for record in records:
payload=serialize_candidate_profile(record) payload=serialize_candidate_profile(record)
payload["job_posts"]=[] payload["job_posts"]=[]
@ -661,10 +664,22 @@ class CandidateView:
payload["ai_score"]=score payload["ai_score"]=score
payload["recommendation"]=band payload["recommendation"]=band
payloads.append(payload) payloads.append(payload)
if payload.get("user_id"):
owners.append(payload["user_id"])
if payload.get("assigned_job_post_id"): if payload.get("assigned_job_post_id"):
wanted.append(payload["assigned_job_post_id"]) wanted.append(payload["assigned_job_post_id"])
wanted.extend(payload.get("suggested_job_post_ids") or []) 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) posts=await self._job_posts_by_id(wanted)
# Copy per attach: two candidates on the same post held independent dicts # Copy per attach: two candidates on the same post held independent dicts
# back when every row re-serialized its own. # back when every row re-serialized its own.

View File

@ -23,7 +23,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" /> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-D0GY3L2I.js"></script> <script type="module" crossorigin src="/assets/index-IKokchhk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css"> <link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
</head> </head>
<body> <body>

View File

@ -7,49 +7,49 @@
"react": { "react": {
"src": "../../react/index.js", "src": "../../react/index.js",
"file": "react.js", "file": "react.js",
"fileHash": "0d8caf33", "fileHash": "30cd4518",
"needsInterop": true "needsInterop": true
}, },
"react-dom": { "react-dom": {
"src": "../../react-dom/index.js", "src": "../../react-dom/index.js",
"file": "react-dom.js", "file": "react-dom.js",
"fileHash": "e31b8bc6", "fileHash": "3e8e68cc",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-dev-runtime": { "react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js", "src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js", "file": "react_jsx-dev-runtime.js",
"fileHash": "8d3fcfde", "fileHash": "9f7d4b35",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-runtime": { "react/jsx-runtime": {
"src": "../../react/jsx-runtime.js", "src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js", "file": "react_jsx-runtime.js",
"fileHash": "e0c49b5a", "fileHash": "5e6dc2ef",
"needsInterop": true "needsInterop": true
}, },
"@tanstack/react-query": { "@tanstack/react-query": {
"src": "../../@tanstack/react-query/build/modern/index.js", "src": "../../@tanstack/react-query/build/modern/index.js",
"file": "@tanstack_react-query.js", "file": "@tanstack_react-query.js",
"fileHash": "436099d2", "fileHash": "f8ca42ca",
"needsInterop": false "needsInterop": false
}, },
"@tanstack/react-query-devtools": { "@tanstack/react-query-devtools": {
"src": "../../@tanstack/react-query-devtools/build/modern/index.js", "src": "../../@tanstack/react-query-devtools/build/modern/index.js",
"file": "@tanstack_react-query-devtools.js", "file": "@tanstack_react-query-devtools.js",
"fileHash": "956f8b03", "fileHash": "fd826471",
"needsInterop": false "needsInterop": false
}, },
"react-dom/client": { "react-dom/client": {
"src": "../../react-dom/client.js", "src": "../../react-dom/client.js",
"file": "react-dom_client.js", "file": "react-dom_client.js",
"fileHash": "b0d019a2", "fileHash": "55eb3962",
"needsInterop": true "needsInterop": true
}, },
"react-router-dom": { "react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs", "src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js", "file": "react-router-dom.js",
"fileHash": "26f4c951", "fileHash": "fbd054cd",
"needsInterop": false "needsInterop": false
} }
}, },

View File

@ -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) { function sourceFields(row, kind) {
if (kind === 'manual') { if (kind === 'manual') {
return { return {

View File

@ -43,6 +43,9 @@ export const qk = {
all: () => ['pipeline'], all: () => ['pipeline'],
board: (p = {}) => ['pipeline', 'board', p], board: (p = {}) => ['pipeline', 'board', p],
transitions: (inboxId) => ['pipeline', 'transitions', inboxId], 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: { analytics: {
all: () => ['analytics'], all: () => ['analytics'],

View File

@ -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 { toast } = useToast()
const [tab, setTab] = useState('Overview') const [tab, setTab] = useState('Overview')
const { data: interviews = [] } = useQuery(seedQuery('interviews')) const { data: interviews = [] } = useQuery(seedQuery('interviews'))
@ -212,10 +221,17 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span> <span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
</div> </div>
</div> </div>
<div style={{ textAlign: 'center' }}> {/* No score anywhere -> the whole block goes, rather than a ring drawn
<ScoreChip score={live?.ai_score ?? c.aiScore} /> around a blank. Seed-backed callers still pass a number and are
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div> unaffected; only live candidates the engine never scored drop out. */}
</div> {(atsScore ?? live?.ai_score ?? c.aiScore) != null && (
<div style={{ textAlign: 'center' }}>
<ScoreChip score={atsScore ?? live?.ai_score ?? c.aiScore} />
{/* The band caption replaces the static label only when the caller
actually fetched one every other screen keeps "AI Match". */}
<div className="cell-sub" style={{ marginTop: 4 }}>{recommendation || 'AI Match'}</div>
</div>
)}
</div> </div>
<div style={{ marginTop: 22 }}> <div style={{ marginTop: 22 }}>

View File

@ -20,6 +20,14 @@
interviews / notes / activity / feedback collections, all writable from their interviews / notes / activity / feedback collections, all writable from their
own tabs. That switch happens inside CandidateProfile; passing `userId` is the own tabs. That switch happens inside CandidateProfile; passing `userId` is the
whole trigger. 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' import { useMemo, useState } from 'react'
@ -33,6 +41,7 @@ import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates' import * as candidatesApi from '../api/candidates'
import * as pipelineApi from '../api/pipeline'
import { avatarColor, departments, initials as initialsOf } from '../data/seed' import { avatarColor, departments, initials as initialsOf } from '../data/seed'
/** The seed bucket holds 100 candidates; one template per person, no reuse. */ /** The seed bucket holds 100 candidates; one template per person, no reuse. */
@ -82,10 +91,12 @@ function merge(row, template) {
status: stage, status: stage,
currentTitle: title || template.currentTitle, currentTitle: title || template.currentTitle,
jobTitle: title || template.jobTitle, jobTitle: title || template.jobTitle,
// Real ATS score (scoring engine, joined server-side by inbox message) // NO seed fallback. `ai_score` is the candidate's current ats_results row,
// wins over the seed placeholder; recommendation follows it. // resolved server-side; null means the scoring engine never scored this
aiScore: row.ai_score ?? template.aiScore, // person, and the card renders nothing rather than a plausible fake number
recommendation: row.recommendation ?? template.recommendation, // a recruiter would read as a real match.
aiScore: row.ai_score ?? null,
recommendation: row.recommendation ?? null,
} }
} }
@ -124,6 +135,28 @@ export default function TalentPool() {
[query.data, templates], [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( const list = useMemo(
() => () =>
pool.filter((c) => { pool.filter((c) => {
@ -214,7 +247,7 @@ export default function TalentPool() {
<div className="lr-title">{c.name}</div> <div className="lr-title">{c.name}</div>
<div className="lr-sub">{c.currentTitle}</div> <div className="lr-sub">{c.currentTitle}</div>
</div> </div>
<ScoreChip score={c.aiScore} /> {c.aiScore != null && <ScoreChip score={c.aiScore} />}
</div> </div>
<div className="k-tags" style={{ marginBottom: 12 }}> <div className="k-tags" style={{ marginBottom: 12 }}>
{c.skills.slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)} {c.skills.slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)}
@ -242,6 +275,8 @@ export default function TalentPool() {
{profileFor && ( {profileFor && (
<CandidateProfile <CandidateProfile
candidate={profileFor} candidate={profileFor}
atsScore={atsScore}
recommendation={scoreQuery.data?.band ?? null}
onClose={() => setProfileFor(null)} onClose={() => setProfileFor(null)}
onAdvance={advance} onAdvance={advance}
onToggleFav={toggleFav} onToggleFav={toggleFav}