diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py index 41cc9a5..96d9145 100644 --- a/backend/employment_agent/decorators.py +++ b/backend/employment_agent/decorators.py @@ -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 diff --git a/backend/employment_agent/plugins.py b/backend/employment_agent/plugins.py index 05b1904..01a3aef 100644 --- a/backend/employment_agent/plugins.py +++ b/backend/employment_agent/plugins.py @@ -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"(? 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): diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py index 5dfd420..a22c40d 100644 --- a/backend/employment_agent/prompt.py +++ b/backend/employment_agent/prompt.py @@ -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): diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index a16fd06..6a34078 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -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).""" diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 2c974a9..2794ff0 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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).""" diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 05a8875..a82adb2 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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.""" diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index cd3729d..0842901 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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") diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index e000ede..b66189c 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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. diff --git a/frontend/candidates-table.test.mjs b/frontend/candidates-table.test.mjs index afdb05b..8965873 100644 --- a/frontend/candidates-table.test.mjs +++ b/frontend/candidates-table.test.mjs @@ -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, diff --git a/frontend/src/api/analytics.js b/frontend/src/api/analytics.js index 9b9f873..309c8c8 100644 --- a/frontend/src/api/analytics.js +++ b/frontend/src/api/analytics.js @@ -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( diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 989dc76..10e5e91 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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), diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index 1e4a8c7..120c2af 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -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', diff --git a/frontend/src/components/ReapplicantHistory.jsx b/frontend/src/components/ReapplicantHistory.jsx index 7fd159a..b61476c 100644 --- a/frontend/src/components/ReapplicantHistory.jsx +++ b/frontend/src/components/ReapplicantHistory.jsx @@ -12,6 +12,7 @@ const SOURCE_LABEL = { } const STAGE_BADGE = { + CLOSED: 'b-gray', Shortlist: 'b-indigo', Screening: 'b-teal', Assessment: 'b-purple', diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 918db3f..35ec9dc 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -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) { diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 35a563e..44672ba 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -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', diff --git a/frontend/src/screens/Pipeline.jsx b/frontend/src/screens/Pipeline.jsx index 9e257d7..19b98e8 100644 --- a/frontend/src/screens/Pipeline.jsx +++ b/frontend/src/screens/Pipeline.jsx @@ -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)' }, diff --git a/frontend/src/screens/Reports.jsx b/frontend/src/screens/Reports.jsx index 0f51bf5..1a93d63 100644 --- a/frontend/src/screens/Reports.jsx +++ b/frontend/src/screens/Reports.jsx @@ -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' },