863 lines
39 KiB
Python
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
|