corrected candiadte

pull/88/head
ahmed.mujtaba 2026-09-10 14:55:57 +05:00
parent 4a33651082
commit 2efc2cdf3a
17 changed files with 550 additions and 198 deletions

View File

@ -101,15 +101,12 @@ def _clean_phone(value,resume_text):
text=(value or "").strip()
if not text or text.lower() in (NO_PHONE.lower(),"none","null","n/a","-"):
return None
digits=re.sub(r"\D","",text)
if digits.startswith("00"):
digits=digits[2:]
if len(digits)<10 or len(digits)>15:
from employment_agent.plugins import _phone_digits,_phone_score,phone_in_resume
digits=_phone_digits(text)
if _phone_score(digits)<0:
return None
if (resume_text or "").strip() and not phone_in_resume(digits,resume_text):
return None
if (resume_text or "").strip():
haystack=re.sub(r"\D","",resume_text)
if digits not in haystack:
return None
return text

View File

@ -9,8 +9,8 @@ Call like the rest of the backend:
fields=parse_linkedin({"linkedin_url":raw},resume_text)
url=fields["linkedin_url"]
`scan_phone` is the regex guts `prefer_extracted_phone` uses so the stacked
parser cannot recurse into itself.
`scan_phone` is the digit-span scan `prefer_extracted_phone` uses so the
stacked parser cannot recurse into itself.
"""
from __future__ import annotations
@ -23,16 +23,43 @@ from employment_agent.decorators import (
prefer_extracted_phone,
)
_PK_MOBILE=re.compile(
r"(?:(?:\+|00)[\s\-.]*)?(?:92[\s\-.]*)?0?3\d{2}(?:[\s\-.\n]*\d){7}"
)
_PHONE_SPAN=re.compile(
r"(?:(?:\+|00)[\s\-.]*)?(?:\(?\d[\s\-()./\n]*){8,16}\d"
)
# PDF extraction uses en/em dashes, nbsp, and bullets as digit separators.
_DASH_TO_HYPHEN=str.maketrans({
"\u2010":"-","\u2011":"-","\u2012":"-","\u2013":"-","\u2014":"-",
"\u2015":"-","\u2212":"-","\u2043":"-","\uFE58":"-","\uFE63":"-",
"\uFF0D":"-",
})
_STRIP_INVISIBLE="".join((
"\u00ad","\u200b","\u200c","\u200d","\u2060","\ufeff",
))
_DIGIT_TO_ASCII=str.maketrans({
**{chr(0x0660+i):str(i) for i in range(10)},
**{chr(0x06F0+i):str(i) for i in range(10)},
**{chr(0xFF10+i):str(i) for i in range(10)},
})
_OCR_O=re.compile(r"(?<![A-Za-z0-9])[Oo](?=3\d{2}[\s\-.\d]{6,})")
_DIGIT_GROUP=re.compile(r"\+?\d+")
_GAP_OK=re.compile(r"^[\s\-./()[\]{},:|•·∙+_]*$")
_WA_ME=re.compile(r"(?i)(?:wa\.me/|api\.whatsapp\.com/send\?phone=)(\+?\d{10,15})")
_TEL_URI=re.compile(r"(?i)tel:\s*(\+?[\d\s\-().]{8,22})")
_YEAR=re.compile(r"^(?:19|20)\d{2}$")
def _normalize_phone_text(text:str) -> str:
raw=(text or "").translate(_DIGIT_TO_ASCII).translate(_DASH_TO_HYPHEN)
raw=raw.replace("\xa0"," ").replace("\u202f"," ").replace("\u2009"," ")
raw=raw.replace("\u2007"," ").replace("\u2028","\n").replace("\u2029","\n")
for ch in _STRIP_INVISIBLE:
raw=raw.replace(ch,"")
return _OCR_O.sub("0",raw)
def _digits_only(raw:str) -> str:
return re.sub(r"\D","",_normalize_phone_text(raw or ""))
def _phone_digits(raw:str) -> str:
digits=re.sub(r"\D","",raw or "")
digits=_digits_only(raw)
if digits.startswith("00"):
digits=digits[2:]
return digits
@ -54,37 +81,90 @@ def _phone_score(digits:str) -> int:
return n
def _phone_keys(digits:str) -> set[str]:
"""03XX / +92 3XX / 3XX national forms of the same PK mobile."""
d=_phone_digits(digits) if re.search(r"\D",digits or "") else (digits or "")
if d.startswith("00"):
d=d[2:]
keys={d}
if d.startswith("92") and len(d)>=12:
rest=d[2:]
keys.add(rest)
if rest.startswith("3"):
keys.add("0"+rest)
if d.startswith("0") and len(d)>=11:
keys.add(d[1:])
keys.add("92"+d[1:])
if d.startswith("3") and len(d)==10:
keys.add("0"+d)
keys.add("92"+d)
return {k for k in keys if len(k)>=10}
def phone_in_resume(digits:str,resume_text:str) -> bool:
"""True when this number (or its 03 / +92 twin) appears in the CV digits."""
haystack=_digits_only(resume_text)
if not haystack:
return True
return any(key in haystack for key in _phone_keys(digits))
def _tidy_raw(raw:str) -> str:
compact=re.sub(r"[\n\r]+"," ",raw or "")
compact=re.sub(r"[ \t]+"," ",compact)
return compact.strip(" \t-./()[]{},:|•·∙_")
def _consider(raw:str,best:str|None,best_score:int) -> tuple[str|None,int]:
value=_tidy_raw(raw)
score=_phone_score(_phone_digits(value))
if score>best_score:
return value,score
return best,best_score
def _scan_digit_groups(text:str,best:str|None,best_score:int) -> tuple[str|None,int]:
groups=list(_DIGIT_GROUP.finditer(text))
for i,start_g in enumerate(groups):
acc=start_g.group(0)
end=start_g.end()
best,best_score=_consider(acc,best,best_score)
for nxt in groups[i+1:]:
gap=text[end:nxt.start()]
if not _GAP_OK.match(gap):
break
nxt_digits=nxt.group(0).lstrip("+")
if _YEAR.match(nxt_digits) and len(_phone_digits(acc))>=10:
break
combined=_phone_digits(acc+nxt.group(0))
if len(combined)>15:
break
acc=text[start_g.start():nxt.end()]
end=nxt.end()
best,best_score=_consider(acc,best,best_score)
return best,best_score
def scan_phone(text:str) -> str|None:
"""Regex scan of CV text — complete numbers only, never a truncated prefix."""
best=None
best_score=-1
haystack=text or ""
for pattern in (_PK_MOBILE,_PHONE_SPAN):
"""Scan CV text for a complete phone — unicode separators, wrap, tel/wa.me."""
haystack=_normalize_phone_text(text or "")
best,best_score=None,-1
best,best_score=_scan_digit_groups(haystack,best,best_score)
for pattern in (_WA_ME,_TEL_URI):
for match in pattern.finditer(haystack):
raw=re.sub(r"[\n\r]+"," ",match.group(0))
raw=re.sub(r"[\s\-()]+"," ",raw).strip()
score=_phone_score(_phone_digits(raw))
if score>best_score:
best_score=score
best=raw
if best_score>=180:
return best
best,best_score=_consider(match.group(1),best,best_score)
return best
def prefer_full_phone(*candidates) -> str|None:
"""Keep the candidate with the most digits (min 10). Truncated regex loses."""
best=None
best_n=-1
"""Keep the strongest complete number. Truncated / CNIC-shaped values lose."""
best,best_score=None,-1
for raw in candidates:
value=(raw or "").strip()
if not value:
continue
n=len(_phone_digits(value))
if n>=10 and n>best_n:
best_n=n
best=value
return best
best,best_score=_consider(value,best,best_score)
return best if best_score>=0 else None
def _as_str(data,key):

View File

@ -80,7 +80,9 @@ phone (its own key — extract this separately; copy EVERY digit):
- Return the candidate's own mobile / phone exactly as written, including country code when present.
- Pakistani mobiles are 11 digits local (03XX-XXXXXXX / 03XX XXXXXXX) or +92 3XX XXXXXXX (12 digits with country code). Copy the last group in full never stop after 7 or 8 digits.
- If PDF extraction wrapped the number across lines (e.g. "0321-5551\\n234"), join the groups into one complete number.
- Spaces, hyphens, and parentheses are allowed; do not delete trailing digits to "clean" the value.
- Spaces, hyphens, parentheses, en-dashes, bullets, and non-breaking spaces are allowed; do not delete trailing digits to "clean" the value.
- A Phone / Mobile / Cell / WhatsApp / Tel label may sit on the line above the digits still copy the number.
- 03XX-XXXXXXX and +92 3XX XXXXXXX are the same number; return the form written on the resume.
- Do not invent a number. If none is mentioned, return exactly: {NO_PHONE}
Examples of CORRECT values (copy this completeness; these are format samples, not this candidate):

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, Index, and_, case, delete, func, insert, or_, update
from sqlalchemy import Column, DateTime, Index, and_, case, delete, false, func, insert, or_, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
@ -127,6 +127,70 @@ class FormData(SQLModel, table=True):
else_=False,
)
@classmethod
def _suggested_contains_any(cls, job_post_ids):
ids = [str(jid) for jid in (job_post_ids or []) if jid]
if not ids:
return false()
return or_(*(cls.suggested_job_post_ids.contains([sid]) for sid in ids))
@classmethod
def _has_job_link(cls):
return or_(
cls.assigned_job_post_id.is_not(None),
cls.job_post_id.is_not(None),
~cls._no_suggested_jobs(),
)
@classmethod
def _matches_any_job(cls, job_post_ids):
ids = list(job_post_ids or [])
if not ids:
return false()
return or_(
cls.assigned_job_post_id.in_(ids),
cls.job_post_id.in_(ids),
cls._suggested_contains_any(ids),
)
@classmethod
def _talent_pool_filters(cls, *, search=None, job_post_ids=None):
"""Same WHERE as list_for_talent_pool / count_for_talent_pool."""
filters = [cls.manual_upload_candidate_id.is_(None)]
if job_post_ids is not None:
filters.append(cls._matches_any_job(list(job_post_ids)))
else:
filters.append(cls._has_job_link())
if search:
like = f"%{search.strip()}%"
filters.append(or_(cls.name.ilike(like), cls.candidate_email.ilike(like)))
return filters
@classmethod
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
"""Candidates list: unpromoted form rows with assigned or suggested jobs."""
if job_post_ids is not None and not list(job_post_ids):
return []
qry = (
select(cls)
.where(*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids))
.order_by(cls.created_at.desc(), cls.id.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(qry)
return list(result.scalars().all())
@classmethod
async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None):
if job_post_ids is not None and not list(job_post_ids):
return 0
qry = select(func.count()).select_from(cls).where(
*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids)
)
result = await session.execute(qry)
return result.scalar_one()
@staticmethod
def _cities_match(column, cities):
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""

View File

@ -365,6 +365,20 @@ class Inbox(SQLModel, table=True):
pattern = f"%{search}%"
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
@classmethod
def _with_job_link(cls, qry, job_post_ids):
"""List mode: keep only applications with an assigned post or suggestions.
`job_post_ids is None` is the unscoped (admin) list still require a
link so unassigned inbox mail never appears on Candidates. A UUID list
is assigned IN those ids OR suggested_job_post_ids containing any of
them (the `assigned_job_post_id` query param is that one-element list).
"""
qry = qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
if job_post_ids is not None:
return qry.where(Inbox_Messages._matches_any_job(list(job_post_ids)))
return qry.where(Inbox_Messages._has_job_link())
@classmethod
async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None,job_post_ids=None):
try:
@ -388,11 +402,8 @@ class Inbox(SQLModel, table=True):
qry = qry.where(cls.user_id == user_id)
if search:
qry = qry.where(cls._candidate_search_filter(search))
if job_post_ids is not None:
qry = (
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
)
if not user_id:
qry = cls._with_job_link(qry, job_post_ids)
# Most-recent-first is the list contract; id breaks ties so a page
# boundary can't drop or repeat a row when created_at collides.
qry = qry.order_by(cls.created_at.desc(), cls.id.desc())
@ -422,11 +433,8 @@ class Inbox(SQLModel, table=True):
qry = qry.where(cls.user_id == user_id)
if search:
qry = qry.where(cls._candidate_search_filter(search))
if job_post_ids is not None:
qry = (
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
)
if not user_id:
qry = cls._with_job_link(qry, job_post_ids)
result = await session.execute(qry)
return result.scalar_one()
except Exception as e:
@ -1026,6 +1034,28 @@ class Inbox_Messages(SQLModel, table=True):
else_=False,
)
@classmethod
def _suggested_contains_any(cls, job_post_ids):
"""JSONB @> '["uuid"]' for any id — suggested_job_post_ids stores strings."""
ids = [str(jid) for jid in (job_post_ids or []) if jid]
if not ids:
return false()
return or_(*(cls.suggested_job_post_ids.contains([sid]) for sid in ids))
@classmethod
def _has_job_link(cls):
return or_(cls.assigned_job_post_id.is_not(None), ~cls._no_suggested_jobs())
@classmethod
def _matches_any_job(cls, job_post_ids):
ids = list(job_post_ids or [])
if not ids:
return false()
return or_(
cls.assigned_job_post_id.in_(ids),
cls._suggested_contains_any(ids),
)
@staticmethod
def _cities_match(column, cities):
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""

View File

@ -485,23 +485,13 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return updated
@classmethod
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
"""Newest applications with a user + job for Talent Pool (manual / form)."""
from users.models import Users
if job_post_ids is not None and not list(job_post_ids):
return []
statement = (
select(cls)
.join(Users, cls.user_id == Users.id)
.where(cls.user_id.is_not(None), cls.job_post_id.is_not(None))
.order_by(cls.created_at.desc())
)
def _talent_pool_filters(cls, Users, *, search=None, job_post_ids=None):
filters = [cls.user_id.is_not(None), cls.job_post_id.is_not(None)]
if job_post_ids is not None:
statement = statement.where(cls.job_post_id.in_(list(job_post_ids)))
filters.append(cls.job_post_id.in_(list(job_post_ids)))
if search:
like = f"%{search.strip()}%"
statement = statement.where(
filters.append(
or_(
cls.candidate_name.ilike(like),
cls.candidate_email.ilike(like),
@ -509,10 +499,41 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
Users.email.ilike(like),
)
)
statement = statement.limit(limit).offset(offset)
return filters
@classmethod
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
"""Newest applications with a user + assigned job for Candidates / Talent Pool."""
from users.models import Users
if job_post_ids is not None and not list(job_post_ids):
return []
statement = (
select(cls)
.join(Users, cls.user_id == Users.id)
.where(*cls._talent_pool_filters(Users, search=search, job_post_ids=job_post_ids))
.order_by(cls.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None):
from users.models import Users
if job_post_ids is not None and not list(job_post_ids):
return 0
statement = (
select(func.count())
.select_from(cls)
.join(Users, cls.user_id == Users.id)
.where(*cls._talent_pool_filters(Users, search=search, job_post_ids=job_post_ids))
)
result = await session.execute(statement)
return result.scalar_one()
@classmethod
async def sources_by_user_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
"""Newest platform/apply_via label per user — Candidates Form badges."""

View File

@ -313,6 +313,79 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
}
def serialize_manual_candidate_list(profile: Dict[str, Any]) -> Dict[str, Any]:
"""List shape of serialize_manual_candidate_profile — drop heavy detail."""
return {
"inbox_id": None,
"manual_upload_candidate_id": profile.get("manual_upload_candidate_id"),
"user_id": profile.get("user_id"),
"candidate_id": None,
"name": profile.get("name"),
"email": profile.get("email"),
"is_active": profile.get("is_active"),
"message_id": None,
"created_at": profile.get("created_at"),
"application_status": profile.get("application_status"),
"experience": profile.get("experience"),
"current_employment": profile.get("current_employment"),
"current_title": profile.get("current_title"),
"resume_text": None,
"suggested_job_post_ids": profile.get("suggested_job_post_ids") or [],
"assigned_job_post_id": profile.get("assigned_job_post_id"),
"job_posts": profile.get("job_posts") or [],
"assigned_job_post": profile.get("assigned_job_post"),
"job_title": profile.get("job_title"),
"recruiter": profile.get("recruiter"),
"recruiter_id": profile.get("recruiter_id"),
"source": profile.get("source"),
"file_path": profile.get("file_path"),
"ai_score": None,
"recommendation": None,
}
def serialize_form_candidate_list(row) -> Dict[str, Any]:
"""GET /candidate/fetch list row for an unpromoted FormData application.
processing_state is an inbox tab, not Candidate_application_Status, so it
is not copied onto application_status CLOSED/rejected-tab values would
paint every sheet row as Rejected on Candidates.
"""
assigned = row.assigned_job_post_id or row.job_post_id
suggested = [str(v) for v in (row.suggested_job_post_ids or []) if v not in (None, "")]
created = row.created_at.isoformat() if row.created_at else None
name = (row.name or "").strip() or None
email = (row.candidate_email or "").strip() or None
return {
"inbox_id": None,
"form_data_id": str(row.id),
"manual_upload_candidate_id": None,
"user_id": None,
"candidate_id": None,
"name": name,
"email": email,
"is_active": None,
"message_id": None,
"created_at": created,
"application_status": None,
"experience": (row.experience or "").strip() or None,
"current_employment": (row.current_company or "").strip() or None,
"current_title": (row.position_applied_for or "").strip() or None,
"resume_text": None,
"suggested_job_post_ids": suggested,
"assigned_job_post_id": str(assigned) if assigned else None,
"job_posts": [],
"assigned_job_post": None,
"job_title": (row.position_applied_for or "").strip() or None,
"recruiter": None,
"recruiter_id": None,
"source": "Form",
"file_path": None,
"ai_score": None,
"recommendation": None,
}
def serialize_manager_candidate(row, *, source) -> dict:
"""One application on a hiring-manager's job — list row, not the profile."""
inbox_id = row.get("inbox_id")

View File

@ -25,7 +25,7 @@ from job.candidate.plugins import (
normalize_spaced_text,
)
from g_sheet.models import FormData
from job.candidate.serializers import is_assigned_application,serialize_application_history,serialize_application_history_item,serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
from job.candidate.serializers import is_assigned_application,serialize_application_history,serialize_application_history_item,serialize_candidate,serialize_candidate_profile,serialize_form_candidate_list,serialize_manual_candidate_list,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
@ -1212,126 +1212,138 @@ class CandidateView:
await assert_manager_candidate_access(
self.session,current_user,user_id=user_id,created_by=created_by,
)
detail=bool(user_id)
list_job_ids=None
if not detail:
list_job_ids=await job_post_ids_for_candidate_list(
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
return await self._get_candidate_detail(
user_id,limit=limit,offset=offset,search=search,
current_user=current_user,created_by=created_by,
)
if list_job_ids is not None and not list_job_ids:
return []
rows=await Inbox.get_candidate_profile(
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
job_post_ids=list_job_ids,
list_job_ids=await job_post_ids_for_candidate_list(
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
if detail:
records=rows if isinstance(rows,list) else ([rows] if rows else [])
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
if records and owned is not None:
owned_set=set(owned)
kept=[]
for rec in records:
msg=getattr(rec,"messages",None)
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
if jid and jid in owned_set:
kept.append(rec)
if kept:
return await self.attach_application_history(await self.attach_profile_detail(kept))
records=[]
if records:
return await self.attach_application_history(await self.attach_profile_detail(rows))
# Manual uploads create users + manual_upload_candidate but no inbox
# row — resolve the profile from that table instead of returning [].
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
if not manual:
return []
if owned is not None:
owned_set=set(owned)
if not manual.job_post_id or manual.job_post_id not in owned_set:
raise HTTPException(status_code=403,detail=_scope_detail(current_user))
user=await Users.get_user_by_id(self.session,user_id)
job_post=None
if manual.job_post_id:
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
payload=serialize_manual_candidate_profile(manual,user,job_post)
from inbox.plugins import get_ats_score_for_manual_user
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
if score:
payload["ai_score"]=score["overall_score"]
payload["recommendation"]=self._recommendation(score["overall_score"])
payload["scored_at"]=score["computed_at"]
payload["candidate_id"]=score.get("candidate_id")
if score.get("user_id") and not payload.get("user_id"):
payload["user_id"]=score["user_id"]
if score.get("job_post_id"):
payload["scored_job_post_id"]=score["job_post_id"]
return await self.attach_application_history(payload)
# List mode: inbox applications + manual/form applications (dedupe by user).
inbox_payloads=await self.attach_job_posts(rows)
if not isinstance(inbox_payloads,list):
inbox_payloads=[inbox_payloads] if inbox_payloads else []
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
self.session,limit=limit,offset=0,search=search,job_post_ids=list_job_ids,
if list_job_ids is not None and not list_job_ids:
return []
return await self._list_candidates(
limit=limit,offset=offset,search=search,job_post_ids=list_job_ids,
)
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
manual_payloads=[]
for manual in manual_rows:
uid=str(manual.user_id) if manual.user_id else None
if uid and uid in seen:
continue
user=await Users.get_user_by_id(self.session,manual.user_id) if manual.user_id else None
job_post=None
if manual.job_post_id:
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
payload=serialize_manual_candidate_profile(manual,user,job_post)
# List shape matches attach_job_posts: keep job_posts, drop heavy detail.
manual_payloads.append({
"inbox_id":None,
"manual_upload_candidate_id":payload["manual_upload_candidate_id"],
"user_id":payload["user_id"],
"candidate_id":None,
"name":payload["name"],
"email":payload["email"],
"is_active":payload.get("is_active"),
"message_id":None,
"created_at":payload.get("created_at"),
"application_status":payload.get("application_status"),
"experience":payload.get("experience"),
"current_employment":payload.get("current_employment"),
"current_title":payload.get("current_title"),
"resume_text":None,
"suggested_job_post_ids":[],
"assigned_job_post_id":payload.get("assigned_job_post_id"),
"job_posts":payload.get("job_posts") or [],
"assigned_job_post":payload.get("assigned_job_post"),
"job_title":payload.get("job_title"),
"recruiter":payload.get("recruiter"),
"recruiter_id":payload.get("recruiter_id"),
"source":payload.get("source"),
"file_path":payload.get("file_path"),
"ai_score":None,
"recommendation":None,
})
if uid:
seen.add(uid)
from inbox.plugins import get_ats_scores_for_users
owners=[p.get("user_id") for p in manual_payloads if p.get("user_id")]
ats=await get_ats_scores_for_users(self.session,owners)
for payload in manual_payloads:
row=ats.get(str(payload.get("user_id") or ""))
if row and row.get("overall_score") is not None:
payload["ai_score"]=row["overall_score"]
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
data=inbox_payloads+manual_payloads
if assigned_job_post_id:
job_id=str(assigned_job_post_id)
data=[p for p in data if str(p.get("assigned_job_post_id") or "")==job_id]
return await self.attach_application_history(data)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def _get_candidate_detail(self,user_id,limit=10,offset=0,search=None,current_user=None,created_by=False):
rows=await Inbox.get_candidate_profile(
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
)
records=rows if isinstance(rows,list) else ([rows] if rows else [])
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
if records and owned is not None:
owned_set=set(owned)
kept=[]
for rec in records:
msg=getattr(rec,"messages",None)
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
if jid and jid in owned_set:
kept.append(rec)
if kept:
return await self.attach_application_history(await self.attach_profile_detail(kept))
records=[]
if records:
return await self.attach_application_history(await self.attach_profile_detail(rows))
return await self._get_manual_detail(user_id,owned,current_user)
async def _get_manual_detail(self,user_id,owned,current_user):
from inbox.plugins import get_ats_score_for_manual_user
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
if not manual:
return []
if owned is not None:
owned_set=set(owned)
if not manual.job_post_id or manual.job_post_id not in owned_set:
raise HTTPException(status_code=403,detail=_scope_detail(current_user))
user=await Users.get_user_by_id(self.session,user_id)
job_post=None
if manual.job_post_id:
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
payload=serialize_manual_candidate_profile(manual,user,job_post)
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
if score:
payload["ai_score"]=score["overall_score"]
payload["recommendation"]=self._recommendation(score["overall_score"])
payload["scored_at"]=score["computed_at"]
payload["candidate_id"]=score.get("candidate_id")
if score.get("user_id") and not payload.get("user_id"):
payload["user_id"]=score["user_id"]
if score and score.get("job_post_id"):
payload["scored_job_post_id"]=score["job_post_id"]
return await self.attach_application_history(payload)
async def _list_candidates(self,limit=10,offset=0,search=None,job_post_ids=None):
rows=await Inbox.get_candidate_profile(
session=self.session,limit=limit,offset=offset,search=search,job_post_ids=job_post_ids,
)
inbox_payloads=await self.attach_job_posts(rows)
if not isinstance(inbox_payloads,list):
inbox_payloads=[inbox_payloads] if inbox_payloads else []
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
seen_emails={(p.get("email") or "").strip().lower() for p in inbox_payloads if (p.get("email") or "").strip()}
manual_payloads=await self._list_manual_payloads(
limit=limit,search=search,job_post_ids=job_post_ids,seen=seen,seen_emails=seen_emails,
)
form_payloads=await self._list_form_payloads(
limit=limit,search=search,job_post_ids=job_post_ids,seen_emails=seen_emails,
)
return await self.attach_application_history(inbox_payloads+manual_payloads+form_payloads)
async def _list_manual_payloads(self,limit,search,job_post_ids,seen,seen_emails):
rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids,
)
payloads=[]
for row in rows:
uid=str(row.user_id) if row.user_id else None
if uid and uid in seen:
continue
user=await Users.get_user_by_id(self.session,row.user_id) if row.user_id else None
job_post=None
if row.job_post_id:
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
payload=serialize_manual_candidate_list(serialize_manual_candidate_profile(row,user,job_post))
payloads.append(payload)
if uid:
seen.add(uid)
email=(payload.get("email") or "").strip().lower()
if email:
seen_emails.add(email)
await self._attach_user_ats(payloads)
return payloads
async def _list_form_payloads(self,limit,search,job_post_ids,seen_emails):
rows=await FormData.list_for_talent_pool(
self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids,
)
payloads=[]
for rec in rows:
payload=serialize_form_candidate_list(rec)
email=(payload.get("email") or "").strip().lower()
if email and email in seen_emails:
continue
payloads.append(payload)
if email:
seen_emails.add(email)
payloads=await self._hydrate_list_jobs(payloads)
await self._attach_form_ats(payloads)
return payloads
async def _attach_user_ats(self,payloads):
from inbox.plugins import get_ats_scores_for_users
owners=[p.get("user_id") for p in payloads if p.get("user_id")]
ats=await get_ats_scores_for_users(self.session,owners)
for payload in payloads:
row=ats.get(str(payload.get("user_id") or ""))
if row and row.get("overall_score") is not None:
payload["ai_score"]=row["overall_score"]
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
try:
job_post_ids=None
@ -1339,9 +1351,20 @@ class CandidateView:
job_post_ids=await job_post_ids_for_candidate_list(
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
return await Inbox.count_candidate_profiles(
if job_post_ids is not None and not job_post_ids:
return 0
inbox_n=await Inbox.count_candidate_profiles(
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
)
if user_id:
return inbox_n
manual_n=await Manual_UPLOAD_CANDIDATE.count_for_talent_pool(
self.session,search=search,job_post_ids=job_post_ids,
)
form_n=await FormData.count_for_talent_pool(
self.session,search=search,job_post_ids=job_post_ids,
)
return inbox_n+manual_n+form_n
except HTTPException:
raise
except Exception as e:
@ -1410,6 +1433,47 @@ class CandidateView:
data["job_title"]=payload.get("title")
return payload
async def _hydrate_list_jobs(self,payloads):
"""Attach assigned + suggested job posts onto already-serialized list rows."""
wanted=[]
for payload in payloads:
if payload.get("assigned_job_post_id"):
wanted.append(payload["assigned_job_post_id"])
wanted.extend(payload.get("suggested_job_post_ids") or [])
posts=await self._job_posts_by_id(wanted)
for payload in payloads:
assigned_id=payload.get("assigned_job_post_id")
if assigned_id:
post=posts.get(str(assigned_id))
self._attach_job_post(payload,dict(post) if post else None,as_assigned=True)
for job_id in payload.get("suggested_job_post_ids") or []:
if assigned_id and str(job_id)==str(assigned_id):
continue
post=posts.get(str(job_id))
self._attach_job_post(payload,dict(post) if post else None)
return payloads
async def _attach_form_ats(self,payloads):
grouped=await AtsResults.get_current_for_forms(
self.session,[p.get("form_data_id") for p in payloads],
)
for payload in payloads:
fid=payload.get("form_data_id")
try:
key=uuid.UUID(str(fid)) if fid else None
except (TypeError,ValueError):
key=None
rows=grouped.get(key) or [] if key is not None else []
assigned=payload.get("assigned_job_post_id")
chosen=None
if assigned:
chosen=next((r for r in rows if str(r.job_post_id)==str(assigned)),None)
if chosen is None and rows:
chosen=rows[0]
if chosen is not None and chosen.overall_score is not None:
payload["ai_score"]=chosen.overall_score
payload["recommendation"]=chosen.band or self._recommendation(chosen.overall_score)
async def _job_posts_by_id(self,ids):
"""Serialized job posts keyed by id — one query for a whole page of rows.

View File

@ -72,9 +72,18 @@ const rejected = toApplicationListView({
created_at: '2026-09-01T10:00:00Z',
})
ok('REJECTED maps to Rejected stage', rejected.stage === 'Rejected', `stage=${rejected.stage}`)
ok('CLOSED also reads as Rejected', toApplicationListView({
ok('CLOSED shows as CLOSED', toApplicationListView({
inbox_id: 43, application_status: 'CLOSED', name: 'Closed',
}).stage === 'Rejected')
}).stage === 'CLOSED')
const formRow = toApplicationListView({
form_data_id: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
name: 'Form Applicant',
source: 'Form',
job_title: 'Brand Manager',
})
ok('form row key', formRow.id === 'form:ffffffff-ffff-ffff-ffff-ffffffffffff')
ok('form with no pipeline status has no stage', formRow.stage == null)
const hired = toApplicationListView({
inbox_id: 44,

View File

@ -31,14 +31,14 @@ export function funnel({ fromDate, toDate, department, recruiterId } = {}) {
/** Board column order used by the pipeline page. Rejected is last so callers
* that drop outcomes can slice it off without re-sorting. */
const BOARD_STAGE_ORDER = [
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
'CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
'Approved', 'Hired', 'On Hold', 'Rejected',
]
/**
* Fold /analytics/funnel/fetch rows (11 enum statuses) onto the pipeline
* columns. Same mapping the board uses, so CLOSED is Rejected and ONHOLD /
* APPROVED keep their own columns rather than folding into Screening / Hired.
* columns. Same mapping the board uses, so CLOSED stays CLOSED and only
* REJECTED is Rejected. ONHOLD / APPROVED keep their own columns.
*/
export function toBoardStageRows(funnelRows, { includeRejected = false } = {}) {
const folded = toStageCounts(

View File

@ -238,6 +238,12 @@ function statusKey(value) {
return String(value).toUpperCase()
}
function listStageOf(status) {
const key = statusKey(status)
if (!key) return null
return STAGE_FROM_STATUS[key] ?? 'Shortlist'
}
function bandOf(score, recommendation) {
if (recommendation) return recommendation
if (score == null || !Number.isFinite(Number(score))) return null
@ -261,12 +267,15 @@ export function toApplicationListView(row) {
const rawScore = row.ai_score ?? row.match_score
const aiScore = rawScore == null || rawScore === '' ? null : Number(rawScore)
const score = Number.isFinite(aiScore) ? aiScore : null
const id = row.inbox_id != null
? `inbox:${row.inbox_id}`
: (row.manual_upload_candidate_id
? `manual:${row.manual_upload_candidate_id}`
: (row.form_data_id
? `form:${row.form_data_id}`
: String(row.user_id || row.id || name)))
return {
id: row.inbox_id != null
? `inbox:${row.inbox_id}`
: (row.manual_upload_candidate_id
? `manual:${row.manual_upload_candidate_id}`
: String(row.user_id || row.id || name)),
id,
userId: row.user_id || null,
name,
email: row.email ?? null,
@ -274,7 +283,7 @@ export function toApplicationListView(row) {
jobTitle,
recruiter: row.recruiter || null,
applicationStatus: status || null,
stage: status ? (STAGE_FROM_STATUS[status] ?? 'Shortlist') : null,
stage: listStageOf(row.application_status ?? row.stage),
source: row.source || null,
aiScore: score,
recommendation: bandOf(score, row.recommendation || null),

View File

@ -17,9 +17,8 @@ import { toDate } from '../lib/format'
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
*
* The enum has 11 values. Approved and On Hold are first-class columns (they
* used to be folded into Hired / Screening). CLOSED is the inbox default for
* an application that did not progress it reads as Rejected, same as the
* board's Rejected column, not as Shortlist.
* used to be folded into Hired / Screening). CLOSED is the inbox default and
* is shown as CLOSED. Only REJECTED reads as Rejected.
*
* Anything unmapped falls through to Shortlist rather than vanishing from the
* board a card with no column is a candidate nobody sees.
@ -34,7 +33,7 @@ export const STAGE_FROM_STATUS = {
OFFER: 'Offer',
APPROVED: 'Approved',
HIRED: 'Hired',
CLOSED: 'Rejected',
CLOSED: 'CLOSED',
REJECTED: 'Rejected',
}
@ -46,6 +45,7 @@ export const STAGE_FROM_STATUS = {
* (not CLOSED) so new drops are distinguishable from the inbox default.
*/
export const STATUS_FROM_STAGE = {
CLOSED: 'CLOSED',
Shortlist: 'PENDING',
Screening: 'SCREENING',
'On Hold': 'ONHOLD',

View File

@ -12,6 +12,7 @@ const SOURCE_LABEL = {
}
const STAGE_BADGE = {
CLOSED: 'b-gray',
Shortlist: 'b-indigo',
Screening: 'b-teal',
Assessment: 'b-purple',

View File

@ -27,7 +27,7 @@ import { companies, moneyK, pick } from '../data/seed'
Forms) track (Notes, Activity) audit (Timeline, History). */
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', 'Timeline', 'History']
// Forward progression for the live Advance button. Rejected has no next stage.
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
const KANBAN_ORDER = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
function tabFromSearch(tabParam, visibleTabs, fallback) {

View File

@ -1,7 +1,7 @@
/* ============================================================
Candidates applications on live backend data.
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
Recruiter rows come from GET /candidate/fetch (inbox + manual + form), one row
per application, so score / stage / job / recruiter have a source.
Without candidates.manage the server scopes that list to jobs the user
owns as recruiter (or created). Tick Requisitions Configure in Access
@ -38,7 +38,7 @@ import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '.
const EMPTY_FILTERS = { account: '', stage: '', band: '' }
const SEARCH_DEBOUNCE_MS = 300
const STAGE_FILTERS = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'On Hold', 'Rejected']
const STAGE_FILTERS = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'On Hold', 'Rejected']
const BAND_FILTERS = ['Strong Match', 'Potential Match', 'Weak Match', 'Unscored']
const BAND_BADGE = {
'Strong Match': 'b-green',
@ -136,6 +136,7 @@ const REFERRAL_RE = new RegExp(
const referralValue = (raw) => (raw || '').trim().toLowerCase()
const STAGE_BADGE = {
CLOSED: 'b-gray',
Shortlist: 'b-indigo',
Screening: 'b-teal',
Assessment: 'b-purple',

View File

@ -30,6 +30,7 @@ import { ReappliedBadge } from '../components/ReapplicantHistory'
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
export const KANBAN_STAGES = [
{ name: 'CLOSED', color: 'var(--stage-9)' },
{ name: 'Shortlist', color: 'var(--stage-1)' },
{ name: 'Screening', color: 'var(--stage-2)' },
{ name: 'Assessment', color: 'var(--stage-3)' },

View File

@ -59,7 +59,7 @@ const RANGES = [
]
/* Order matters: "reached" is a running sum from the end of this list back to
the start. REJECTED, CLOSED (shown as Rejected on the board) and ONHOLD are
the start. REJECTED, CLOSED (shown as CLOSED) and ONHOLD are
absent parking and outcomes are not a step in the happy-path suffix sum. */
const FUNNEL_ORDER = [
{ key: 'PENDING', label: 'Shortlist' },