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
parent
eb95d7a2c0
commit
7c593d32ce
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue