diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 2794ff0..fecb153 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -261,11 +261,15 @@ class Inbox(SQLModel, table=True): Inbox_Messages.current_employment.label("current_company"), Inbox_Messages.current_title, Inbox_Messages.candidate_education.label("education"), + cls.message_id.label("message_id"), Inbox_Messages.file_name, Inbox_Messages.file_path, Inbox_Messages.ats_score, Inbox_Messages.ats_band, + Inbox_Messages.assigned_job_post_id, + Inbox_Messages.suggested_job_post_ids, cls.created_at, + JobPosts.id.label("last_job_post_id"), JobPosts.title.label("last_job_title"), Candidates.matched_keywords, Candidates.years_experience, @@ -302,6 +306,10 @@ class Inbox(SQLModel, table=True): "file_path":row["file_path"] or None, "ai_score":int(row["ats_score"]) if row["ats_score"] is not None else None, "recommendation":row["ats_band"] or None, + "message_id":str(row["message_id"]) if row["message_id"] else None, + "assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None, + "suggested_job_post_ids":list(row["suggested_job_post_ids"] or []), + "last_job_post_id":str(row["last_job_post_id"]) if row["last_job_post_id"] else None, "last_job_title":row["last_job_title"] or None, "matched_keywords":list(row["matched_keywords"] or []), "years_experience":row["years_experience"], diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index a82adb2..1c4de18 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -694,8 +694,13 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): @classmethod async def list_bank(cls, session: AsyncSession, limit=100, offset=0): - """Unassigned No-job CVs only — assigned rows leave the bank for Matching.""" - bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None)) + """Speculative CVs (`apply_via=cv_bank`), including ones later linked to a job. + + Run ATS assigns job_post_id so the row joins Pipeline like any other + application. The bank still lists them so the ATS score is visible on + this screen. Matching remains the assign queue for unassigned rows. + """ + bank = (cls.apply_via == "cv_bank",) total = ( await session.execute( select(func.count()).select_from(cls).where(*bank) @@ -1122,6 +1127,38 @@ class Candidates(SQLModel, table=True): }) return rows + @classmethod + async def latest_completed_by_emails(cls, session: AsyncSession, emails): + """Newest completed ATS score per email, with the job it ran against. + + CV Bank speculative rows have no candidates FK; email is the join the + scorer already writes (score_bank → upsert_candidate). + """ + from job.job_post.models import JobPosts + + lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + if not lowers: + return {} + result = await session.execute( + select(cls, JobPosts.title) + .outerjoin(JobPosts, cls.job_id == JobPosts.id) + .where(func.lower(cls.candidate_email).in_(lowers)) + .where(cls.status == "completed") + .where(cls.match_score.is_not(None)) + .order_by(cls.updated_at.desc(), cls.created_at.desc()) + ) + out = {} + for rec, title in result.all(): + email = (rec.candidate_email or "").strip().lower() + if not email or email in out: + continue + out[email] = { + "match_score": int(rec.match_score) if rec.match_score is not None else None, + "job_post_id": str(rec.job_id) if rec.job_id else None, + "job_title": title or rec.job_title or None, + } + return out + @classmethod async def upsert_candidate(cls, session: AsyncSession, fields: dict): existing = None diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 0842901..7a231ea 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -13,6 +13,18 @@ from job.activity.serializers import serialize_activity from job.feedback.serializers import serialize_feedback from job.job_post.serializers import serialize_job_post +def _id_str(value): + if value in (None, ""): + return None + return str(value) + +def _id_list(value): + if not value: + return [] + if isinstance(value, (list, tuple)): + return [str(v) for v in value if v not in (None, "")] + return [str(value)] + def serialize_candidate(row) -> dict: return { "id": str(row.id), @@ -72,11 +84,13 @@ def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]: prefixed because the two sources have different key spaces and would otherwise collide in a merged list. - rank_score is the deterministic tier-1 overlap against whichever job the - recruiter is ranking by; it is None until they pick one, and it is NOT an - ATS score — ai_score is. + rank_score is optional keyword overlap used by /cv-bank/suggestions, not + by the CV Bank table. ai_score is filled after serialize by joining the + latest candidates row for this email — this function leaves it None. + Speculative uploads have no inbox suggestions; suggested_job_post_ids is []. """ name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown" + assigned=_id_str(row.job_post_id) return { "id":f"bank:{row.id}", "record_id":str(row.id), @@ -99,7 +113,13 @@ def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]: "bank_reason":(row.bank_reason or "").strip() or None, "bank_expires_at":row.bank_expires_at.isoformat() if row.bank_expires_at else None, "user_id":str(row.user_id) if row.user_id else None, - "assigned_job_post_id":str(row.job_post_id) if row.job_post_id else None, + "message_id":None, + "assigned_job_post_id":assigned, + "assigned_job_title":None, + "scored_job_post_id":None, + "scored_job_title":None, + "suggested_job_post_ids":[], + "suggested_jobs":[], "created_at":row.created_at.isoformat() if row.created_at else None, "updated_at":row.updated_at.isoformat() if row.updated_at else None, } @@ -119,6 +139,9 @@ def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]: name=(get("name") or "") or (get("email") or "") or "Unknown" expires=get("bank_expires_at") created=get("created_at") + assigned=_id_str(get("assigned_job_post_id") or get("last_job_post_id")) + suggested=_id_list(row.get("suggested_job_post_ids")) + last_title=get("last_job_title") or None return { "id":f"app:{get('inbox_id')}", "record_id":str(get("inbox_id")), @@ -139,11 +162,17 @@ def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]: "ai_score":get("ai_score"), "recommendation":get("recommendation"), "rank_score":rank_score, - "last_job_title":get("last_job_title") or None, + "last_job_title":last_title, "bank_reason":"silver_medalist", "bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires, "user_id":str(get("user_id")) if get("user_id") else None, - "assigned_job_post_id":None, + "message_id":_id_str(get("message_id")), + "assigned_job_post_id":assigned, + "assigned_job_title":last_title, + "scored_job_post_id":assigned, + "scored_job_title":last_title, + "suggested_job_post_ids":suggested, + "suggested_jobs":[], "created_at":created.isoformat() if hasattr(created,"isoformat") else created, "updated_at":None, } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index b66189c..44f47ac 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -1715,9 +1715,8 @@ class CandidateView: band=None,job_post_id=None,limit=50,offset=0): """The unified CV Bank: speculative uploads plus scored rejections. - job_post_id does not filter — it attaches the tier-1 rank_score for - that job and sorts by it, which is how a recruiter "pulls from" the - bank when an opening appears. + job_post_id does not filter — it is only used by /cv-bank/suggestions + to attach rank_score. The CV Bank screen itself no longer ranks. """ from inbox.models import Inbox from job.candidate.serializers import serialize_bank_candidate,serialize_bank_silver_medalist @@ -1734,6 +1733,8 @@ class CandidateView: ) rows.extend(serialize_bank_silver_medalist(r) for r in medalists) + rows=await self._attach_bank_ats(rows) + rows=await self._hydrate_bank_job_titles(rows) if job_post_id: rows=await self._attach_rank_scores(rows,job_post_id) @@ -1749,6 +1750,68 @@ class CandidateView: return int(os.getenv("CV_BANK_SILVER_FLOOR","60")) + async def _attach_bank_ats(self,rows): + """Join the latest completed ATS score onto speculative bank rows. + + serialize_bank_candidate leaves ai_score None; scores live on + candidates (and ats_results) keyed by email, written by score_bank. + Silver medalists already carry the inbox denorm score. + """ + emails=[] + for row in rows: + if row.get("bank_source")!="speculative": + continue + email=(row.get("email") or "").strip().lower() + if email: + emails.append(email) + if not emails: + return rows + latest=await Candidates.latest_completed_by_emails(self.session,emails) + for row in rows: + if row.get("bank_source")!="speculative": + continue + hit=latest.get((row.get("email") or "").strip().lower()) + if not hit: + continue + row["ai_score"]=hit.get("match_score") + row["recommendation"]=self._recommendation(hit.get("match_score")) + row["scored_job_post_id"]=hit.get("job_post_id") + row["scored_job_title"]=hit.get("job_title") + return rows + + async def _hydrate_bank_job_titles(self,rows): + """Resolve assigned / scored / suggested job ids to titles in one fetch.""" + ids=[] + seen=set() + def add(jid): + key=str(jid) if jid not in (None,"") else "" + if key and key not in seen: + seen.add(key) + ids.append(key) + for row in rows: + add(row.get("assigned_job_post_id")) + add(row.get("scored_job_post_id")) + for jid in row.get("suggested_job_post_ids") or []: + add(jid) + titles={} + if ids: + posts=await JobPosts.titles_by_ids(self.session,ids,active_only=False) + titles={str(p.id):p.title for p in posts} + for row in rows: + assigned=str(row.get("assigned_job_post_id") or "") + scored=str(row.get("scored_job_post_id") or "") + if not row.get("assigned_job_title"): + row["assigned_job_title"]=titles.get(assigned) + if not row.get("scored_job_title"): + row["scored_job_title"]=titles.get(scored) + suggested=[] + for jid in row.get("suggested_job_post_ids") or []: + title=titles.get(str(jid)) + if title: + suggested.append({"id":str(jid),"title":title}) + row["suggested_jobs"]=suggested + return rows + async def _attach_rank_scores(self,rows,job_post_id): """Fill rank_score from the stored tier-1 ranking for one job. diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 10e5e91..695b1a7 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -59,9 +59,8 @@ export function uploadToCvBank(file) { * The CV Bank — GET /candidate/cv-bank/fetch. Two populations in one list: * speculative uploads with no job, and rejected applicants who scored well. * - * `jobPostId` does NOT filter. It attaches rank_score (deterministic keyword - * overlap against that job) and sorts by it — the "a role just opened, who do - * we already have" view. + * `jobPostId` is unused by the CV Bank screen. The suggestions endpoint still + * passes it to attach rank_score for notifications on job create. */ export function listCvBank({ top = 100, skip = 0, source, search, skills, minYears, band, jobPostId, @@ -298,30 +297,50 @@ export const BANK_SOURCE_LABELS = { silver_medalist: 'Silver medalist', } +function asJobList(value) { + if (!Array.isArray(value)) return [] + const out = [] + for (const item of value) { + if (item && typeof item === 'object') { + const id = item.id != null ? String(item.id) : '' + if (id) out.push({ id, title: item.title || '' }) + continue + } + if (item != null && item !== '') out.push({ id: String(item), title: '' }) + } + return out +} + /** * GET /candidate/cv-bank/fetch row -> the CV Bank table. * - * Two numbers that must never be confused: `aiScore` is a real paid ATS score - * and only exists once someone ran one; `rankScore` is free keyword overlap - * against whichever job is selected. The screen renders them differently on - * purpose. + * `aiScore` is a real paid ATS score and only exists once someone ran one. + * Suggested jobs come from inbox silver-medalist payloads; speculative rows + * typically have none. `scoredJobPostId` is the job the last ATS ran against. */ export function toBankRowView(row) { const score = row.ai_score == null || row.ai_score === '' ? null : Number(row.ai_score) const aiScore = Number.isFinite(score) ? score : null - const rank = row.rank_score == null || row.rank_score === '' ? null : Number(row.rank_score) const years = row.years_experience == null || row.years_experience === '' ? null : Number(row.years_experience) const expires = row.bank_expires_at ? new Date(row.bank_expires_at) : null + const suggestedJobs = row.suggested_jobs?.length + ? asJobList(row.suggested_jobs) + : asJobList(row.suggested_job_post_ids) + const assignedJobPostId = row.assigned_job_post_id ? String(row.assigned_job_post_id) : null + const scoredJobPostId = row.scored_job_post_id + ? String(row.scored_job_post_id) + : assignedJobPostId + const isStoredCv = row.bank_source !== 'silver_medalist' return { id: String(row.id || ''), recordId: row.record_id != null ? String(row.record_id) : null, source: row.bank_source || 'speculative', sourceLabel: BANK_SOURCE_LABELS[row.bank_source] || 'Speculative', // A silver medalist is read live from their application, so removing or - // re-scoring them is not the bank's call to make. - isStoredCv: row.bank_source !== 'silver_medalist', + // re-scoring them via the bank score endpoint is not the bank's call. + isStoredCv, name: row.name || row.email || 'Unknown', email: row.email ?? null, phone: row.phone ?? null, @@ -335,7 +354,13 @@ export function toBankRowView(row) { years: Number.isFinite(years) ? years : null, aiScore, recommendation: bandOf(aiScore, row.recommendation || null), - rankScore: Number.isFinite(rank) ? rank : null, + suggestedJobs, + scoredJobPostId, + scoredJobTitle: row.scored_job_title || row.assigned_job_title || null, + assignedJobPostId, + assignedJobTitle: row.assigned_job_title || null, + messageId: row.message_id ? String(row.message_id) : null, + canRunAts: isStoredCv || Boolean(row.message_id), lastJobTitle: row.last_job_title ?? null, bankReason: row.bank_reason ?? null, expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires : null,