commit
parent
2efc2cdf3a
commit
34f15c7183
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue