pull/88/head
ahmed.mujtaba 2026-09-10 15:15:05 +05:00
parent 2efc2cdf3a
commit 34f15c7183
5 changed files with 184 additions and 22 deletions

View File

@ -261,11 +261,15 @@ class Inbox(SQLModel, table=True):
Inbox_Messages.current_employment.label("current_company"), Inbox_Messages.current_employment.label("current_company"),
Inbox_Messages.current_title, Inbox_Messages.current_title,
Inbox_Messages.candidate_education.label("education"), Inbox_Messages.candidate_education.label("education"),
cls.message_id.label("message_id"),
Inbox_Messages.file_name, Inbox_Messages.file_name,
Inbox_Messages.file_path, Inbox_Messages.file_path,
Inbox_Messages.ats_score, Inbox_Messages.ats_score,
Inbox_Messages.ats_band, Inbox_Messages.ats_band,
Inbox_Messages.assigned_job_post_id,
Inbox_Messages.suggested_job_post_ids,
cls.created_at, cls.created_at,
JobPosts.id.label("last_job_post_id"),
JobPosts.title.label("last_job_title"), JobPosts.title.label("last_job_title"),
Candidates.matched_keywords, Candidates.matched_keywords,
Candidates.years_experience, Candidates.years_experience,
@ -302,6 +306,10 @@ class Inbox(SQLModel, table=True):
"file_path":row["file_path"] or None, "file_path":row["file_path"] or None,
"ai_score":int(row["ats_score"]) if row["ats_score"] is not None else None, "ai_score":int(row["ats_score"]) if row["ats_score"] is not None else None,
"recommendation":row["ats_band"] or 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, "last_job_title":row["last_job_title"] or None,
"matched_keywords":list(row["matched_keywords"] or []), "matched_keywords":list(row["matched_keywords"] or []),
"years_experience":row["years_experience"], "years_experience":row["years_experience"],

View File

@ -694,8 +694,13 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
@classmethod @classmethod
async def list_bank(cls, session: AsyncSession, limit=100, offset=0): async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
"""Unassigned No-job CVs only — assigned rows leave the bank for Matching.""" """Speculative CVs (`apply_via=cv_bank`), including ones later linked to a job.
bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None))
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 = ( total = (
await session.execute( await session.execute(
select(func.count()).select_from(cls).where(*bank) select(func.count()).select_from(cls).where(*bank)
@ -1122,6 +1127,38 @@ class Candidates(SQLModel, table=True):
}) })
return rows 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 @classmethod
async def upsert_candidate(cls, session: AsyncSession, fields: dict): async def upsert_candidate(cls, session: AsyncSession, fields: dict):
existing = None existing = None

View File

@ -13,6 +13,18 @@ from job.activity.serializers import serialize_activity
from job.feedback.serializers import serialize_feedback from job.feedback.serializers import serialize_feedback
from job.job_post.serializers import serialize_job_post 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: def serialize_candidate(row) -> dict:
return { return {
"id": str(row.id), "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 prefixed because the two sources have different key spaces and would
otherwise collide in a merged list. otherwise collide in a merged list.
rank_score is the deterministic tier-1 overlap against whichever job the rank_score is optional keyword overlap used by /cv-bank/suggestions, not
recruiter is ranking by; it is None until they pick one, and it is NOT an by the CV Bank table. ai_score is filled after serialize by joining the
ATS score ai_score is. 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" 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 { return {
"id":f"bank:{row.id}", "id":f"bank:{row.id}",
"record_id":str(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_reason":(row.bank_reason or "").strip() or None,
"bank_expires_at":row.bank_expires_at.isoformat() if row.bank_expires_at else 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, "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, "created_at":row.created_at.isoformat() if row.created_at else None,
"updated_at":row.updated_at.isoformat() if row.updated_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" name=(get("name") or "") or (get("email") or "") or "Unknown"
expires=get("bank_expires_at") expires=get("bank_expires_at")
created=get("created_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 { return {
"id":f"app:{get('inbox_id')}", "id":f"app:{get('inbox_id')}",
"record_id":str(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"), "ai_score":get("ai_score"),
"recommendation":get("recommendation"), "recommendation":get("recommendation"),
"rank_score":rank_score, "rank_score":rank_score,
"last_job_title":get("last_job_title") or None, "last_job_title":last_title,
"bank_reason":"silver_medalist", "bank_reason":"silver_medalist",
"bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires, "bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires,
"user_id":str(get("user_id")) if get("user_id") else None, "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, "created_at":created.isoformat() if hasattr(created,"isoformat") else created,
"updated_at":None, "updated_at":None,
} }

View File

@ -1715,9 +1715,8 @@ class CandidateView:
band=None,job_post_id=None,limit=50,offset=0): band=None,job_post_id=None,limit=50,offset=0):
"""The unified CV Bank: speculative uploads plus scored rejections. """The unified CV Bank: speculative uploads plus scored rejections.
job_post_id does not filter it attaches the tier-1 rank_score for job_post_id does not filter it is only used by /cv-bank/suggestions
that job and sorts by it, which is how a recruiter "pulls from" the to attach rank_score. The CV Bank screen itself no longer ranks.
bank when an opening appears.
""" """
from inbox.models import Inbox from inbox.models import Inbox
from job.candidate.serializers import serialize_bank_candidate,serialize_bank_silver_medalist 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.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: if job_post_id:
rows=await self._attach_rank_scores(rows,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")) 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): async def _attach_rank_scores(self,rows,job_post_id):
"""Fill rank_score from the stored tier-1 ranking for one job. """Fill rank_score from the stored tier-1 ranking for one job.

View File

@ -59,9 +59,8 @@ export function uploadToCvBank(file) {
* The CV Bank GET /candidate/cv-bank/fetch. Two populations in one list: * The CV Bank GET /candidate/cv-bank/fetch. Two populations in one list:
* speculative uploads with no job, and rejected applicants who scored well. * speculative uploads with no job, and rejected applicants who scored well.
* *
* `jobPostId` does NOT filter. It attaches rank_score (deterministic keyword * `jobPostId` is unused by the CV Bank screen. The suggestions endpoint still
* overlap against that job) and sorts by it the "a role just opened, who do * passes it to attach rank_score for notifications on job create.
* we already have" view.
*/ */
export function listCvBank({ export function listCvBank({
top = 100, skip = 0, source, search, skills, minYears, band, jobPostId, top = 100, skip = 0, source, search, skills, minYears, band, jobPostId,
@ -298,30 +297,50 @@ export const BANK_SOURCE_LABELS = {
silver_medalist: 'Silver medalist', 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. * GET /candidate/cv-bank/fetch row -> the CV Bank table.
* *
* Two numbers that must never be confused: `aiScore` is a real paid ATS score * `aiScore` is a real paid ATS score and only exists once someone ran one.
* and only exists once someone ran one; `rankScore` is free keyword overlap * Suggested jobs come from inbox silver-medalist payloads; speculative rows
* against whichever job is selected. The screen renders them differently on * typically have none. `scoredJobPostId` is the job the last ATS ran against.
* purpose.
*/ */
export function toBankRowView(row) { export function toBankRowView(row) {
const score = row.ai_score == null || row.ai_score === '' ? null : Number(row.ai_score) const score = row.ai_score == null || row.ai_score === '' ? null : Number(row.ai_score)
const aiScore = Number.isFinite(score) ? score : null 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 === '' const years = row.years_experience == null || row.years_experience === ''
? null ? null
: Number(row.years_experience) : Number(row.years_experience)
const expires = row.bank_expires_at ? new Date(row.bank_expires_at) : null 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 { return {
id: String(row.id || ''), id: String(row.id || ''),
recordId: row.record_id != null ? String(row.record_id) : null, recordId: row.record_id != null ? String(row.record_id) : null,
source: row.bank_source || 'speculative', source: row.bank_source || 'speculative',
sourceLabel: BANK_SOURCE_LABELS[row.bank_source] || 'Speculative', sourceLabel: BANK_SOURCE_LABELS[row.bank_source] || 'Speculative',
// A silver medalist is read live from their application, so removing or // A silver medalist is read live from their application, so removing or
// re-scoring them is not the bank's call to make. // re-scoring them via the bank score endpoint is not the bank's call.
isStoredCv: row.bank_source !== 'silver_medalist', isStoredCv,
name: row.name || row.email || 'Unknown', name: row.name || row.email || 'Unknown',
email: row.email ?? null, email: row.email ?? null,
phone: row.phone ?? null, phone: row.phone ?? null,
@ -335,7 +354,13 @@ export function toBankRowView(row) {
years: Number.isFinite(years) ? years : null, years: Number.isFinite(years) ? years : null,
aiScore, aiScore,
recommendation: bandOf(aiScore, row.recommendation || null), 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, lastJobTitle: row.last_job_title ?? null,
bankReason: row.bank_reason ?? null, bankReason: row.bank_reason ?? null,
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires : null, expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires : null,