Surface real ATS scores in the talent-pool profile

Main stubbed ai_score/recommendation as None in the candidate-profile payload;
fill them from the scoring engine's candidates table, joined by inbox message
(one batched query for the list, assigned-job-preferred for the detail). The
detail payload also gains matched/missing keywords, the critique, and which job
the score was against.

Frontend: TalentPool cards and the profile hero prefer the real score over the
seed placeholder, and the profile grows a Score-with-ATS button (shown when the
candidate has an assigned job and an inbox message) that runs the existing
score_inbox pipeline and repaints via the detail refetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dashboard_Wiring
Talha Ahmed 2026-08-11 19:22:29 +05:00
parent eb95d7a2c0
commit 7c593d32ce
3 changed files with 75 additions and 1 deletions

View File

@ -285,6 +285,31 @@ class CandidateView:
def __init__(self,session:AsyncSession):
self.session=session
@staticmethod
def _recommendation(score):
if score is None:
return None
# Same bands the frontend uses (Candidates.jsx / seed.js).
return "Strong Match" if score>=82 else "Potential Match" if score>=65 else "Weak Match"
async def _scores_by_message(self,message_ids):
"""Completed ATS scores (candidates table) per inbox message id, newest first.
One batched query the profile list would otherwise pay a query per row.
"""
mids=[m for m in message_ids if m]
if not mids:
return {}
result=await self.session.execute(
select(Candidates)
.where(Candidates.inbox_message_id.in_(mids),Candidates.status=="completed")
.order_by(Candidates.updated_at.desc())
)
scores={}
for row in result.scalars().all():
scores.setdefault(row.inbox_message_id,[]).append(row)
return scores
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None):
try:
detail=bool(user_id)
@ -357,11 +382,16 @@ class CandidateView:
"""Normalize list/single, serialize each record, attach full job_posts rows."""
single=not isinstance(data,list)
records=[data] if single else list(data or [])
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
enriched=[]
for record in records:
payload=serialize_candidate_profile(record)
payload["job_posts"]=[]
payload["assigned_job_post"]=None
scored=scores.get(getattr(record,"message_id",None)) or []
if scored:
payload["ai_score"]=scored[0].match_score
payload["recommendation"]=self._recommendation(scored[0].match_score)
assigned_id=payload.get("assigned_job_post_id")
if assigned_id:
await self.get_job_post_by_id(record_id=assigned_id,data=payload,as_assigned=True)
@ -432,6 +462,27 @@ class CandidateView:
)
notes=[serialize_note(r) for r in result.scalars().all()]
# ATS score: join the scoring engine's `candidates` rows onto the profile
# by inbox message. The serializer stubs ai_score/recommendation to None;
# this is where they get real values. Prefer the score against the
# assigned job post, else the most recent completed score.
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
scored_rows=[row for rows in scores.values() for row in rows]
if scored_rows:
assigned_uid=Candidates._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
chosen=None
if assigned_uid is not None:
chosen=next((r for r in scored_rows if r.job_id==assigned_uid),None)
if chosen is None:
chosen=max(scored_rows,key=lambda r:r.updated_at)
base["ai_score"]=chosen.match_score
base["recommendation"]=self._recommendation(chosen.match_score)
base["matched_keywords"]=list(chosen.matched_keywords or [])
base["missing_keywords"]=list(chosen.missing_keywords or [])
base["summary_critique"]=chosen.summary_critique
base["scored_job_post_id"]=str(chosen.job_id)
base["scored_at"]=chosen.updated_at.isoformat() if chosen.updated_at else None
activity.sort(key=lambda r:(r.get("activity_date") or ""),reverse=True)
base["favorite"]=favorite
base["rating"]=rating

View File

@ -130,6 +130,16 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
})
// Score the candidate's inbox CV against the assigned job with the ATS engine.
// Needs both an assigned job (what to score against) and a message (whose
// attachment to score); the refetch lands the new ai_score in this modal.
const canScoreAts = isLive && Boolean(live?.assigned_job_post_id && live?.message_id)
const scoreAts = useProfileWrite({
userId: c.userId,
mutationFn: () => candidatesApi.scoreInbox(live.assigned_job_post_id, [live.message_id]),
success: 'CV scored against the assigned job',
})
const title = live?.job_title || c.currentTitle
const company = live?.currentCompany || c.currentCompany
@ -171,6 +181,15 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
>
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
</button>
{canScoreAts && (
<button
className="btn btn-secondary"
disabled={scoreAts.isPending}
onClick={() => scoreAts.mutate()}
>
<Icon name="sparkles" /> {scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
</button>
)}
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
@ -194,7 +213,7 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
</div>
</div>
<div style={{ textAlign: 'center' }}>
<ScoreChip score={c.aiScore} />
<ScoreChip score={live?.ai_score ?? c.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
</div>

View File

@ -82,6 +82,10 @@ 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,
}
}