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}
+ )} +
+ )} + )}
)