added finishe /candidate

pull/13/head
ahmed.mujtaba 2026-08-12 17:49:21 +05:00
parent 7239b2ccad
commit a49022b757
13 changed files with 167 additions and 100 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
@ -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(

View File

@ -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,

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).

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>