advanced to openai

pull/90/head
ahmed.mujtaba 2026-09-10 17:24:22 +05:00
parent 87282328f6
commit 79a479ba36
12 changed files with 422 additions and 51 deletions

View File

@ -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)

View File

@ -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),

View File

@ -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,

View File

@ -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):

View File

@ -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,

View File

@ -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":

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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) }}
/> />
)} )}

View File

@ -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 }}>