Link_ATS_with_Sys #13

Merged
ahmed.mujtaba merged 3 commits from Link_ATS_with_Sys into main 2026-08-12 13:13:20 +00:00
16 changed files with 449 additions and 190 deletions

1
.gitignore vendored
View File

@ -6,6 +6,7 @@ Icon
?
._*
dist/**
dist/**/*
# Editor / IDE
.idea/
.vscode/

View File

@ -32,14 +32,14 @@ def clamp_company_to_resume(func):
@wraps(func)
def wrapper(data,resume_text="",*args,**kwargs):
company,education=func(data,resume_text,*args,**kwargs)
company,education,current_title=func(data,resume_text,*args,**kwargs)
company=(company or "").strip()
if not company or company.lower()==NO_COMPANY.lower():
return NO_COMPANY,education
return NO_COMPANY,education,current_title
haystack=(resume_text or "").lower()
if company.lower() not in haystack:
return NO_COMPANY,education
return company,education
return NO_COMPANY,education,current_title
return company,education,current_title
return wrapper
@ -49,14 +49,14 @@ def clamp_education_to_resume(func):
@wraps(func)
def wrapper(data,resume_text="",*args,**kwargs):
company,education=func(data,resume_text,*args,**kwargs)
company,education,current_title=func(data,resume_text,*args,**kwargs)
education=(education or "").strip()
if not education or education.lower()==EDUCATION.lower():
return company,EDUCATION
return company,EDUCATION,current_title
haystack=(resume_text or "").lower()
if education.lower() not in haystack:
return company,EDUCATION
return company,education
return company,EDUCATION,current_title
return company,education,current_title
return wrapper
@ -68,8 +68,11 @@ def parse_employment_response(data,resume_text:str="") -> tuple[str,str]:
"""Pull company + education from LLM JSON; decorators clamp to the resume."""
current=data.get("current_employment")
education=data.get("education")
current_title=data.get("current_title")
if not isinstance(current,str):
current=""
if not isinstance(education,str):
education=""
return current.strip(),education.strip()
if not isinstance(current_title,str):
current_title=""
return current.strip(),education.strip(),current_title.strip()

View File

@ -9,7 +9,7 @@ from __future__ import annotations
import logging
from employment_agent.decorators import parse_employment_response
from employment_agent.prompt import EDUCATION,NO_COMPANY,prompt,user_prompt
from employment_agent.prompt import CURRENT_TITLE,EDUCATION,NO_COMPANY,prompt,user_prompt
from llm_setup import llm_call
logger=logging.getLogger("employment_agent")
@ -18,7 +18,7 @@ logger=logging.getLogger("employment_agent")
async def run_employment_agent(*,resume_text="") -> tuple[str,str]:
text=(resume_text or "").strip()
if not text:
return NO_COMPANY,EDUCATION
return NO_COMPANY,EDUCATION,CURRENT_TITLE
try:
data=await llm_call(prompt(),user_prompt(text),json_mode=True)
return parse_employment_response(data,text)

View File

@ -9,7 +9,7 @@ import json
NO_COMPANY="no company was mentioned"
EDUCATION="No Education Mentioned"
CURRENT_TITLE="No JOB POSITION MENTIONED"
def prompt():
return f"""You are an HR-ATS recruiting assistant.
@ -20,15 +20,19 @@ name and their education (degree / school) when present.
Rules:
- Return only the company name that appears in the resume text for the ongoing / most recent role.
- Return only education that appears in the resume text.
- Return only job title that appears in the resume text.
- The company string you return MUST appear verbatim (or as a clear substring) in the resume text.
- The education string you return MUST appear verbatim (or as a clear substring) in the resume text.
- The job title string you return MUST appear verbatim (or as a clear substring) in the resume text.
- Do not invent a company. If none is mentioned, return exactly: {NO_COMPANY}
- Do not invent education. If none is mentioned, return exactly: {EDUCATION}
- Do not invent job title. If none is mentioned, return exactly: {CURRENT_TITLE}
Respond with JSON only:
{{
"current_employment": "Company Name",
"education": "Degree / School"
"education": "Degree / School",
"current_title": "Job Title"
}}
"""

View File

@ -237,6 +237,7 @@ class Inbox_Messages(SQLModel, table=True):
candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx")
candidate_education: str | None = Field(default=None)
current_employment: str | None = Field(default=None)
current_title: str | None = Field(default=None)
# Denormalised dashboard / list-screen fields. server_default is load-bearing
# for every NOT NULL column — these arrive as ALTERs on a populated table.
ats_score: float | None = Field(default=None)
@ -279,6 +280,7 @@ class Inbox_Messages(SQLModel, table=True):
candidate_education=None,
candidate_phone_number=None,
current_employment=None,
current_title=None,
suggested_job_post_ids=None,
summary="",
reasoning="",
@ -297,6 +299,8 @@ class Inbox_Messages(SQLModel, table=True):
row.candidate_education = candidate_education
if current_employment is not None:
row.current_employment = current_employment
if current_title is not None:
row.current_title = current_title
row.suggested_job_post_ids = suggested_job_post_ids
row.match_summary = summary or None
row.match_reasoning = reasoning or None

View File

@ -109,6 +109,7 @@ def serialize_application(message: Inbox_Messages) -> dict:
"phone": message.candidate_phone_number,
"experience": message.experience or "",
"current_employment": message.current_employment or "",
"current_title": message.current_title or "",
"recruiter": str(message.recruiter_id) if message.recruiter_id else None,
"duplicate": message.is_duplicate,
"processing_state": message.processing_state,

View File

@ -119,7 +119,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
if status=="failed":
raise RuntimeError(result.get("error") or "agent returned failed status")
current_employment,education=await run_employment_agent(resume_text=text)
current_employment,education,current_title=await run_employment_agent(resume_text=text)
async with session_scope() as session:
await Inbox_Messages.set_match_result(
@ -129,6 +129,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
experience=result.get("experience") or "",
candidate_phone_number=phone,
current_employment=current_employment,
current_title=current_title,
candidate_education=education,
suggested_job_post_ids=result.get("suggested_job_post_ids") or [],
summary=result.get("summary") or "",
@ -158,5 +159,6 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
"status":status,
"suggested_job_post_ids":result.get("suggested_job_post_ids") or [],
"current_employment":current_employment,
"current_title":current_title,
"education":education,
}

View File

@ -16,6 +16,7 @@ from job.job_post.views import JobPost,JobPostCreate
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
import logging
from users.views import User
from job.job_post.plugins import PlatformAlias
from fastapi import UploadFile, File, Form
from dotenv import load_dotenv
@ -134,6 +135,7 @@ async def create_manual_candidate(
candidate_phone: str | None = Form(None),
job_post_id: str | None = Form(None),
current_company: str | None = Form(None),
current_position: str | None = Form(None),
platform: str | None = Form(None),
experience: str | None = Form(None),
status: str | None = Form(None),
@ -158,6 +160,7 @@ async def create_manual_candidate(
candidate_phone=candidate_phone,
job_post_id=job_post_id,
current_company=current_company,
current_position=current_position,
platform=platform,
experience=experience,
status=status,
@ -177,6 +180,21 @@ async def create_manual_candidate(
FileRead.discard_upload(saved_path)
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch/users")
async def fetch_users(
role_id:int=Query(8),
top:int=Query(10),
skip:int=Query(0),
search:str=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=User(session=session)
data=await service.get_users(role_id=role_id,top=top,skip=skip)
return JSONResponse(content={"data":data,"status_code":200})
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/cv_upload")
async def cv_upload(

View File

@ -31,6 +31,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
full_text: str = Field(default="")
current_company: str = Field(default="")
# Candidate's role at that company (e.g. "Senior Merchandiser"). Distinct
# from job_posts.title — that is the role they applied to, not their own.
# Same ALTER-on-a-populated-table reasoning as referral_by below.
current_position: str = Field(default="", sa_column_kwargs={"server_default": ""})
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
platform: str = Field(default="")
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
@ -98,6 +102,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
job_post_id=cls._as_uuid(fields.get("job_post_id")),
full_text=fields.get("full_text") or "",
current_company=(fields.get("current_company") or "").strip(),
current_position=(fields.get("current_position") or "").strip(),
user_id=user.id,
platform=(fields.get("platform") or "").strip(),
created_by=cls._as_uuid(fields.get("created_by")),
@ -112,6 +117,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
await session.refresh(row)
return row
@classmethod
async def get_by_user_id(cls, session: AsyncSession, user_id):
uid = cls._as_uuid(user_id)
if uid is None:
return None
result = await session.execute(
select(cls).where(cls.user_id == uid).order_by(cls.created_at.desc())
)
return result.scalars().first()
class Candidates(SQLModel, table=True):
"""One scored (or failed-to-score) CV against one job post.

View File

@ -5,6 +5,7 @@ from job.candidate.plugins import documents_from_message, source_from_message_to
from job.interviews.serializers import serialize_interview
from job.activity.serializers import serialize_activity
from job.feedback.serializers import serialize_feedback
from job.job_post.serializers import serialize_job_post
def serialize_candidate(row) -> dict:
return {
@ -42,6 +43,7 @@ def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
"job_post_id":str(row.job_post_id) if row.job_post_id else None,
"full_text":row.full_text,
"current_company":row.current_company,
"current_position":row.current_position,
"user_id":str(row.user_id) if row.user_id else None,
"platform":row.platform,
"created_by":str(row.created_by) if row.created_by else None,
@ -78,6 +80,7 @@ def serialize_candidate_profile(
"application_status": message.application_status if message else None,
"experience": message.experience if message else None,
"current_employment": message.current_employment if message else None,
"current_title": message.current_title if message else None,
"resume_text": message.resume_text if message else None,
"suggested_job_post_ids": list(message.suggested_job_post_ids or []) if message else [],
"assigned_job_post_id": str(message.assigned_job_post_id) if message and message.assigned_job_post_id else None,
@ -97,6 +100,7 @@ def serialize_candidate_profile(
"phone": message.candidate_phone_number if message else None,
"education": message.candidate_education if message else None,
"currentCompany": message.current_employment if message else None,
"current_title": message.current_title if message else None,
"stage": message.application_status if message else None,
"source": source_from_message_to(message.message_to if message else None),
"applied": message.message_received_time if message else None,
@ -116,3 +120,64 @@ def serialize_candidate_profile(
"notes": [],
})
return payload
def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
"""Same key vocabulary as serialize_candidate_profile(detail=True).
Manual uploads never go through inbox, so current_position maps onto
current_title here and the agent/ATS fields stay empty.
"""
job_payload = serialize_job_post(job_post) if job_post else None
created = row.created_at.isoformat() if row.created_at else None
file_name = (row.file_name or "").strip() or None
file_path = (row.file_path or "").strip() or None
documents = [{"name": file_name or "", "path": file_path or ""}] if (file_name or file_path) else []
company = (row.current_company or "").strip() or None
position = (row.current_position or "").strip() or None
return {
"inbox_id": None,
"user_id": str(user.id) if user else (str(row.user_id) if row.user_id else None),
"name": (user.name if user else None) or row.candidate_name or None,
"email": (user.email if user else None) or row.candidate_email or None,
"is_active": user.is_active if user else None,
"message_id": None,
"created_at": created,
"application_status": row.status or None,
"experience": (row.experience or "").strip() or None,
"current_employment": company,
"current_title": position,
"resume_text": row.full_text or None,
"suggested_job_post_ids": [],
"assigned_job_post_id": str(row.job_post_id) if row.job_post_id else None,
"match_summary": None,
"match_reasoning": None,
"match_status": None,
"match_error": None,
"matched_at": None,
"job_posts": [job_payload] if job_payload else [],
"favorite": None,
"rating": None,
"phone": (row.candidate_phone or "").strip() or None,
"education": None,
"currentCompany": company,
"stage": row.status or None,
"source": (row.platform or "").strip() or None,
"applied": created,
"documents": documents,
"recruiter": job_payload.get("created_by_name") if job_payload else None,
"recruiter_id": job_payload.get("created_by") if job_payload else None,
"job_title": job_payload.get("title") if job_payload else None,
"ai_score": None,
"recommendation": None,
"sub_scores": None,
"interviews": [],
"activity": [],
"feedback": [],
"notes": [],
"assigned_job_post": job_payload,
"matched_keywords": [],
"missing_keywords": [],
"summary_critique": None,
"scored_at": None,
}

View File

@ -21,13 +21,13 @@ from job.candidate.plugins import (
get_scoring_settings,
normalize_spaced_text,
)
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
from job.candidate.serializers import serialize_manual_upload_candidate
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
from job.notes.serializers import serialize_note
from job.candidate.plugins import extract_candidate_email
from users.models import Users
load_dotenv()
logger=logging.getLogger("job.candidate.views")
@ -467,7 +467,7 @@ class CandidateView:
scores.setdefault(row.inbox_message_id,[]).append(row)
return scores
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None):
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,current_position=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None):
try:
email=(candidate_email or "").strip().lower()
if not email:
@ -480,6 +480,7 @@ class CandidateView:
"candidate_phone":(candidate_phone or "").strip(),
"job_post_id":job_post_id,
"current_company":(current_company or "").strip(),
"current_position":(current_position or "").strip(),
"platform":(platform or "").strip(),
"experience":(experience or "").strip(),
"status":(status or "").strip(),
@ -504,7 +505,19 @@ class CandidateView:
fetch_limit=1000 if detail else limit
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search)
if detail:
return await self.attach_profile_detail(rows)
records=rows if isinstance(rows,list) else ([rows] if rows else [])
if records:
return 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 []
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))
return serialize_manual_candidate_profile(manual,user,job_post)
return await self.attach_job_posts(rows)
except HTTPException:
raise

View File

@ -1,3 +1,4 @@
from optparse import Option
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING,List,Optional
@ -88,7 +89,11 @@ class Users(SQLModel, table=True):
@classmethod
async def get_users(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None
cls, session: AsyncSession,
top: Optional[int]=None,
skip: Optional[int]=None,
search: Optional[str]=None,
role_id:Optional[int]=None
):
statement = (
select(cls)
@ -102,6 +107,8 @@ class Users(SQLModel, table=True):
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
if role_id:
statement = statement.where(cls.role_id == role_id)
result = await session.execute(statement)
return result.scalars().all()

View File

@ -9,6 +9,7 @@ from dotenv import load_dotenv
load_dotenv()
from sqlalchemy.ext.asyncio import AsyncSession
import jwt
from typing import Optional
class User:
@ -60,8 +61,11 @@ class User:
await service.send_confirmation(user)
return user
async def get_users(self,top,skip,search=None):
users=await Users.get_users(self.session,top,skip,search)
async def get_users(self,top:Optional[int]=None,skip:Optional[int]=None,search:Optional[str]=None,role_id:Optional[int]=None):
if role_id:
users=await Users.get_users(self.session,top=top,skip=skip,search=search,role_id=role_id)
else:
users=await Users.get_users(self.session,top=top,skip=skip,search=search)
return [serialize_user(u) for u in users]
async def get_user_by_id(self,record_id):

View File

@ -87,6 +87,64 @@ export function toCandidateView(row) {
}
}
/**
* Candidate USER accounts `users` rows filtered by role, not the scored
* `candidates` table. Needs candidates.view.
*
* role_id 8 is the seeded `candidate` role (backend/role/models.py::EnumRoles);
* the route defaults to it, and we send it explicitly so a re-seed that renumbers
* the roles fails loudly here rather than silently listing the wrong people.
*
* Three things this route does NOT do, all verified against
* backend/job/app.py::fetch_users:
* - it returns `{data, status_code}` with NO `total`, so a caller cannot show a
* row count or drive server-side pagination from the response alone;
* - `top` defaults to 10, so omitting it silently truncates to ten rows;
* - it accepts a `search` query param but never forwards it to the service
* layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op
* server-side. Filtering stays client-side until that is fixed.
*/
export function listCandidateUsers({ roleId = 8, top = 500, skip = 0 } = {}) {
return request('/candidate/fetch/users', {
params: { role_id: roleId, top, skip },
})
}
/**
* `users` row -> the row shape the Candidates table renders.
*
* A user account carries identity only. Everything the ATS produces
* (score, matched skills, critique, the job it was scored against) lives in the
* `candidates` table keyed by inbox_message_id, with no user_id to join on, so
* those fields are null here by construction rather than by omission.
*/
export function toCandidateUserView(row) {
return {
id: row.id,
userId: row.id,
name: row.name || row.email || 'Unknown',
email: row.email ?? null,
isActive: row.is_active ?? null,
roleName: row.role_name ?? null,
applied: row.created_at ? new Date(row.created_at) : null,
// No ATS data on a users row — see the note above.
jobId: null,
filename: null,
source: null,
currentTitle: null,
currentCompany: null,
experience: null,
aiScore: null,
matchedSkills: [],
missingSkills: [],
critique: null,
scoringStatus: null,
errorCode: null,
errorMessage: null,
inboxMessageId: null,
}
}
/**
* Candidate profiles the `inbox -> users -> roles` join, restricted server-side
* to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).
@ -161,7 +219,7 @@ export function update(userId, payload) {
* the recruiter typed often someone with no account here.
*/
export function createManual({
file, name, email, phone, jobPostId, company, source, experience, stage, referralBy,
file, name, email, phone, jobPostId, company, currentPosition, source, experience, stage, referralBy,
}) {
const form = new FormData()
form.append('file', file)
@ -176,6 +234,7 @@ export function createManual({
put('candidate_phone', phone)
put('job_post_id', jobPostId)
put('current_company', company)
put('current_position', currentPosition)
put('platform', source)
put('experience', experience)
put('status', stage)

View File

@ -14,7 +14,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Pagination, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip } from '../ui/primitives'
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import CandidateProfile from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
@ -23,16 +23,28 @@ import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import { useFormState } from '../components/AuthLayout'
import { persist } from '../data/seedQueries'
import { atsRecommendationClass, avatarColor, initials as initialsOf, sources, stages } from '../data/seed'
import { avatarColor, initials as initialsOf, sources, stages } from '../data/seed'
const ATS_BANDS = ['85+', '70-84', '<70']
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
const EMPTY_FILTERS = { job: '', skill: '', source: '', ats: '', status: '' }
const EMPTY_FILTERS = { account: '' }
/** The seeded `candidate` role (backend/role/models.py::EnumRoles). */
const CANDIDATE_ROLE_ID = 8
/* Rows are candidate USER accounts (GET /candidate/fetch/users?role_id=8), not
rows of the scored `candidates` table.
Why: /candidate/scored/fetch only ever returns CVs that have been through the
ATS, so the pool was empty for every candidate who has an account but no score
yet. The user list is the real population; the score is an attribute some of
them have.
The consequence is that the ATS columns have no source on this screen see
toCandidateUserView. Open a candidate to get their score, which
ScoredCandidateProfile still reads from the scored endpoint. */
async function fetchCandidates() {
const res = await candidatesApi.listCandidates()
const res = await candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(candidatesApi.toCandidateView)
return rows.map(candidatesApi.toCandidateUserView)
}
async function fetchJobs() {
@ -97,7 +109,7 @@ export default function Candidates() {
const [q, setQ] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('relevance')
const [sortMode, setSortMode] = useState('recent')
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
@ -107,15 +119,8 @@ export default function Candidates() {
[jobsById],
)
/** ATS score + matched-skill ratio + recency same shape as before, but every
input is now real: matched/missing come from the model, applied from the DB. */
const relevance = useCallback((c) => {
if (c.aiScore == null) return 0
const total = c.matchedSkills.length + c.missingSkills.length
const skillRatio = total ? c.matchedSkills.length / total : 0.5
const recency = c.applied ? 1 - Math.min(1, (Date.now() - c.applied) / (90 * 864e5)) : 0.5
return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10)
}, [])
/* The relevance blend (score + matched-skill ratio + recency) went with the
scoring columns none of its three inputs exists on a users row. */
const openProfile = useCallback(
(c) => {
@ -140,58 +145,43 @@ export default function Candidates() {
}
}, [location.state, candidates, openProfile])
const skillOptions = useMemo(() => {
const set = new Set()
for (const c of candidates) for (const s of c.matchedSkills) set.add(s)
return [...set].sort((a, b) => a.localeCompare(b)).slice(0, 40)
}, [candidates])
const jobOptions = useMemo(
() => (jobsQuery.data ?? []).map((j) => j.title),
[jobsQuery.data],
)
const rows = useMemo(() => {
const f = filters
let list = candidates.filter((c) => {
if (f.job && jobTitleOf(c) !== f.job) return false
if (f.skill && !c.matchedSkills.includes(f.skill)) return false
if (f.source && c.source !== f.source) return false
if (f.status === 'Scored' && c.scoringStatus !== 'completed') return false
if (f.status === 'Failed' && c.scoringStatus !== 'failed') return false
if (f.ats === '85+' && (c.aiScore == null || c.aiScore < 85)) return false
if (f.ats === '70-84' && (c.aiScore == null || c.aiScore < 70 || c.aiScore > 84)) return false
if (f.ats === '<70' && (c.aiScore == null || c.aiScore >= 70)) return false
if (f.account === 'Active' && !c.isActive) return false
if (f.account === 'Unconfirmed' && c.isActive) return false
if (q) {
// Client-side: the route accepts `search` but never forwards it to the
// service layer, so asking the server to filter would be a silent no-op.
const term = q.toLowerCase()
const hay = [
c.name, c.filename, c.currentTitle ?? '', c.currentCompany ?? '',
c.matchedSkills.join(' '),
c.name, c.email ?? '', c.filename ?? '', c.currentTitle ?? '',
c.currentCompany ?? '', c.matchedSkills.join(' '),
].join(' ').toLowerCase()
if (!hay.includes(term)) return false
}
return true
})
if (sortMode === 'relevance') list = [...list].sort((a, b) => relevance(b) - relevance(a))
else if (sortMode === 'ats') list = [...list].sort((a, b) => (b.aiScore ?? -1) - (a.aiScore ?? -1))
else if (sortMode === 'recent') list = [...list].sort((a, b) => (b.applied ?? 0) - (a.applied ?? 0))
if (sortMode === 'recent') list = [...list].sort((a, b) => (b.applied ?? 0) - (a.applied ?? 0))
else if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
return list
}, [candidates, filters, q, sortMode, relevance, jobTitleOf])
}, [candidates, filters, q, sortMode])
/* Columns follow the row source. A `users` row carries identity only, so the
four scoring columns (Scored For / Exp / Relevance / ATS) have nothing to
read and are gone rather than rendered as permanent em-dashes the same
rule the Inbox screen set and this file's header states. They come back the
moment the rows carry a score again. */
const columns = useMemo(
() => [
{ key: 'name', label: 'Candidate', sortable: true },
{ key: '_job', label: 'Scored For', sortable: true, sortValue: jobTitleOf },
{ key: 'experience', label: 'Exp', sortable: true, align: 'center' },
{ key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: relevance },
{ key: 'scoringStatus', label: 'Status', sortable: true },
{ key: 'aiScore', label: 'ATS', sortable: true, align: 'center' },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'isActive', label: 'Account', sortable: true },
{ key: 'applied', label: 'Added', sortable: true },
{ key: '_a', label: 'Actions', align: 'right' },
],
[relevance, jobTitleOf],
[],
)
const t = useDataTable({ columns, rows, pageSize: 10 })
@ -217,7 +207,7 @@ export default function Candidates() {
<div>
<h1 className="page-title">Candidates</h1>
<p className="page-sub">
{rows.length} candidate{rows.length === 1 ? '' : 's'} · ranked by AI relevance
{rows.length} candidate account{rows.length === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}
</p>
</div>
<div className="page-head-actions">
@ -257,8 +247,6 @@ export default function Candidates() {
<div className="spacer" />
<label className="text-muted text-sm">Sort:</label>
<select className="select" value={sortMode} onChange={(e) => setSortMode(e.target.value)}>
<option value="relevance">AI Relevance</option>
<option value="ats">ATS Score</option>
<option value="recent">Most Recent</option>
<option value="name">Name AZ</option>
</select>
@ -269,11 +257,10 @@ export default function Candidates() {
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobOptions} />
<Facet label="Matched Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillOptions} />
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={['upload', 'inbox']} labels={SOURCE_LABEL} />
<Facet label="ATS Score" value={filters.ats} onChange={(v) => setFilter('ats', v)} any="Any Score" options={ATS_BANDS} />
<Facet label="Status" value={filters.status} onChange={(v) => setFilter('status', v)} any="Any Status" options={['Scored', 'Failed']} />
{/* Job / Matched Skill / Source / ATS Score / scoring Status are gone
with the scoring columns: on a users row every one of them would
match nothing and silently empty the table. */}
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} />
</div>
)}
</div>
@ -336,38 +323,17 @@ export default function Candidates() {
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} />
<div>
<div className="cell-primary">{c.name}</div>
<div className="cell-sub">
{c.currentTitle ?? c.filename}
{c.currentCompany ? ` · ${c.currentCompany}` : ''}
</div>
<div className="cell-sub">{c.roleName ?? '—'}</div>
</div>
</div>
</td>
<td>
<div className="text-sm">{jobTitleOf(c)}</div>
<div className="cell-sub">{SOURCE_LABEL[c.source] ?? c.source}</div>
</td>
<td style={{ textAlign: 'center' }}>
{c.experience != null ? <><b>{c.experience}</b>y</> : '—'}
</td>
<td style={{ textAlign: 'center' }}>
{c.scoringStatus === 'completed' ? (
<span className={`badge ${atsRecommendationClass(recommendationOf(c))} badge-plain`}>
{relevance(c)}%
</span>
) : '—'}
<span className="text-sm">{c.email ?? '—'}</span>
</td>
<td>
{c.scoringStatus === 'completed'
? <Badge className="b-green">Scored</Badge>
: <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>}
</td>
<td style={{ textAlign: 'center' }}>
{c.aiScore != null ? (
<span style={{ cursor: 'pointer' }} onClick={() => openAts(c)}>
<ScoreChip score={c.aiScore} />
</span>
) : '—'}
{c.isActive
? <Badge className="b-green">Active</Badge>
: <Badge className="b-amber">Unconfirmed</Badge>}
</td>
<td>
<span className="text-sm">
@ -376,7 +342,6 @@ export default function Candidates() {
</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className="act-btn" data-tip="ATS Match" onClick={() => openAts(c)}><Icon name="target" /></button>
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
</div>
</td>
@ -546,7 +511,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
const form = useFormState({
name: '', email: '', phone: '', job: '',
experience: '3', company: '', source: sources[0], stage: stages[0],
experience: '3', company: '', position: '', source: sources[0], stage: stages[0],
referral: '',
})
@ -608,6 +573,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
phone: v.phone,
jobPostId,
company: v.company,
currentPosition: v.position,
source: v.source,
experience: v.experience,
stage: v.stage,
@ -664,6 +630,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
</div>
<div className="form-field"><label>Experience (years)</label><input type="number" {...field('experience')} /></div>
<div className="form-field"><label>Current Company</label><input {...field('company')} placeholder="Acme Inc." /></div>
<div className="form-field"><label>Current Position</label><input {...field('position')} placeholder="Senior Merchandiser" /></div>
<div className="form-field">
<label>Source</label>
<select {...field('source')}>{sources.map((s) => <option key={s}>{s}</option>)}</select>

View File

@ -1,27 +1,125 @@
/* The profile modal for SCORED candidates (rows from /candidate/scored/fetch),
used by Candidates.jsx. Distinct from CandidateProfile.jsx, which renders
inbox-derived candidate profiles (userId, interviews, notes, feedback) for
TalentPool. The two data shapes share almost no fields, hence two modals. */
/* The profile modal for candidate rows on /candidates (identity from
/candidate/fetch/users, detail from GET /candidate/fetch?user_id=). Distinct
from CandidateProfile.jsx, which renders the 8-tab TalentPool modal. */
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
const TABS = ['Overview', 'Scoring', 'File']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
/** Agent sentinels arrive as literal strings, not null — strip before display. */
const AGENT_SENTINELS = new Set([
'no company was mentioned',
'no education mentioned',
'no education mentioned.',
'no job position mentioned',
])
function stripSentinel(value) {
if (value == null) return null
const text = String(value).trim()
if (!text) return null
return AGENT_SENTINELS.has(text.toLowerCase()) ? null : text
}
/** Append a unit only for bare numeric counts ("5", "5+", "3.5"); leave "5+ years" alone. */
function formatExperience(value, unit) {
if (value == null || value === '') return null
const text = String(value).trim()
if (!text) return null
if (/^\d+(\.\d+)?\+?$/.test(text)) return `${text} ${unit}`
return text
}
function useCandidateDetail(userId) {
return useQuery({
queryKey: qk.candidates.detail(userId),
queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null,
enabled: Boolean(userId),
})
}
export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) {
const [tab, setTab] = useState('Overview')
const scored = c.scoringStatus === 'completed'
const isLive = Boolean(c.userId)
const detail = useCandidateDetail(c.userId)
const live = detail.data ?? null
const view = useMemo(() => {
const currentTitle = stripSentinel(live?.current_title) ?? c.currentTitle ?? null
const currentCompany =
stripSentinel(live?.currentCompany ?? live?.current_employment) ?? c.currentCompany ?? null
const experience = live?.experience ?? c.experience ?? null
const source = live?.source ?? c.source ?? null
const filename =
live?.documents?.[0]?.name || c.filename || null
const matchSummary = live?.match_summary ?? null
const messageId = live?.message_id ?? c.inboxMessageId ?? null
const aiScore = live?.ai_score ?? c.aiScore ?? null
const matchedSkills = live?.matched_keywords ?? c.matchedSkills ?? []
const missingSkills = live?.missing_keywords ?? c.missingSkills ?? []
const critique = live?.summary_critique ?? c.critique ?? null
const errorCode = live?.error_code ?? c.errorCode ?? null
const errorMessage = live?.error_message ?? live?.match_error ?? c.errorMessage ?? null
const scoredFor = live?.job_title ?? jobTitle ?? null
const scored = live
? Boolean(live.scored_at || live.ai_score != null)
: c.scoringStatus === 'completed'
return {
name: c.name,
email: c.email,
applied: c.applied,
currentTitle,
currentCompany,
experience,
source,
filename,
matchSummary,
messageId,
aiScore,
matchedSkills: Array.isArray(matchedSkills) ? matchedSkills : [],
missingSkills: Array.isArray(missingSkills) ? missingSkills : [],
critique,
errorCode,
errorMessage,
scoredFor,
scored,
roleLine: [currentTitle, currentCompany].filter(Boolean).join(' at ') || '—',
experienceBadge: formatExperience(experience, 'yrs exp'),
experienceOverview: formatExperience(experience, 'years'),
sourceLabel: SOURCE_LABEL[source] ?? source ?? '—',
subtitle: filename || c.email || null,
}
}, [c, live, jobTitle])
// enabled:false stays pending forever in TanStack v5 short-circuit when no userId.
const guard = !isLive ? null
: detail.isPending ? (
<EmptyState icon="refresh" title="Loading candidate…">Fetching the full record.</EmptyState>
) : detail.isError ? (
<EmptyState icon="alert" title="Could not load this candidate">
{friendlyAuthError(detail.error, 'Please try again.')}
</EmptyState>
) : !live ? (
<EmptyState icon="user" title="No application on file">
This candidate has no inbox application or manual upload on record yet.
</EmptyState>
) : null
return (
<Modal
title="Candidate Profile"
subtitle={c.filename}
subtitle={view.subtitle}
size="modal-lg"
onClose={onClose}
footer={
@ -34,23 +132,21 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
}
>
<div className="profile-hero">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<Avatar name={view.name} initials={initialsOf(view.name)} color={avatarColor(view.name)} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{c.name}</div>
<div className="ph-role">
{c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''}
</div>
<div className="ph-name">{view.name}</div>
<div className="ph-role">{view.roleLine}</div>
<div className="ph-tags">
{c.scoringStatus && (scored ? <Badge className="b-green">Scored</Badge> : <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>)}
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
{c.experience != null && (
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
{view.scored && <Badge className="b-green">Scored</Badge>}
{view.source && <Badge className="b-gray">{view.sourceLabel}</Badge>}
{view.experienceBadge && (
<span className="badge b-plain b-indigo badge-plain">{view.experienceBadge}</span>
)}
</div>
</div>
{c.aiScore != null && (
{view.aiScore != null && (
<div style={{ textAlign: 'center' }}>
<ScoreChip score={c.aiScore} />
<ScoreChip score={view.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
)}
@ -61,77 +157,77 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
</div>
<div className="tab-pane active">
{tab === 'Overview' && (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Scored For</div><div className="iv">{jobTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany ?? '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience != null ? `${c.experience} years` : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
<div className="info-item"><div className="il">Added On</div><div className="iv">{c.applied ? fmtDate(c.applied) : '—'}</div></div>
</div>
{scored && (
{guard ?? (<>
{tab === 'Overview' && (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Scored For</div><div className="iv">{view.scoredFor ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Title</div><div className="iv">{view.currentTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{view.currentCompany ?? '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{view.experienceOverview ?? '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
<div className="info-item"><div className="il">Added On</div><div className="iv">{view.applied ? fmtDate(view.applied) : '—'}</div></div>
</div>
{view.scored && (
<>
<div style={LABEL}>Matched Skills</div>
<div className="k-tags">
{view.matchedSkills.length
? view.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)
: <span className="text-muted"></span>}
</div>
</>
)}
</>
)}
{tab === 'Scoring' && (
view.scored ? (
<>
<div style={LABEL}>Matched Skills</div>
<div className="k-tags">
{c.matchedSkills.length
? c.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
<p className="text-muted" style={{ marginBottom: 18 }}>{view.critique ?? '—'}</p>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({view.matchedSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{view.matchedSkills.length
? view.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Missing Skills ({view.missingSkills.length})
</div>
<div className="k-tags">
{view.missingSkills.length
? view.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
</>
)}
</>
)}
) : (
<EmptyState icon="target" title="Not scored yet">
This candidate has not been scored against a job post.
</EmptyState>
)
)}
{tab === 'Scoring' && (
scored ? (
<>
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
<p className="text-muted" style={{ marginBottom: 18 }}>{c.critique ?? '—'}</p>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({c.matchedSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{c.matchedSkills.length
? c.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Missing Skills ({c.missingSkills.length})
</div>
<div className="k-tags">
{c.missingSkills.length
? c.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
</>
) : (
<EmptyState icon="target" title="Not scored">
{c.errorMessage ?? 'This CV could not be processed.'}
</EmptyState>
)
)}
{tab === 'File' && (
<div className="info-grid">
<div className="info-item"><div className="il">File Name</div><div className="iv">{c.filename}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
{c.inboxMessageId && (
<div className="info-item"><div className="il">Inbox Message</div><div className="iv">{c.inboxMessageId}</div></div>
)}
{!scored && (
<>
<div className="info-item"><div className="il">Error</div><div className="iv">{c.errorCode ?? '—'}</div></div>
<div className="info-item"><div className="il">Detail</div><div className="iv">{c.errorMessage ?? '—'}</div></div>
</>
)}
</div>
)}
{tab === 'File' && (
<div className="info-grid">
<div className="info-item"><div className="il">File Name</div><div className="iv">{view.filename ?? '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
{view.messageId && (
<div className="info-item"><div className="il">Inbox Message</div><div className="iv">{view.messageId}</div></div>
)}
<div className="info-item"><div className="il">Detail</div><div className="iv">{view.matchSummary ?? '—'}</div></div>
{view.errorCode && (
<div className="info-item"><div className="il">Error</div><div className="iv">{view.errorCode}</div></div>
)}
</div>
)}
</>)}
</div>
</Modal>
)