From a49022b7575a4dff5365ed304e7d547708c6888e Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 12 Aug 2026 17:49:21 +0500 Subject: [PATCH 1/2] added finishe /candidate --- .gitignore | 1 + backend/employment_agent/decorators.py | 21 ++-- backend/employment_agent/execute_agent.py | 4 +- backend/employment_agent/prompt.py | 8 +- backend/inbox/models.py | 4 + backend/inbox/serializers.py | 1 + backend/inbox/tasks.py | 4 +- backend/job/app.py | 16 +++ backend/job/candidate/serializers.py | 2 + backend/users/models.py | 9 +- backend/users/views.py | 8 +- frontend/src/api/candidates.js | 58 ++++++++++ frontend/src/screens/Candidates.jsx | 131 ++++++++-------------- 13 files changed, 167 insertions(+), 100 deletions(-) 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() {

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}

@@ -257,8 +247,6 @@ export default function Candidates() {
@@ -269,11 +257,10 @@ export default function Candidates() { className="filter-panel" style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }} > - setFilter('job', v)} any="Any Job" options={jobOptions} /> - setFilter('skill', v)} any="Any Skill" options={skillOptions} /> - setFilter('source', v)} any="Any Source" options={['upload', 'inbox']} labels={SOURCE_LABEL} /> - setFilter('ats', v)} any="Any Score" options={ATS_BANDS} /> - 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. */} + setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} />
)}
@@ -336,38 +323,17 @@ export default function Candidates() {
{c.name}
-
- {c.currentTitle ?? c.filename} - {c.currentCompany ? ` · ${c.currentCompany}` : ''} -
+
{c.roleName ?? '—'}
-
{jobTitleOf(c)}
-
{SOURCE_LABEL[c.source] ?? c.source}
- - - {c.experience != null ? <>{c.experience}y : '—'} - - - {c.scoringStatus === 'completed' ? ( - - {relevance(c)}% - - ) : '—'} + {c.email ?? '—'} - {c.scoringStatus === 'completed' - ? Scored - : {c.errorCode ?? 'Failed'}} - - - {c.aiScore != null ? ( - openAts(c)}> - - - ) : '—'} + {c.isActive + ? Active + : Unconfirmed} @@ -376,7 +342,6 @@ export default function Candidates() {
-
-- 2.40.1 From 4d56f987f78db7e447078eaff56631823f2ebcc4 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 12 Aug 2026 18:11:42 +0500 Subject: [PATCH 2/2] /candidate page is done and completedc --- backend/job/app.py | 2 + backend/job/candidate/models.py | 15 + backend/job/candidate/serializers.py | 63 +++++ backend/job/candidate/views.py | 21 +- frontend/src/api/candidates.js | 3 +- frontend/src/screens/Candidates.jsx | 4 +- .../src/screens/ScoredCandidateProfile.jsx | 264 ++++++++++++------ 7 files changed, 282 insertions(+), 90 deletions(-) diff --git a/backend/job/app.py b/backend/job/app.py index e860f82..b708d6f 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -135,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), @@ -159,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, diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 2fffa91..72b84bc 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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. diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index c1e15fa..f5b2e07 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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, @@ -118,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, + } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 7d36fb5..62d3167 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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 diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index fe4944e..8741241 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -219,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) @@ -234,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) diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 35a335e..6144436 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -511,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: '', }) @@ -573,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, @@ -629,6 +630,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
+
diff --git a/frontend/src/screens/ScoredCandidateProfile.jsx b/frontend/src/screens/ScoredCandidateProfile.jsx index 61182ff..70889a7 100644 --- a/frontend/src/screens/ScoredCandidateProfile.jsx +++ b/frontend/src/screens/ScoredCandidateProfile.jsx @@ -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 ? ( + Fetching the full record. + ) : detail.isError ? ( + + {friendlyAuthError(detail.error, 'Please try again.')} + + ) : !live ? ( + + This candidate has no inbox application or manual upload on record yet. + + ) : null return (
- +
-
{c.name}
-
- {c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''} -
+
{view.name}
+
{view.roleLine}
- {c.scoringStatus && (scored ? Scored : {c.errorCode ?? 'Failed'})} - {SOURCE_LABEL[c.source] ?? c.source} - {c.experience != null && ( - {c.experience} yrs exp + {view.scored && Scored} + {view.source && {view.sourceLabel}} + {view.experienceBadge && ( + {view.experienceBadge} )}
- {c.aiScore != null && ( + {view.aiScore != null && (
- +
AI Match
)} @@ -61,77 +157,77 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
- {tab === 'Overview' && ( - <> -
-
Scored For
{jobTitle ?? '—'}
-
Current Title
{c.currentTitle ?? '—'}
-
Current Company
{c.currentCompany ?? '—'}
-
Experience
{c.experience != null ? `${c.experience} years` : '—'}
-
Source
{SOURCE_LABEL[c.source] ?? c.source}
-
Added On
{c.applied ? fmtDate(c.applied) : '—'}
-
- {scored && ( + {guard ?? (<> + {tab === 'Overview' && ( + <> +
+
Scored For
{view.scoredFor ?? '—'}
+
Current Title
{view.currentTitle ?? '—'}
+
Current Company
{view.currentCompany ?? '—'}
+
Experience
{view.experienceOverview ?? '—'}
+
Source
{view.sourceLabel}
+
Added On
{view.applied ? fmtDate(view.applied) : '—'}
+
+ {view.scored && ( + <> +
Matched Skills
+
+ {view.matchedSkills.length + ? view.matchedSkills.map((s) => {s}) + : } +
+ + )} + + )} + + {tab === 'Scoring' && ( + view.scored ? ( <> -
Matched Skills
-
- {c.matchedSkills.length - ? c.matchedSkills.map((s) => {s}) +
AI Assessment
+

{view.critique ?? '—'}

+
+ Matched Skills ({view.matchedSkills.length}) +
+
+ {view.matchedSkills.length + ? view.matchedSkills.map((s) => ( + {s} + )) : }
+
+ Missing Skills ({view.missingSkills.length}) +
+
+ {view.missingSkills.length + ? view.missingSkills.map((s) => ( + {s} + )) + : None — full match} +
- )} - - )} + ) : ( + + This candidate has not been scored against a job post. + + ) + )} - {tab === 'Scoring' && ( - scored ? ( - <> -
AI Assessment
-

{c.critique ?? '—'}

-
- Matched Skills ({c.matchedSkills.length}) -
-
- {c.matchedSkills.length - ? c.matchedSkills.map((s) => ( - {s} - )) - : } -
-
- Missing Skills ({c.missingSkills.length}) -
-
- {c.missingSkills.length - ? c.missingSkills.map((s) => ( - {s} - )) - : None — full match} -
- - ) : ( - - {c.errorMessage ?? 'This CV could not be processed.'} - - ) - )} - - {tab === 'File' && ( -
-
File Name
{c.filename}
-
Source
{SOURCE_LABEL[c.source] ?? c.source}
- {c.inboxMessageId && ( -
Inbox Message
{c.inboxMessageId}
- )} - {!scored && ( - <> -
Error
{c.errorCode ?? '—'}
-
Detail
{c.errorMessage ?? '—'}
- - )} -
- )} + {tab === 'File' && ( +
+
File Name
{view.filename ?? '—'}
+
Source
{view.sourceLabel}
+ {view.messageId && ( +
Inbox Message
{view.messageId}
+ )} +
Detail
{view.matchSummary ?? '—'}
+ {view.errorCode && ( +
Error
{view.errorCode}
+ )} +
+ )} + )}
) -- 2.40.1