Merge pull request 'SQS_BROKER' (#90) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 36s
Details
Deploy to S3 / deploy (push) Successful in 36s
Details
Reviewed-on: #90pull/91/head^2
commit
f8cd8d26f2
|
|
@ -1293,6 +1293,23 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_resume_text(cls, session: AsyncSession, record_id, text, *, only_if_empty=True):
|
||||||
|
"""Keep extracted CV text on the inbox row so Inbox / ATS rerun can fetch it."""
|
||||||
|
row = await cls.get_inbox_message_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
value = (text or "").strip() or None
|
||||||
|
if not value:
|
||||||
|
return row
|
||||||
|
if only_if_empty and (row.resume_text or "").strip():
|
||||||
|
return row
|
||||||
|
row.resume_text = value
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def set_professional_summary(cls, session: AsyncSession, record_id, summary):
|
async def set_professional_summary(cls, session: AsyncSession, record_id, summary):
|
||||||
row = await cls.get_inbox_message_by_id(session, record_id)
|
row = await cls.get_inbox_message_by_id(session, record_id)
|
||||||
|
|
|
||||||
|
|
@ -731,6 +731,12 @@ class InboxScoreRequest(BaseModel):
|
||||||
message_ids: list[str] # inbox_messages PK uuids, not Graph message ids
|
message_ids: list[str] # inbox_messages PK uuids, not Graph message ids
|
||||||
|
|
||||||
|
|
||||||
|
class AtsRerunBody(BaseModel):
|
||||||
|
user_id: UUID | None = None
|
||||||
|
inbox_message_id: UUID | None = None
|
||||||
|
manual_upload_candidate_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/candidate/score")
|
@router.post("/candidate/score")
|
||||||
async def score_candidates(
|
async def score_candidates(
|
||||||
job_id: str = Form(...),
|
job_id: str = Form(...),
|
||||||
|
|
@ -767,6 +773,28 @@ async def score_inbox_candidates(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidate/ats-rerun")
|
||||||
|
async def rerun_candidate_ats(
|
||||||
|
payload: AtsRerunBody,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Re-score one candidate from stored professional_summary / inbox extract."""
|
||||||
|
try:
|
||||||
|
service=CandidateScoring(session=session)
|
||||||
|
data=await service.rerun_ats(
|
||||||
|
current_user,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
inbox_message_id=payload.inbox_message_id,
|
||||||
|
manual_upload_candidate_id=payload.manual_upload_candidate_id,
|
||||||
|
)
|
||||||
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/candidate/scored/fetch")
|
@router.get("/candidate/scored/fetch")
|
||||||
async def fetch_scored_candidates(
|
async def fetch_scored_candidates(
|
||||||
job_id: str = Query(None),
|
job_id: str = Query(None),
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,24 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
result = await session.execute(select(cls).where(cls.id == uid))
|
result = await session.execute(select(cls).where(cls.id == uid))
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_full_text(cls, session: AsyncSession, record_id, text, *, only_if_empty=True):
|
||||||
|
"""Keep extracted CV text on the bank/manual row (Inbox uses resume_text)."""
|
||||||
|
row = await cls.get_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
value = (text or "").strip()
|
||||||
|
if not value:
|
||||||
|
return row
|
||||||
|
if only_if_empty and (row.full_text or "").strip():
|
||||||
|
return row
|
||||||
|
row.full_text = value
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def set_professional_summary(cls, session: AsyncSession, record_id, summary):
|
async def set_professional_summary(cls, session: AsyncSession, record_id, summary):
|
||||||
row = await cls.get_by_id(session, record_id)
|
row = await cls.get_by_id(session, record_id)
|
||||||
|
|
@ -1039,6 +1057,12 @@ class Candidates(SQLModel, table=True):
|
||||||
professional_summary: str | None = Field(default=None)
|
professional_summary: str | None = Field(default=None)
|
||||||
# Public LinkedIn URL extracted from the scored CV. Fetch reads this column.
|
# Public LinkedIn URL extracted from the scored CV. Fetch reads this column.
|
||||||
linkedin_url: str | None = Field(default=None)
|
linkedin_url: str | None = Field(default=None)
|
||||||
|
# Application that owns the extracted CV text. Inbox email → inbox_messages.resume_text;
|
||||||
|
# Add Candidate / CV bank → manual_upload_candidate.full_text. Migration 036.
|
||||||
|
inbox_message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id", index=True)
|
||||||
|
manual_upload_candidate_id: uuid.UUID | None = Field(
|
||||||
|
default=None, foreign_key="manual_upload_candidate.id", index=True
|
||||||
|
)
|
||||||
|
|
||||||
status: str # "completed" | "failed"
|
status: str # "completed" | "failed"
|
||||||
error_code: str | None = Field(default=None)
|
error_code: str | None = Field(default=None)
|
||||||
|
|
@ -1140,8 +1164,8 @@ class Candidates(SQLModel, table=True):
|
||||||
"source": "ats",
|
"source": "ats",
|
||||||
"email": email,
|
"email": email,
|
||||||
"inbox_id": None,
|
"inbox_id": None,
|
||||||
"message_id": None,
|
"message_id": str(rec.inbox_message_id) if getattr(rec, "inbox_message_id", None) else None,
|
||||||
"manual_upload_candidate_id": None,
|
"manual_upload_candidate_id": str(rec.manual_upload_candidate_id) if getattr(rec, "manual_upload_candidate_id", None) else None,
|
||||||
"form_data_id": None,
|
"form_data_id": None,
|
||||||
"candidate_id": str(rec.id),
|
"candidate_id": str(rec.id),
|
||||||
"job_post_id": str(rec.job_id) if rec.job_id else None,
|
"job_post_id": str(rec.job_id) if rec.job_id else None,
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,20 @@ def build_job_description(job) -> str:
|
||||||
|
|
||||||
|
|
||||||
def candidate_base_fields(source):
|
def candidate_base_fields(source):
|
||||||
return {
|
fields = {
|
||||||
"filename": source["safe_name"],
|
"filename": source["safe_name"],
|
||||||
"file_path": source["file_path"],
|
"file_path": source["file_path"],
|
||||||
"content_sha256": source["sha256"],
|
"content_sha256": source["sha256"],
|
||||||
"candidate_email": source.get("candidate_email"),
|
"candidate_email": source.get("candidate_email"),
|
||||||
}
|
}
|
||||||
|
# Omit empty FKs so an upsert cannot wipe a link that this source does not know.
|
||||||
|
inbox_mid = source.get("inbox_message_id")
|
||||||
|
if inbox_mid:
|
||||||
|
fields["inbox_message_id"] = inbox_mid
|
||||||
|
manual_id = source.get("manual_upload_candidate_id")
|
||||||
|
if manual_id:
|
||||||
|
fields["manual_upload_candidate_id"] = manual_id
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
def candidate_failed_fields(source, code, message):
|
def candidate_failed_fields(source, code, message):
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ def serialize_candidate(row) -> dict:
|
||||||
"summary_critique": row.summary_critique,
|
"summary_critique": row.summary_critique,
|
||||||
"professional_summary": row.professional_summary or None,
|
"professional_summary": row.professional_summary or None,
|
||||||
"linkedin_url": row.linkedin_url or None,
|
"linkedin_url": row.linkedin_url or None,
|
||||||
|
"inbox_message_id": str(row.inbox_message_id) if getattr(row, "inbox_message_id", None) else None,
|
||||||
|
"manual_upload_candidate_id": str(row.manual_upload_candidate_id) if getattr(row, "manual_upload_candidate_id", None) else None,
|
||||||
"status": row.status,
|
"status": row.status,
|
||||||
"error_code": row.error_code,
|
"error_code": row.error_code,
|
||||||
"error_message": row.error_message,
|
"error_message": row.error_message,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from fastapi import HTTPException
|
||||||
from pypdf import PdfReader
|
from pypdf import PdfReader
|
||||||
from app.core.errors import ATSError,ErrorCode
|
from app.core.errors import ATSError,ErrorCode
|
||||||
from app.models.scoring import CompletedCandidate
|
from app.models.scoring import CompletedCandidate
|
||||||
from app.services.pdf import extract_resume,sanitize_filename
|
from app.services.pdf import ExtractedResume,extract_resume,sanitize_filename
|
||||||
from app.services.scoring import score_batch
|
from app.services.scoring import score_batch
|
||||||
from inbox.models import Inbox_Messages,Inbox,Inbox_Message_Triage,InboxRescanRun,AtsResults
|
from inbox.models import Inbox_Messages,Inbox,Inbox_Message_Triage,InboxRescanRun,AtsResults
|
||||||
from job.candidate.models import Candidates
|
from job.candidate.models import Candidates
|
||||||
|
|
@ -705,6 +705,7 @@ class CandidateScoring:
|
||||||
if not row.file_path:
|
if not row.file_path:
|
||||||
continue
|
continue
|
||||||
names=[n.strip() for n in (row.file_name or "").split(",") if n.strip()]
|
names=[n.strip() for n in (row.file_name or "").split(",") if n.strip()]
|
||||||
|
stored=(row.resume_text or "").strip()
|
||||||
for idx,path_str in enumerate(p.strip() for p in row.file_path.split(",") if p.strip()):
|
for idx,path_str in enumerate(p.strip() for p in row.file_path.split(",") if p.strip()):
|
||||||
name=names[idx] if idx<len(names) else Path(path_str.replace("\\","/")).name or "resume.pdf"
|
name=names[idx] if idx<len(names) else Path(path_str.replace("\\","/")).name or "resume.pdf"
|
||||||
source={
|
source={
|
||||||
|
|
@ -716,6 +717,9 @@ class CandidateScoring:
|
||||||
"professional_summary":None,
|
"professional_summary":None,
|
||||||
"precheck":None,
|
"precheck":None,
|
||||||
}
|
}
|
||||||
|
if stored:
|
||||||
|
source["resume_text"]=stored
|
||||||
|
source["stored_resume_text"]=stored
|
||||||
source["professional_summary"]=await self._professional_summary_for_email(
|
source["professional_summary"]=await self._professional_summary_for_email(
|
||||||
source["candidate_email"],row.professional_summary,
|
source["candidate_email"],row.professional_summary,
|
||||||
)
|
)
|
||||||
|
|
@ -723,6 +727,9 @@ class CandidateScoring:
|
||||||
if source["precheck"] is not None:
|
if source["precheck"] is not None:
|
||||||
sources.append(source)
|
sources.append(source)
|
||||||
continue
|
continue
|
||||||
|
if stored:
|
||||||
|
sources.append(source)
|
||||||
|
continue
|
||||||
lower=name.lower()
|
lower=name.lower()
|
||||||
if lower.endswith(".doc") or lower.endswith(".docx"):
|
if lower.endswith(".doc") or lower.endswith(".docx"):
|
||||||
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"DOC/DOCX extraction is not supported yet.")
|
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"DOC/DOCX extraction is not supported yet.")
|
||||||
|
|
@ -785,6 +792,10 @@ class CandidateScoring:
|
||||||
"professional_summary":None,
|
"professional_summary":None,
|
||||||
"precheck":None,
|
"precheck":None,
|
||||||
}
|
}
|
||||||
|
stored=(row.full_text or "").strip()
|
||||||
|
if stored:
|
||||||
|
source["resume_text"]=stored
|
||||||
|
source["stored_resume_text"]=stored
|
||||||
source["professional_summary"]=await self._professional_summary_for_email(
|
source["professional_summary"]=await self._professional_summary_for_email(
|
||||||
source["candidate_email"],row.professional_summary,
|
source["candidate_email"],row.professional_summary,
|
||||||
)
|
)
|
||||||
|
|
@ -792,6 +803,9 @@ class CandidateScoring:
|
||||||
if source["precheck"] is not None:
|
if source["precheck"] is not None:
|
||||||
sources.append(source)
|
sources.append(source)
|
||||||
continue
|
continue
|
||||||
|
if stored:
|
||||||
|
sources.append(source)
|
||||||
|
continue
|
||||||
file_row=await CvBankFiles.get(self.session,row.id)
|
file_row=await CvBankFiles.get(self.session,row.id)
|
||||||
data=file_row.data if file_row and file_row.data else None
|
data=file_row.data if file_row and file_row.data else None
|
||||||
if data is None and source["file_path"]:
|
if data is None and source["file_path"]:
|
||||||
|
|
@ -828,7 +842,127 @@ class CandidateScoring:
|
||||||
data=serialize_candidate(row)
|
data=serialize_candidate(row)
|
||||||
return await CandidateView(session=self.session).attach_application_history(data)
|
return await CandidateView(session=self.session).attach_application_history(data)
|
||||||
|
|
||||||
async def _score_and_persist(self,job_id,sources,source_kind,current_user,rescan_run_id=None):
|
async def rerun_ats(self,current_user,user_id=None,inbox_message_id=None,manual_upload_candidate_id=None):
|
||||||
|
"""Re-score one candidate without re-reading the PDF.
|
||||||
|
|
||||||
|
Prefer candidates.professional_summary as the scoring text. Fall back to
|
||||||
|
inbox_messages.resume_text / manual_upload_candidate.full_text. Only parse
|
||||||
|
the file when nothing is stored, then write that extract onto the inbox
|
||||||
|
or manual row and stamp the FK on candidates.
|
||||||
|
"""
|
||||||
|
from inbox.plugins import extract_resume_text
|
||||||
|
|
||||||
|
message=None
|
||||||
|
manual=None
|
||||||
|
email=None
|
||||||
|
if inbox_message_id:
|
||||||
|
message=await Inbox_Messages.get_inbox_message_by_id(self.session,inbox_message_id)
|
||||||
|
if message is None:
|
||||||
|
raise HTTPException(status_code=404,detail="Inbox message not found")
|
||||||
|
elif user_id:
|
||||||
|
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 [])
|
||||||
|
picked=None
|
||||||
|
for link in records:
|
||||||
|
msg=getattr(link,"messages",None)
|
||||||
|
if msg is not None and msg.assigned_job_post_id:
|
||||||
|
picked=link
|
||||||
|
break
|
||||||
|
if picked is None and records:
|
||||||
|
picked=records[0]
|
||||||
|
if picked is None:
|
||||||
|
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||||
|
if manual is None:
|
||||||
|
raise HTTPException(status_code=404,detail="Candidate not found")
|
||||||
|
else:
|
||||||
|
message=getattr(picked,"messages",None)
|
||||||
|
user=getattr(picked,"user",None)
|
||||||
|
email=(getattr(user,"email",None) or "").strip().lower() or None
|
||||||
|
elif manual_upload_candidate_id:
|
||||||
|
manual=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_upload_candidate_id)
|
||||||
|
if manual is None:
|
||||||
|
raise HTTPException(status_code=404,detail="Candidate not found")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400,detail="user_id, inbox_message_id or manual_upload_candidate_id is required")
|
||||||
|
|
||||||
|
if message is None and manual is None:
|
||||||
|
raise HTTPException(status_code=404,detail="Candidate not found")
|
||||||
|
|
||||||
|
job_id=None
|
||||||
|
filename="resume.pdf"
|
||||||
|
file_path=None
|
||||||
|
stored_extract=None
|
||||||
|
inbox_summary=None
|
||||||
|
inbox_mid=None
|
||||||
|
manual_id=None
|
||||||
|
existing_sha=None
|
||||||
|
|
||||||
|
if message is not None:
|
||||||
|
inbox_mid=message.id
|
||||||
|
email=email or (message.message_from or "").strip().lower() or None
|
||||||
|
suggested=list(message.suggested_job_post_ids or [])
|
||||||
|
job_id=message.assigned_job_post_id or (suggested[0] if suggested else None)
|
||||||
|
names=[n.strip() for n in (message.file_name or "").split(",") if n.strip()]
|
||||||
|
filename=names[0] if names else "resume.pdf"
|
||||||
|
paths=[p.strip() for p in (message.file_path or "").split(",") if p.strip()]
|
||||||
|
file_path=paths[0] if paths else None
|
||||||
|
stored_extract=(message.resume_text or "").strip() or None
|
||||||
|
inbox_summary=(message.professional_summary or "").strip() or None
|
||||||
|
else:
|
||||||
|
manual_id=manual.id
|
||||||
|
email=email or (manual.candidate_email or "").strip().lower() or None
|
||||||
|
job_id=manual.job_post_id
|
||||||
|
filename=(manual.file_name or "").strip() or "resume.pdf"
|
||||||
|
file_path=(manual.file_path or "").strip() or None
|
||||||
|
stored_extract=(manual.full_text or "").strip() or None
|
||||||
|
inbox_summary=(manual.professional_summary or "").strip() or None
|
||||||
|
|
||||||
|
if job_id is None:
|
||||||
|
raise HTTPException(status_code=422,detail="Assign a job before running ATS")
|
||||||
|
|
||||||
|
scored=await Candidates.get_completed_by_email_job(self.session,email,job_id) if email else None
|
||||||
|
table_summary=(scored.professional_summary or "").strip() if scored else None
|
||||||
|
if scored and scored.content_sha256:
|
||||||
|
existing_sha=scored.content_sha256
|
||||||
|
identity_summary=await self._professional_summary_for_email(email,inbox_summary)
|
||||||
|
summary=(table_summary or identity_summary or "").strip() or None
|
||||||
|
stored_extract=(stored_extract or "").strip() or None
|
||||||
|
|
||||||
|
score_text=summary or stored_extract
|
||||||
|
if not score_text:
|
||||||
|
paths=[p.strip() for p in (file_path or "").split(",") if p.strip()] if file_path else []
|
||||||
|
if not paths:
|
||||||
|
raise HTTPException(status_code=400,detail="No professional summary or stored CV text to score")
|
||||||
|
text,extract_err=await extract_resume_text(paths)
|
||||||
|
score_text=(text or "").strip() or None
|
||||||
|
if not score_text:
|
||||||
|
raise HTTPException(status_code=400,detail=extract_err or "No professional summary or stored CV text to score")
|
||||||
|
stored_extract=score_text
|
||||||
|
|
||||||
|
if inbox_mid is not None and stored_extract:
|
||||||
|
await Inbox_Messages.set_resume_text(self.session,inbox_mid,stored_extract)
|
||||||
|
if manual_id is not None and stored_extract:
|
||||||
|
await Manual_UPLOAD_CANDIDATE.set_full_text(self.session,manual_id,stored_extract)
|
||||||
|
|
||||||
|
source={
|
||||||
|
"filename":filename,
|
||||||
|
"data":None,
|
||||||
|
"file_path":file_path,
|
||||||
|
"resume_text":score_text,
|
||||||
|
"stored_resume_text":stored_extract,
|
||||||
|
"sha256":existing_sha or (
|
||||||
|
hashlib.sha256(stored_extract.encode("utf-8")).hexdigest() if stored_extract else None
|
||||||
|
),
|
||||||
|
"inbox_message_id":inbox_mid,
|
||||||
|
"manual_upload_candidate_id":manual_id,
|
||||||
|
"candidate_email":email,
|
||||||
|
"professional_summary":summary,
|
||||||
|
"precheck":None,
|
||||||
|
}
|
||||||
|
kind="inbox" if inbox_mid is not None else "upload"
|
||||||
|
return await self._score_and_persist(job_id, [source], kind, current_user, skip_gate=True)
|
||||||
|
|
||||||
|
async def _score_and_persist(self,job_id,sources,source_kind,current_user,rescan_run_id=None,skip_gate=False):
|
||||||
job=await JobPosts.get_job_post_by_id(self.session,job_id)
|
job=await JobPosts.get_job_post_by_id(self.session,job_id)
|
||||||
if job is None or job.is_deleted:
|
if job is None or job.is_deleted:
|
||||||
raise HTTPException(status_code=404,detail="Job post not found")
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
|
|
@ -842,21 +976,23 @@ class CandidateScoring:
|
||||||
source["professional_summary"]=await self._professional_summary_for_email(
|
source["professional_summary"]=await self._professional_summary_for_email(
|
||||||
source.get("candidate_email"),
|
source.get("candidate_email"),
|
||||||
)
|
)
|
||||||
await self._gate_source(source,jd)
|
if not skip_gate:
|
||||||
|
await self._gate_source(source,jd)
|
||||||
fields_by_slot=await self._score_sources(sources,jd,settings)
|
fields_by_slot=await self._score_sources(sources,jd,settings)
|
||||||
# Prefer the Manual S3 URL for this email+job when scoring from a raw upload
|
# Prefer the Manual S3 URL for this email+job when scoring from a raw upload
|
||||||
# (Add Candidate scores right after create — same link as manual_upload_candidate).
|
# (Add Candidate scores right after create — same link as manual_upload_candidate).
|
||||||
for slot,source in enumerate(sources):
|
for slot,source in enumerate(sources):
|
||||||
fields=fields_by_slot.get(slot) or {}
|
fields=fields_by_slot.get(slot) or {}
|
||||||
if (fields.get("file_path") or source.get("file_path") or "").strip():
|
|
||||||
continue
|
|
||||||
email=(fields.get("candidate_email") or source.get("candidate_email") or "").strip().lower()
|
email=(fields.get("candidate_email") or source.get("candidate_email") or "").strip().lower()
|
||||||
if not email:
|
if email and not source.get("manual_upload_candidate_id") and not fields.get("manual_upload_candidate_id"):
|
||||||
continue
|
manual=await Manual_UPLOAD_CANDIDATE.get_by_email_and_job(self.session,email,job.id)
|
||||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_email_and_job(self.session,email,job.id)
|
if manual:
|
||||||
if manual and (manual.file_path or "").strip():
|
source["manual_upload_candidate_id"]=manual.id
|
||||||
fields["file_path"]=manual.file_path.strip()
|
fields["manual_upload_candidate_id"]=manual.id
|
||||||
source["file_path"]=manual.file_path.strip()
|
if not (fields.get("file_path") or source.get("file_path") or "").strip() and (manual.file_path or "").strip():
|
||||||
|
fields["file_path"]=manual.file_path.strip()
|
||||||
|
source["file_path"]=manual.file_path.strip()
|
||||||
|
await self._persist_extracts(sources)
|
||||||
common={
|
common={
|
||||||
"job_id":job.id,
|
"job_id":job.id,
|
||||||
"source":source_kind,
|
"source":source_kind,
|
||||||
|
|
@ -888,17 +1024,36 @@ class CandidateScoring:
|
||||||
|
|
||||||
async def _score_sources(self,sources,jd,settings):
|
async def _score_sources(self,sources,jd,settings):
|
||||||
# Slot-indexed: results merge back by position, never by filename —
|
# Slot-indexed: results merge back by position, never by filename —
|
||||||
# inbox attachments can share a basename.
|
# inbox attachments can share a basename. Stored resume_text skips PDF parse.
|
||||||
fields_by_slot={}
|
fields_by_slot={}
|
||||||
extracted=[]
|
extracted=[]
|
||||||
for slot,source in enumerate(sources):
|
for slot,source in enumerate(sources):
|
||||||
source["safe_name"]=sanitize_filename(source["filename"])
|
source["safe_name"]=sanitize_filename(source.get("filename") or "resume.pdf")
|
||||||
data=source["data"]
|
if source.get("precheck") is not None:
|
||||||
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
|
|
||||||
if source["precheck"] is not None:
|
|
||||||
code,message=source["precheck"]
|
code,message=source["precheck"]
|
||||||
fields_by_slot[slot]=candidate_failed_fields(source,code,message)
|
fields_by_slot[slot]=candidate_failed_fields(source,code,message)
|
||||||
continue
|
continue
|
||||||
|
stored=(source.get("resume_text") or "").strip()
|
||||||
|
if stored:
|
||||||
|
text=normalize_spaced_text(stored)
|
||||||
|
if len(text)>settings.max_resume_chars:
|
||||||
|
text=text[:settings.max_resume_chars]
|
||||||
|
if not source.get("sha256"):
|
||||||
|
source["sha256"]=hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
if not source.get("candidate_email"):
|
||||||
|
detected,_=extract_candidate_email(text)
|
||||||
|
source["candidate_email"]=detected
|
||||||
|
resume=ExtractedResume(
|
||||||
|
filename=source["safe_name"],
|
||||||
|
candidate_id=str(source.get("inbox_message_id") or source.get("manual_upload_candidate_id") or uuid.uuid4()),
|
||||||
|
text=text,
|
||||||
|
page_count=1,
|
||||||
|
truncated=False,
|
||||||
|
)
|
||||||
|
extracted.append((slot,resume))
|
||||||
|
continue
|
||||||
|
data=source.get("data")
|
||||||
|
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
|
||||||
try:
|
try:
|
||||||
resume=await asyncio.to_thread(
|
resume=await asyncio.to_thread(
|
||||||
extract_resume,data,source["safe_name"],settings.max_resume_chars
|
extract_resume,data,source["safe_name"],settings.max_resume_chars
|
||||||
|
|
@ -907,6 +1062,8 @@ class CandidateScoring:
|
||||||
if not source.get("candidate_email"):
|
if not source.get("candidate_email"):
|
||||||
detected,_=extract_candidate_email(resume.text)
|
detected,_=extract_candidate_email(resume.text)
|
||||||
source["candidate_email"]=detected
|
source["candidate_email"]=detected
|
||||||
|
if not source.get("stored_resume_text"):
|
||||||
|
source["stored_resume_text"]=resume.text
|
||||||
except ATSError as exc:
|
except ATSError as exc:
|
||||||
fields_by_slot[slot]=candidate_failed_fields(source,exc.error_code,exc.public_message)
|
fields_by_slot[slot]=candidate_failed_fields(source,exc.error_code,exc.public_message)
|
||||||
continue
|
continue
|
||||||
|
|
@ -925,6 +1082,19 @@ class CandidateScoring:
|
||||||
fields_by_slot[slot]=candidate_failed_fields(source,result.error_code,result.error_message)
|
fields_by_slot[slot]=candidate_failed_fields(source,result.error_code,result.error_message)
|
||||||
return fields_by_slot
|
return fields_by_slot
|
||||||
|
|
||||||
|
async def _persist_extracts(self,sources):
|
||||||
|
"""Write PDF extract onto inbox_messages / manual_upload_candidate, never onto candidates."""
|
||||||
|
for source in sources:
|
||||||
|
extract=(source.get("stored_resume_text") or "").strip()
|
||||||
|
if not extract:
|
||||||
|
continue
|
||||||
|
mid=source.get("inbox_message_id")
|
||||||
|
if mid is not None:
|
||||||
|
await Inbox_Messages.set_resume_text(self.session,mid,extract)
|
||||||
|
uid=source.get("manual_upload_candidate_id")
|
||||||
|
if uid is not None:
|
||||||
|
await Manual_UPLOAD_CANDIDATE.set_full_text(self.session,uid,extract)
|
||||||
|
|
||||||
async def _sync_ats_results(self,source_kind,job,rows,sources,current_user=None,rescan_run_id=None):
|
async def _sync_ats_results(self,source_kind,job,rows,sources,current_user=None,rescan_run_id=None):
|
||||||
run_id=self._as_rescan_run_id(rescan_run_id)
|
run_id=self._as_rescan_run_id(rescan_run_id)
|
||||||
if source_kind=="inbox":
|
if source_kind=="inbox":
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- 036_candidate_source_links.sql
|
||||||
|
-- Scored `candidates` rows point at the application that owns the CV text.
|
||||||
|
-- Extract lives on inbox_messages.resume_text / manual_upload_candidate.full_text
|
||||||
|
-- (so Inbox and CV Bank can fetch it). Do not duplicate that blob here.
|
||||||
|
-- Drops resume_text if an earlier draft of this file added it.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
|
||||||
|
ALTER TABLE app.candidates
|
||||||
|
DROP COLUMN IF EXISTS resume_text;
|
||||||
|
|
||||||
|
ALTER TABLE app.candidates
|
||||||
|
ADD COLUMN IF NOT EXISTS inbox_message_id UUID REFERENCES app.inbox_messages (id);
|
||||||
|
|
||||||
|
ALTER TABLE app.candidates
|
||||||
|
ADD COLUMN IF NOT EXISTS manual_upload_candidate_id UUID REFERENCES app.manual_upload_candidate (id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_candidates_inbox_message_id
|
||||||
|
ON app.candidates (inbox_message_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_candidates_manual_upload_candidate_id
|
||||||
|
ON app.candidates (manual_upload_candidate_id);
|
||||||
|
|
@ -6,6 +6,7 @@ from uuid import uuid4
|
||||||
from g_sheet.scoring import serialize_form_ats
|
from g_sheet.scoring import serialize_form_ats
|
||||||
from inbox.serializers import serialize_ats_result, serialize_inbox_rescan_run
|
from inbox.serializers import serialize_ats_result, serialize_inbox_rescan_run
|
||||||
from job.candidate.plugins import candidate_completed_fields, candidate_failed_fields
|
from job.candidate.plugins import candidate_completed_fields, candidate_failed_fields
|
||||||
|
from job.candidate.serializers import serialize_candidate
|
||||||
|
|
||||||
|
|
||||||
def test_failed_fields_omit_professional_summary():
|
def test_failed_fields_omit_professional_summary():
|
||||||
|
|
@ -29,6 +30,38 @@ def test_completed_fields_include_professional_summary():
|
||||||
source = {"safe_name": "a.pdf", "file_path": None, "sha256": "x"}
|
source = {"safe_name": "a.pdf", "file_path": None, "sha256": "x"}
|
||||||
fields = candidate_completed_fields(source, result)
|
fields = candidate_completed_fields(source, result)
|
||||||
assert fields["professional_summary"] == "Python backend specialist in Engineering."
|
assert fields["professional_summary"] == "Python backend specialist in Engineering."
|
||||||
|
assert "resume_text" not in fields
|
||||||
|
assert "inbox_message_id" not in fields
|
||||||
|
assert "manual_upload_candidate_id" not in fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_fields_link_inbox_and_manual_ids():
|
||||||
|
result = SimpleNamespace(
|
||||||
|
candidate_name="Ada",
|
||||||
|
job_title="Engineer",
|
||||||
|
current_company="Acme",
|
||||||
|
years_experience=6,
|
||||||
|
match_score=82,
|
||||||
|
matched_keywords=["Python"],
|
||||||
|
missing_keywords=["AWS"],
|
||||||
|
summary_critique="Strong Python, missing cloud.",
|
||||||
|
professional_summary="Python backend specialist in Engineering.",
|
||||||
|
)
|
||||||
|
inbox_id = uuid4()
|
||||||
|
manual_id = uuid4()
|
||||||
|
inbox_fields = candidate_completed_fields(
|
||||||
|
{"safe_name": "a.pdf", "file_path": None, "sha256": "x", "inbox_message_id": inbox_id},
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
assert inbox_fields["inbox_message_id"] == inbox_id
|
||||||
|
assert "manual_upload_candidate_id" not in inbox_fields
|
||||||
|
assert "resume_text" not in inbox_fields
|
||||||
|
manual_fields = candidate_completed_fields(
|
||||||
|
{"safe_name": "a.pdf", "file_path": None, "sha256": "x", "manual_upload_candidate_id": manual_id},
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
assert manual_fields["manual_upload_candidate_id"] == manual_id
|
||||||
|
assert "inbox_message_id" not in manual_fields
|
||||||
|
|
||||||
|
|
||||||
def test_serialize_ats_result_includes_summary():
|
def test_serialize_ats_result_includes_summary():
|
||||||
|
|
@ -81,3 +114,39 @@ def test_serialize_inbox_rescan_run_includes_summaries():
|
||||||
assert payload["summaries"] == [entry]
|
assert payload["summaries"] == [entry]
|
||||||
assert payload["id"] == str(run_id)
|
assert payload["id"] == str(run_id)
|
||||||
assert payload["done_count"] == 1
|
assert payload["done_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_candidate_links_source_tables_not_resume_text():
|
||||||
|
inbox_id = uuid4()
|
||||||
|
row = SimpleNamespace(
|
||||||
|
id=uuid4(),
|
||||||
|
job_id=uuid4(),
|
||||||
|
source="inbox",
|
||||||
|
filename="a.pdf",
|
||||||
|
file_path=None,
|
||||||
|
content_sha256="x",
|
||||||
|
candidate_email="ada@example.com",
|
||||||
|
candidate_name="Ada",
|
||||||
|
job_title="Engineer",
|
||||||
|
current_company="Acme",
|
||||||
|
years_experience=6,
|
||||||
|
match_score=82,
|
||||||
|
matched_keywords=["Python"],
|
||||||
|
missing_keywords=["AWS"],
|
||||||
|
summary_critique="Strong Python.",
|
||||||
|
professional_summary="Python backend in Engineering.",
|
||||||
|
linkedin_url=None,
|
||||||
|
inbox_message_id=inbox_id,
|
||||||
|
manual_upload_candidate_id=None,
|
||||||
|
status="completed",
|
||||||
|
error_code=None,
|
||||||
|
error_message=None,
|
||||||
|
model="gpt-test",
|
||||||
|
created_by=uuid4(),
|
||||||
|
created_at=None,
|
||||||
|
updated_at=None,
|
||||||
|
)
|
||||||
|
payload = serialize_candidate(row)
|
||||||
|
assert payload["inbox_message_id"] == str(inbox_id)
|
||||||
|
assert payload["manual_upload_candidate_id"] is None
|
||||||
|
assert "resume_text" not in payload
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,21 @@ export function scoreInbox(jobId, messageIds) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-score one candidate from stored professional_summary (no PDF re-read).
|
||||||
|
* Needs candidates.create. Pass the profile userId, or an inbox / manual id.
|
||||||
|
*/
|
||||||
|
export function rerunAts({ userId, inboxMessageId, manualUploadCandidateId } = {}) {
|
||||||
|
return request('/candidate/ats-rerun', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
user_id: userId || null,
|
||||||
|
inbox_message_id: inboxMessageId || null,
|
||||||
|
manual_upload_candidate_id: manualUploadCandidateId || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared snake_case → camelCase view-model mapper for candidate rows, so the
|
* Shared snake_case → camelCase view-model mapper for candidate rows, so the
|
||||||
* three candidate screens agree on field names. Fields the backend does not
|
* three candidate screens agree on field names. Fields the backend does not
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ function useCandidateDetail(userId) {
|
||||||
* scorecard both land through the same refetch rather than through hand-patched
|
* scorecard both land through the same refetch rather than through hand-patched
|
||||||
* cache entries that could drift from the server's view.
|
* cache entries that could drift from the server's view.
|
||||||
*/
|
*/
|
||||||
function useProfileWrite({ userId, mutationFn, success, onDone }) {
|
function useProfileWrite({ userId, mutationFn, success, error, onDone }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|
@ -95,7 +95,7 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) {
|
||||||
toast(typeof success === 'function' ? success(vars) : success, 'success')
|
toast(typeof success === 'function' ? success(vars) : success, 'success')
|
||||||
onDone?.()
|
onDone?.()
|
||||||
},
|
},
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not save. Please try again.'), 'error'),
|
onError: (err) => toast(friendlyAuthError(err, error || 'Could not save. Please try again.'), 'error'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,14 +165,24 @@ export default function CandidateProfile({
|
||||||
success: (next) => `Rating saved — ${next}/5`,
|
success: (next) => `Rating saved — ${next}/5`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Score the candidate's inbox CV against the assigned job with the ATS engine.
|
const qc = useQueryClient()
|
||||||
// Needs both an assigned job (what to score against) and a message (whose
|
|
||||||
// attachment to score); the refetch lands the new ai_score in this modal.
|
// Re-score from stored professional_summary (candidates table), not a PDF re-read.
|
||||||
const canScoreAts = isLive && Boolean(live?.assigned_job_post_id && live?.message_id)
|
const canRerunAts = isLive && Boolean(live) && (can('candidates.create') || can('candidates.edit'))
|
||||||
const scoreAts = useProfileWrite({
|
const rerunAts = useProfileWrite({
|
||||||
userId: c.userId,
|
userId: c.userId,
|
||||||
mutationFn: () => candidatesApi.scoreInbox(live.assigned_job_post_id, [live.message_id]),
|
mutationFn: () => candidatesApi.rerunAts({
|
||||||
success: 'CV scored against the assigned job',
|
userId: c.userId,
|
||||||
|
inboxMessageId: live?.message_id || undefined,
|
||||||
|
manualUploadCandidateId: live?.manual_upload_candidate_id || undefined,
|
||||||
|
}),
|
||||||
|
success: 'ATS match updated from the stored summary',
|
||||||
|
error: 'Could not run ATS match. Please try again.',
|
||||||
|
onDone: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const title = live?.job_title || c.currentTitle
|
const title = live?.job_title || c.currentTitle
|
||||||
|
|
@ -189,9 +199,6 @@ export default function CandidateProfile({
|
||||||
? KANBAN_ORDER[stageIdx + 1]
|
? KANBAN_ORDER[stageIdx + 1]
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// The REAL stage move (PATCH /candidate/stage) — the seed-only onAdvance walk
|
|
||||||
// is kept for seed candidates only. Requires pipeline.edit server-side.
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const advanceLive = useProfileWrite({
|
const advanceLive = useProfileWrite({
|
||||||
userId: c.userId,
|
userId: c.userId,
|
||||||
mutationFn: () => pipelineApi.changeStage({
|
mutationFn: () => pipelineApi.changeStage({
|
||||||
|
|
@ -239,28 +246,31 @@ export default function CandidateProfile({
|
||||||
|
|
||||||
const actions = isManager ? null : (
|
const actions = isManager ? null : (
|
||||||
<>
|
<>
|
||||||
<button
|
<div className="flex gap-8" style={{ marginRight: 'auto', flexWrap: 'wrap' }}>
|
||||||
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
|
|
||||||
style={{ marginRight: 'auto' }}
|
|
||||||
disabled={isLive && (setFavorite.isPending || !live)}
|
|
||||||
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
|
|
||||||
>
|
|
||||||
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
|
|
||||||
</button>
|
|
||||||
{canScoreAts && (
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
|
||||||
disabled={scoreAts.isPending}
|
disabled={isLive && (setFavorite.isPending || !live)}
|
||||||
onClick={() => scoreAts.mutate()}
|
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
|
||||||
>
|
>
|
||||||
<Icon name="sparkles" /> {scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
|
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
{isLive && (
|
||||||
{onAtsMatch && (
|
<button
|
||||||
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
|
className="btn btn-secondary"
|
||||||
<Icon name="target" /> ATS Match
|
disabled={!live || rerunAts.isPending || !canRerunAts}
|
||||||
</button>
|
title={
|
||||||
)}
|
!can('candidates.create') && !can('candidates.edit')
|
||||||
|
? 'Needs candidates.create'
|
||||||
|
: !live?.assigned_job_post_id && !(live?.suggested_job_post_ids || []).length && !live?.manual_upload_candidate_id
|
||||||
|
? 'Assign a job before running ATS'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => rerunAts.mutate()}
|
||||||
|
>
|
||||||
|
<Icon name="target" /> {rerunAts.isPending ? 'Matching…' : 'ATS Match'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{isLive ? (
|
{isLive ? (
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|
|
||||||
|
|
@ -740,7 +740,6 @@ function RecruiterCandidates() {
|
||||||
onClose={() => setProfileFor(null)}
|
onClose={() => setProfileFor(null)}
|
||||||
onAdvance={advance}
|
onAdvance={advance}
|
||||||
onToggleFav={toggleFav}
|
onToggleFav={toggleFav}
|
||||||
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2917,6 +2917,14 @@ function ApplicationDetail({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{resumeText ? (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div className="fw-600" style={{ marginBottom: 6 }}>Extracted CV</div>
|
||||||
|
<pre className="resume-thumb is-full" style={{ maxHeight: 280, overflow: 'auto' }}>
|
||||||
|
{resumeText}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue