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