candidatye page
parent
4297badb43
commit
786c159ff6
|
|
@ -24,7 +24,7 @@ dist/**/*
|
||||||
.backup-prebrand/
|
.backup-prebrand/
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
*/node_modules/*
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
|
|
@ -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>)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue