diff --git a/.gitignore b/.gitignore index af3e05c..7d206f3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ Icon ? ._* dist/** +dist/**/* # Editor / IDE .idea/ .vscode/ diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py index b2bf69e..7720454 100644 --- a/backend/employment_agent/decorators.py +++ b/backend/employment_agent/decorators.py @@ -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() diff --git a/backend/employment_agent/execute_agent.py b/backend/employment_agent/execute_agent.py index 7f74380..8c1252d 100644 --- a/backend/employment_agent/execute_agent.py +++ b/backend/employment_agent/execute_agent.py @@ -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) diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py index d3175b5..3951836 100644 --- a/backend/employment_agent/prompt.py +++ b/backend/employment_agent/prompt.py @@ -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" }} """ diff --git a/backend/inbox/models.py b/backend/inbox/models.py index d04c1f6..3b32dca 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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 diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 8310bab..5b819dd 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -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, diff --git a/backend/inbox/tasks.py b/backend/inbox/tasks.py index 04b4480..0ce2f7e 100644 --- a/backend/inbox/tasks.py +++ b/backend/inbox/tasks.py @@ -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, } diff --git a/backend/job/app.py b/backend/job/app.py index a1b23c8..e860f82 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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 @@ -177,6 +178,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( diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index da0651a..c1e15fa 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -78,6 +78,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 +98,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, diff --git a/backend/users/models.py b/backend/users/models.py index e6cb580..0ecae32 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -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() diff --git a/backend/users/views.py b/backend/users/views.py index bce5072..4ce0f0c 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -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): diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 26183ab..fe4944e 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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). diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 2e18ea3..35a335e 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -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() {
- {rows.length} candidate{rows.length === 1 ? '' : 's'} · ranked by AI relevance + {rows.length} candidate account{rows.length === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}