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
>