candidatye page

pull/16/head
ahmed.mujtaba 2026-08-13 19:25:42 +05:00
parent 4297badb43
commit 786c159ff6
7 changed files with 127 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

@ -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-BsDQzhFd.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

@ -221,12 +221,17 @@ export default function CandidateProfile({
<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={atsScore ?? live?.ai_score ?? c.aiScore} /> around a blank. Seed-backed callers still pass a number and are
{/* The band caption replaces the static label only when the caller unaffected; only live candidates the engine never scored drop out. */}
actually fetched one every other screen keeps "AI Match". */} {(atsScore ?? live?.ai_score ?? c.aiScore) != null && (
<div className="cell-sub" style={{ marginTop: 4 }}>{recommendation || 'AI Match'}</div> <div style={{ textAlign: 'center' }}>
</div> <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

@ -91,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,
} }
} }
@ -245,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>)}