diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 778627d..c1a4846 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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 diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index f99db8c..2ba242e 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -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 > {favorite ? 'Favorited' : 'Favorite'} + {canScoreAts && ( + + )} @@ -194,7 +213,7 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
- +
AI Match
diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index bae190a..32d59ed 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -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, } }