HR-ATS-Portal/backend/job/candidate/views.py

863 lines
39 KiB
Python

from sqlalchemy.ext.asyncio import AsyncSession
import asyncio,base64,dataclasses,hashlib,io,logging,os,uuid
from datetime import datetime,timezone
from pathlib import Path
from dotenv import load_dotenv
from fastapi import HTTPException
from pypdf import PdfReader
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlmodel import true
from app.core.errors import ATSError,ErrorCode
from app.models.scoring import CompletedCandidate
from app.services.pdf import extract_resume,sanitize_filename
from app.services.scoring import score_batch
from inbox.models import Inbox_Messages,Inbox,AtsResults
from job.candidate.models import Candidates
from job.candidate.plugins import (
FILE_NOT_FOUND,
build_job_description,
get_scorer,
get_scoring_settings,
normalize_spaced_text,
)
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.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")
CV_QUEUE_NAME=os.getenv("TASKIQ_CV_QUEUE_NAME","cv_upload")
MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
"MANUAL_UPLOAD_TO_ADDRESS","manual-cv-upload@hr-ats.local"
)
class FileRead:
def __init__(self,session:AsyncSession,filename=None,file=None):
self.session=session
self.filename=filename
self.file=file
async def read_file(self,file=None,filename=None):
try:
reader = PdfReader(io.BytesIO(self.file))
if reader.is_encrypted:
raise HTTPException(400, "PDF is password protected")
pages = [(page.extract_text() or "") for page in reader.pages]
return {
"filename": self.filename,
"num_pages": len(reader.pages),
"text": normalize_spaced_text("\n".join(pages)),
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, str(e))
async def injest_manual_upload(self):
try:
parsed=await self.read_file()
text=(parsed.get("text") or "").strip()
if not text:
raise HTTPException(status_code=400,detail="No usable text could be extracted from the PDF")
return parsed
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400,detail=str(e))
async def save_manual_upload(self):
"""Write the uploaded CV under inbox/decoded_attachments.
Returns ``{"file_name", "file_path"}``: the recruiter-facing original
name, and the absolute path actually written.
Those two differ deliberately. decode_attachment writes ``Path(name).name``
with plain ``write_bytes`` — no collision handling — so two candidates
uploading "resume.pdf" would silently clobber each other and the first
row's file_path would then serve the second candidate's CV. Prefixing the
stored basename with a uuid makes every upload its own file, while
file_name keeps what the recruiter recognises. resolve_attachment_path
handles the result either way: the stored absolute path wins, and its
basename-under-attachments fallback still finds the prefixed name.
"""
from inbox.file_decoder import AttachmentDecodeError,decode_attachment
# Separators normalized before taking the basename: a Windows client can
# send "C:\Users\x\cv.pdf", whose Path(...).name on Linux is the whole
# string. Same reasoning as inbox.plugins.resolve_attachment_path.
original=Path((self.filename or "resume.pdf").replace("\\","/")).name or "resume.pdf"
stored=f"{uuid.uuid4().hex}-{original}"
try:
paths=await decode_attachment([{
"name":stored,
"contentBytes":base64.b64encode(self.file).decode("ascii"),
}])
except AttachmentDecodeError as e:
raise HTTPException(status_code=400,detail=str(e))
if not paths:
# decode_attachment skips rather than raises on an unsupported
# extension, so an empty list is the only signal that nothing landed.
raise HTTPException(status_code=400,detail="attachment could not be saved")
return {"file_name":original,"file_path":paths[0]}
@staticmethod
def discard_upload(file_path):
"""Best-effort removal of a saved CV whose row never got created.
Called on the failure path so a rejected request (a missing email, a DB
error) does not leave an orphan PDF behind. Failure to delete is logged
and swallowed — it must never mask the error that got us here.
"""
if not file_path:
return
try:
Path(file_path).unlink(missing_ok=True)
except OSError as e:
logger.warning("could not remove orphaned upload %s: %s",file_path,e)
async def ingest_upload(self,candidate_email=None,candidate_name=None):
"""Persist a recruiter-uploaded CV with full email-ingestion parity."""
from inbox.file_decoder import AttachmentDecodeError,decode_attachment
from inbox.cv_tasks import match_uploaded_cv
from inbox.views import Email
parsed=await self.read_file()
text=parsed.get("text") or ""
detected,emails_found=extract_candidate_email(text)
supplied=(candidate_email or "").strip().lower() or None
email=supplied or detected
email_source="recruiter" if supplied else ("cv" if detected else None)
if not email:
raise HTTPException(
status_code=422,
detail={
"error_code":"CANDIDATE_EMAIL_REQUIRED",
"filename":parsed.get("filename"),
"num_pages":parsed.get("num_pages"),
"emails_found":emails_found,
"text":text,
},
)
filename=self.filename or "resume.pdf"
try:
paths=await decode_attachment([{
"name":filename,
"contentBytes":base64.b64encode(self.file).decode("ascii"),
}])
except AttachmentDecodeError as e:
raise HTTPException(status_code=400,detail=str(e))
if not paths:
raise HTTPException(status_code=400,detail="attachment could not be saved")
now=datetime.now(timezone.utc).isoformat()
email_data={
"id":f"manual-cv:{uuid.uuid4()}",
"subject":f"Manual CV upload — {filename}",
"body":{"content":"","contentType":"text"},
"hasAttachments":True,
"attachments":[{"name":filename}],
"from":{"emailAddress":{"address":email,"name":(candidate_name or "").strip()}},
"toRecipients":[{"emailAddress":{"address":MANUAL_UPLOAD_TO_ADDRESS}}],
"ccRecipients":[],
"bccRecipients":[],
"replyTo":[],
"isRead":False,
"sentDateTime":now,
"receivedDateTime":now,
}
row,new_user_email=await Inbox_Messages.insert_email(
self.session,email_data,file_path=paths,
)
created_at=datetime.now(timezone.utc).isoformat()
task=await match_uploaded_cv.kicker().with_labels(
created_at=created_at,
correlation_id=str(row.id),
queue=CV_QUEUE_NAME,
).kiq(str(row.id),force=False)
account_setup=None
if new_user_email:
try:
account_setup=await Email(session=self.session).send_account_setup(
[new_user_email]
)
except Exception as e:
logger.warning("account setup mail failed for %s: %s",new_user_email,e)
account_setup=[{"email":new_user_email,"sent":False}]
return {
"queued":True,
"inbox_message_id":str(row.id),
"task_id":task.task_id,
"filename":parsed.get("filename"),
"num_pages":parsed.get("num_pages"),
"candidate_email":email,
"email_source":email_source,
"account_setup":account_setup,
"text":text,
}
async def match_inbox_cv(self,inbox_message_id):
from inbox.plugins import resolve_attachment_path
from inbox.tasks import match_inbox_message
row=await Inbox_Messages.get_inbox_message_by_id(self.session,inbox_message_id)
if not row:
raise HTTPException(status_code=404,detail="Message not found")
if not row.attachment or not row.file_path:
raise HTTPException(status_code=400,detail="your file isnt in the system")
found=None
for path_str in (p.strip() for p in row.file_path.split(",") if p.strip()):
path=resolve_attachment_path(path_str)
if path.is_file():
found=path
break
if found is None:
raise HTTPException(status_code=400,detail="your file isnt in the system")
created_at=datetime.now(timezone.utc).isoformat()
task=await match_inbox_message.kicker().with_labels(
created_at=created_at,
correlation_id=str(row.id),
queue="inbox",
).kiq(str(row.id),force=True)
file_name=(row.file_name or "").split(",")[0].strip() or found.name
return {
"queued":True,
"inbox_message_id":str(row.id),
"file_name":file_name,
"task_id":task.task_id,
}
# async def get_intention(self,input):
# try:
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)
# get_file=
class CandidateScoring:
"""ATS scoring of CVs against one job post, persisted to the candidates table.
Complements the agent's inbox-match flow: the agent suggests WHICH job a CV is
for; this service scores HOW WELL a CV fits a chosen job (0-100 leaderboard).
Deviation from the bulk-ats HTTP API (which rejects a whole batch with 415/413
on a bad file): here per-file problems become persisted rows with
status="failed" so one broken attachment never sinks the rest of the batch.
Request-level errors (unknown job, too many files) still raise.
"""
def __init__(self,session:AsyncSession):
self.session=session
async def score_uploads(self,job_id,files,current_user):
"""files: list of (filename, bytes) pairs from the route handler."""
settings=get_scoring_settings()
if len(files)>settings.max_resumes_per_request:
raise HTTPException(
status_code=413,
detail=f"At most {settings.max_resumes_per_request} resumes per request",
)
sources=[]
for filename,data in files:
source={
"filename":filename or "resume.pdf",
"data":data,
"file_path":None,
"inbox_message_id":None,
"precheck":None,
}
if not (filename or "").lower().endswith(".pdf"):
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"Only PDF resumes are supported.")
elif len(data)>settings.max_pdf_size_bytes:
source["precheck"]=(ErrorCode.PAYLOAD_TOO_LARGE,"The file exceeds the size limit.")
sources.append(source)
return await self._score_and_persist(job_id,sources,"upload",current_user)
async def score_inbox(self,job_id,message_ids,current_user):
"""Score the decoded attachments of inbox messages (PK uuids, not Graph ids)."""
# Local import: inbox.plugins imports this module (FileRead), so a top-level
# import would be circular — same pattern as match_inbox_cv above.
from inbox.plugins import resolve_attachment_path
sources=[]
for mid in message_ids:
row=await Inbox_Messages.get_inbox_message_by_id(self.session,mid)
if row is None:
raise HTTPException(status_code=404,detail=f"Inbox message {mid} not found")
if not row.file_path:
continue
for path_str in (p.strip() for p in row.file_path.split(",") if p.strip()):
path=resolve_attachment_path(path_str)
source={
"filename":path.name,
"data":None,
"file_path":str(path),
"inbox_message_id":row.id,
"precheck":None,
}
suffix=path.suffix.lower()
if suffix in (".doc",".docx"):
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"DOC/DOCX extraction is not supported yet.")
elif suffix!=".pdf":
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"Only PDF resumes are supported.")
elif not path.is_file():
source["precheck"]=(FILE_NOT_FOUND,"The decoded attachment is missing on disk.")
else:
try:
source["data"]=await asyncio.to_thread(path.read_bytes)
except OSError:
source["precheck"]=(FILE_NOT_FOUND,"The decoded attachment could not be read.")
sources.append(source)
if not sources:
raise HTTPException(status_code=400,detail="No attachments found for the given message(s)")
return await self._score_and_persist(job_id,sources,"inbox",current_user)
async def fetch_candidates(self,job_id=None):
# job_id omitted -> the whole pool across jobs (frontend Candidates/TalentPool).
if job_id is not None:
job=await JobPosts.get_job_post_by_id(self.session,job_id)
if job is None or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
rows=await Candidates.get_candidates_by_job(self.session,job_id)
return [serialize_candidate(row) for row in rows]
async def fetch_candidate_by_id(self,candidate_id):
row=await Candidates.get_candidate_by_id(self.session,candidate_id)
if row is None:
raise HTTPException(status_code=404,detail="Candidate not found")
return serialize_candidate(row)
async def _score_and_persist(self,job_id,sources,source_kind,current_user):
job=await JobPosts.get_job_post_by_id(self.session,job_id)
if job is None or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
settings=get_scoring_settings()
jd=build_job_description(job)
if len(jd)>settings.max_jd_chars:
raise HTTPException(status_code=422,detail="The job post is too large to score against")
# Slot-indexed like app/api/routes.py: results merge back by position, never
# by filename — inbox attachments can share a basename.
results_by_slot={}
extracted=[]
for slot,source in enumerate(sources):
source["safe_name"]=sanitize_filename(source["filename"])
data=source["data"]
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
if source["precheck"] is not None:
code,message=source["precheck"]
results_by_slot[slot]=self._failed_fields(source,code,message)
continue
try:
# pypdf is CPU-bound: keep it off the event loop. Despace BEFORE
# scoring so keyword verification sees the exact text the model saw;
# ExtractedResume is frozen, hence dataclasses.replace.
resume=await asyncio.to_thread(
extract_resume,data,source["safe_name"],settings.max_resume_chars
)
resume=dataclasses.replace(resume,text=normalize_spaced_text(resume.text))
except ATSError as exc:
results_by_slot[slot]=self._failed_fields(source,exc.error_code,exc.public_message)
continue
extracted.append((slot,resume))
scored=await score_batch(
[resume for _,resume in extracted],
job_description=jd,
scorer=get_scorer(),
concurrency=settings.scoring_concurrency,
)
for (slot,_),result in zip(extracted,scored,strict=True):
source=sources[slot]
if isinstance(result,CompletedCandidate):
results_by_slot[slot]={
**self._base_fields(source),
"status":"completed",
"candidate_name":result.candidate_name,
"job_title":result.job_title,
"current_company":result.current_company,
"years_experience":result.years_experience,
"match_score":result.match_score,
"matched_keywords":result.matched_keywords,
"missing_keywords":result.missing_keywords,
"summary_critique":result.summary_critique,
"error_code":None,
"error_message":None,
}
else:
results_by_slot[slot]=self._failed_fields(source,result.error_code,result.error_message)
common={
"job_id":job.id,
"source":source_kind,
"created_by":uuid.UUID(str(current_user["id"])),
"model":settings.openai_model,
}
rows=[]
for slot in range(len(sources)):
fields={**results_by_slot[slot],**common}
rows.append(await Candidates.upsert_candidate(self.session,fields))
# Every completed score lands in ats_results. Inbox scores additionally
# denormalise onto inbox_messages / inbox (one sync per message: a
# multi-attachment mail keeps its best completed score); upload scores
# chain per candidates row instead — they have no inbox application.
if source_kind=="inbox":
best={}
for row in rows:
if row.status=="completed" and row.inbox_message_id:
cur=best.get(row.inbox_message_id)
if cur is None or (row.match_score or 0)>(cur.match_score or 0):
best[row.inbox_message_id]=row
for message_id,row in best.items():
try:
await self._sync_inbox_ats(message_id,job,row)
except Exception:
# The candidates row is the primary outcome and is already
# committed; a denorm failure must not fail the scoring call.
await self.session.rollback()
logger.exception("inbox ATS denorm failed for message %s",message_id)
else:
for row in rows:
if row.status!="completed":
continue
try:
await self._sync_upload_ats(job,row)
except Exception:
await self.session.rollback()
logger.exception("upload ATS history failed for candidate %s",row.id)
# Leaderboard order: completed by score desc, failures last, stable.
rows.sort(key=lambda r:(0,-(r.match_score or 0)) if r.status=="completed" else (1,0))
return [serialize_candidate(row) for row in rows]
@staticmethod
def _base_fields(source):
return {
"inbox_message_id":source["inbox_message_id"],
"filename":source["safe_name"],
"file_path":source["file_path"],
"content_sha256":source["sha256"],
}
@classmethod
def _failed_fields(cls,source,code,message):
return {
**cls._base_fields(source),
"status":"failed",
"error_code":str(code),
"error_message":message,
"candidate_name":None,
"job_title":None,
"current_company":None,
"years_experience":None,
"match_score":None,
"matched_keywords":[],
"missing_keywords":[],
"summary_critique":None,
}
async def _sync_inbox_ats(self,message_id,job,row):
"""Land a completed score on the inbox tables (README "What is missing").
inbox_messages.ats_score / ats_band are what serialize_application renders
on the Applications tab; ats_results keeps the per-application history that
candidates' in-place upsert cannot. Precedence mirrors _recommendation: a
score against the assigned job always wins the denormalised columns, any
other job's score only lands while no completed assigned-job score exists.
The history row is appended regardless — it records the scoring event.
"""
msg=await Inbox_Messages.get_inbox_message_by_id(self.session,message_id)
if msg is None:
return
band=CandidateView._recommendation(row.match_score) or ""
denorm=True
assigned=msg.assigned_job_post_id
if assigned and str(assigned)!=str(job.id):
outranked=await self.session.execute(
select(Candidates).where(
Candidates.inbox_message_id==msg.id,
Candidates.job_id==assigned,
Candidates.status=="completed",
)
)
denorm=outranked.scalars().first() is None
if denorm:
msg.ats_score=float(row.match_score)
msg.ats_band=band
self.session.add(msg)
# ats_results hangs off the inbox JOIN row (int PK), which only exists once
# the sender is linked to a users account; without it there is no history row.
link=await Inbox.get_inbox_by_message_id(self.session,message_id)
if link is not None:
prev=await AtsResults.get_current_for_inbox(self.session,link.id)
entry=AtsResults(
inbox_id=link.id,
candidate_id=row.id,
job_post_id=job.id,
overall_score=float(row.match_score),
band=band,
model_name=row.model,
is_current=True,
)
self.session.add(entry)
# Flush the INSERT before touching prev/link: with no relationship()
# edge the unit of work emits the UPDATEs first, and both FKs
# (superseded_by_id, inbox.ats_id) reject a pointer to a row that is
# not inserted yet.
await self.session.flush()
if prev is not None:
prev.is_current=False
prev.superseded_by_id=entry.id
self.session.add(prev)
# inbox.ats_id always points at the CURRENT score row.
link.ats_id=entry.id
link.updated_at=datetime.now(timezone.utc)
self.session.add(link)
await self.session.commit()
async def _sync_upload_ats(self,job,row):
"""History row for an upload-sourced score — no inbox application exists,
so inbox_id stays NULL and the supersede chain runs per candidates row
(stable across re-scores: upsert keeps the id for the same job+bytes)."""
band=CandidateView._recommendation(row.match_score) or ""
prev=await AtsResults.get_current_for_candidate(self.session,row.id)
entry=AtsResults(
inbox_id=None,
candidate_id=row.id,
job_post_id=job.id,
overall_score=float(row.match_score),
band=band,
model_name=row.model,
is_current=True,
)
self.session.add(entry)
# Same flush-before-pointing rule as the inbox path: the FK on
# superseded_by_id must see the new row inserted first.
await self.session.flush()
if prev is not None:
prev.is_current=False
prev.superseded_by_id=entry.id
self.session.add(prev)
await self.session.commit()
class CandidateView:
def __init__(self,session:AsyncSession):
self.session=session
@staticmethod
def _recommendation(score):
if score is None:
return None
# Same bands the frontend uses (Candidates.jsx / seed.js).
return "Strong Match" if score>=82 else "Potential Match" if score>=65 else "Weak Match"
async def _scores_by_message(self,message_ids):
"""Completed ATS scores (candidates table) per inbox message id, newest first.
One batched query — the profile list would otherwise pay a query per row.
"""
mids=[m for m in message_ids if m]
if not mids:
return {}
result=await self.session.execute(
select(Candidates)
.where(Candidates.inbox_message_id.in_(mids),Candidates.status=="completed")
.order_by(Candidates.updated_at.desc())
)
scores={}
for row in result.scalars().all():
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,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:
raise HTTPException(status_code=422,detail="candidate_email is required")
if not current_user:
raise HTTPException(status_code=400,detail="created_by is required")
data={
"candidate_email":email,
"candidate_name":(candidate_name or "").strip(),
"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(),
"referral_by":(referral_by or "").strip(),
"file_name":(file_name or "").strip(),
"file_path":(file_path or "").strip(),
"full_text":full_text or "",
"created_by":current_user,
}
row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session=self.session,fields=data)
return serialize_manual_upload_candidate(row)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None):
try:
detail=bool(user_id)
# Detail mode must see every application for the candidate, not one page.
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:
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
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def count_candidates(self,user_id=None,search=None):
try:
return await Inbox.count_candidate_profiles(session=self.session,user_id=user_id,search=search)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def update_candidate(self,user_id,payload):
try:
if not user_id:
raise HTTPException(status_code=400,detail="user_id is required")
fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
if not fields:
raise HTTPException(status_code=400,detail="favorite or rating is required")
links=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
records=links if isinstance(links,list) else ([links] if links else [])
if not records:
raise HTTPException(status_code=404,detail="Candidate not found")
for link in records:
await Inbox.update_inbox(self.session,link.id,fields)
refreshed=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
return await self.attach_profile_detail(refreshed)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@staticmethod
def _attach_job_post(data,payload,*,as_assigned=False):
"""Merge one serialized job post onto a candidate payload.
Pure, so the per-id path and the batched one below cannot drift. A missing
post attaches nothing, matching the old early return.
"""
if not isinstance(data,dict) or not payload:
return payload
if as_assigned:
data["assigned_job_post"]=payload
if payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name")
data["recruiter_id"]=payload.get("created_by")
if payload.get("title"):
data["job_title"]=payload.get("title")
else:
data.setdefault("job_posts",[]).append(payload)
if data.get("recruiter") is None and payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name")
data["recruiter_id"]=payload.get("created_by")
if data.get("job_title") is None and payload.get("title"):
data["job_title"]=payload.get("title")
return payload
async def _job_posts_by_id(self,ids):
"""Serialized job posts keyed by id — one query for a whole page of rows.
active_only=False mirrors get_job_post_by_id, which filters neither flag:
an application assigned to a closed post must keep its title.
"""
wanted=[]
seen=set()
for raw in ids or []:
key=str(raw) if raw else None
if not key or key in seen:
continue
seen.add(key)
wanted.append(key)
if not wanted:
return {}
rows=await JobPosts.get_by_ids(self.session,wanted,active_only=False)
return {str(r.id):serialize_job_post(r) for r in rows}
async def get_job_post_by_id(self,record_id,data=None,*,as_assigned=False):
"""Load full job_posts row and optionally append it onto a candidate payload."""
try:
job_post_data=await JobPosts.get_job_post_by_id(session=self.session,record_id=record_id)
if not job_post_data:
return None
return self._attach_job_post(data,serialize_job_post(job_post_data),as_assigned=as_assigned)
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def attach_job_posts(self,data):
"""Normalize list/single, serialize each record, attach full job_posts rows.
Two batched queries per page, not two per row — a 200-row pipeline board
issued 600+ sequential job_posts round trips before.
"""
single=not isinstance(data,list)
records=[data] if single else list(data or [])
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
payloads=[]
wanted=[]
for record in records:
payload=serialize_candidate_profile(record)
payload["job_posts"]=[]
payload["assigned_job_post"]=None
scored=scores.get(getattr(record,"message_id",None)) or []
if scored:
payload["ai_score"]=scored[0].match_score
payload["recommendation"]=self._recommendation(scored[0].match_score)
payloads.append(payload)
if payload.get("assigned_job_post_id"):
wanted.append(payload["assigned_job_post_id"])
wanted.extend(payload.get("suggested_job_post_ids") or [])
posts=await self._job_posts_by_id(wanted)
# Copy per attach: two candidates on the same post held independent dicts
# back when every row re-serialized its own.
for payload in payloads:
assigned_id=payload.get("assigned_job_post_id")
if assigned_id:
post=posts.get(str(assigned_id))
self._attach_job_post(payload,dict(post) if post else None,as_assigned=True)
for job_id in payload.get("suggested_job_post_ids") or []:
post=posts.get(str(job_id))
self._attach_job_post(payload,dict(post) if post else None)
return payloads[0] if single else payloads
async def attach_profile_detail(self,data):
"""Detail mode: flatten child collections across every Inbox row for the candidate."""
single=not isinstance(data,list)
records=[data] if single else list(data or [])
if not records:
return {} if single else []
interviews=[]
activity=[]
feedback=[]
documents=[]
job_posts=[]
assigned_job_post=None
base=None
user_id=None
favorite=None
rating=None
for record in records:
payload=serialize_candidate_profile(record,detail=True)
if base is None:
base=payload
user_id=payload.get("user_id")
favorite=payload.get("favorite")
rating=payload.get("rating")
interviews.extend(payload.get("interviews") or [])
activity.extend(payload.get("activity") or [])
feedback.extend(payload.get("feedback") or [])
documents.extend(payload.get("documents") or [])
if payload.get("assigned_job_post_id") and assigned_job_post is None:
await self.get_job_post_by_id(
record_id=payload.get("assigned_job_post_id"),
data=payload,
as_assigned=True,
)
assigned_job_post=payload.get("assigned_job_post")
if base.get("recruiter") is None and payload.get("recruiter"):
base["recruiter"]=payload.get("recruiter")
base["recruiter_id"]=payload.get("recruiter_id")
if base.get("job_title") is None and payload.get("job_title"):
base["job_title"]=payload.get("job_title")
for job_id in payload.get("suggested_job_post_ids") or []:
await self.get_job_post_by_id(record_id=job_id,data=payload)
for jp in payload.get("job_posts") or []:
if not any(x.get("id")==jp.get("id") for x in job_posts):
job_posts.append(jp)
if base.get("recruiter") is None and payload.get("recruiter"):
base["recruiter"]=payload.get("recruiter")
base["recruiter_id"]=payload.get("recruiter_id")
if base.get("job_title") is None and payload.get("job_title"):
base["job_title"]=payload.get("job_title")
notes=[]
uid=Notes._as_uuid(user_id) if user_id else None
if uid is not None:
result=await self.session.execute(
select(Notes)
.options(selectinload(Notes.author))
.where(Notes.user_id==uid)
.order_by(Notes.created_at.desc())
)
notes=[serialize_note(r) for r in result.scalars().all()]
# ATS score: join the scoring engine's `candidates` rows onto the profile
# by inbox message. The serializer stubs ai_score/recommendation to None;
# this is where they get real values. Prefer the score against the
# assigned job post, else the most recent completed score.
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
scored_rows=[row for rows in scores.values() for row in rows]
if scored_rows:
assigned_uid=Candidates._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
chosen=None
if assigned_uid is not None:
chosen=next((r for r in scored_rows if r.job_id==assigned_uid),None)
if chosen is None:
chosen=max(scored_rows,key=lambda r:r.updated_at)
base["ai_score"]=chosen.match_score
base["recommendation"]=self._recommendation(chosen.match_score)
base["matched_keywords"]=list(chosen.matched_keywords or [])
base["missing_keywords"]=list(chosen.missing_keywords or [])
base["summary_critique"]=chosen.summary_critique
base["scored_job_post_id"]=str(chosen.job_id)
base["scored_at"]=chosen.updated_at.isoformat() if chosen.updated_at else None
activity.sort(key=lambda r:(r.get("activity_date") or ""),reverse=True)
base["favorite"]=favorite
base["rating"]=rating
base["interviews"]=interviews
base["activity"]=activity
base["feedback"]=feedback
base["documents"]=documents
base["notes"]=notes
base["job_posts"]=job_posts or base.get("job_posts") or []
base["assigned_job_post"]=assigned_job_post
if assigned_job_post:
base["assigned_job_post_id"]=assigned_job_post.get("id")
if assigned_job_post.get("created_by_name"):
base["recruiter"]=assigned_job_post.get("created_by_name")
base["recruiter_id"]=assigned_job_post.get("created_by")
if assigned_job_post.get("title"):
base["job_title"]=assigned_job_post.get("title")
return base