Merge pull request 'SQS_BROKER' (#90) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 36s Details

Reviewed-on: #90
pull/91/head^2
ahmed.mujtaba 2026-09-10 12:26:45 +00:00
commit f8cd8d26f2
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)
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
async def set_professional_summary(cls, session: AsyncSession, record_id, summary):
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
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")
async def score_candidates(
job_id: str = Form(...),
@ -767,6 +773,28 @@ async def score_inbox_candidates(
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")
async def fetch_scored_candidates(
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))
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
async def set_professional_summary(cls, session: AsyncSession, record_id, summary):
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)
# Public LinkedIn URL extracted from the scored CV. Fetch reads this column.
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"
error_code: str | None = Field(default=None)
@ -1140,8 +1164,8 @@ class Candidates(SQLModel, table=True):
"source": "ats",
"email": email,
"inbox_id": None,
"message_id": None,
"manual_upload_candidate_id": None,
"message_id": str(rec.inbox_message_id) if getattr(rec, "inbox_message_id", None) else 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,
"candidate_id": str(rec.id),
"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):
return {
fields = {
"filename": source["safe_name"],
"file_path": source["file_path"],
"content_sha256": source["sha256"],
"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):

View File

@ -44,6 +44,8 @@ def serialize_candidate(row) -> dict:
"summary_critique": row.summary_critique,
"professional_summary": row.professional_summary 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,
"error_code": row.error_code,
"error_message": row.error_message,

View File

@ -7,7 +7,7 @@ from fastapi import HTTPException
from pypdf import PdfReader
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.pdf import ExtractedResume,extract_resume,sanitize_filename
from app.services.scoring import score_batch
from inbox.models import Inbox_Messages,Inbox,Inbox_Message_Triage,InboxRescanRun,AtsResults
from job.candidate.models import Candidates
@ -705,6 +705,7 @@ class CandidateScoring:
if not row.file_path:
continue
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()):
name=names[idx] if idx<len(names) else Path(path_str.replace("\\","/")).name or "resume.pdf"
source={
@ -716,6 +717,9 @@ class CandidateScoring:
"professional_summary":None,
"precheck":None,
}
if stored:
source["resume_text"]=stored
source["stored_resume_text"]=stored
source["professional_summary"]=await self._professional_summary_for_email(
source["candidate_email"],row.professional_summary,
)
@ -723,6 +727,9 @@ class CandidateScoring:
if source["precheck"] is not None:
sources.append(source)
continue
if stored:
sources.append(source)
continue
lower=name.lower()
if lower.endswith(".doc") or lower.endswith(".docx"):
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"DOC/DOCX extraction is not supported yet.")
@ -785,6 +792,10 @@ class CandidateScoring:
"professional_summary":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["candidate_email"],row.professional_summary,
)
@ -792,6 +803,9 @@ class CandidateScoring:
if source["precheck"] is not None:
sources.append(source)
continue
if stored:
sources.append(source)
continue
file_row=await CvBankFiles.get(self.session,row.id)
data=file_row.data if file_row and file_row.data else None
if data is None and source["file_path"]:
@ -828,7 +842,127 @@ class CandidateScoring:
data=serialize_candidate(row)
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)
if job is None or job.is_deleted:
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.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)
# 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).
for slot,source in enumerate(sources):
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()
if not email:
continue
manual=await Manual_UPLOAD_CANDIDATE.get_by_email_and_job(self.session,email,job.id)
if manual and (manual.file_path or "").strip():
fields["file_path"]=manual.file_path.strip()
source["file_path"]=manual.file_path.strip()
if email and not source.get("manual_upload_candidate_id") and not fields.get("manual_upload_candidate_id"):
manual=await Manual_UPLOAD_CANDIDATE.get_by_email_and_job(self.session,email,job.id)
if manual:
source["manual_upload_candidate_id"]=manual.id
fields["manual_upload_candidate_id"]=manual.id
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={
"job_id":job.id,
"source":source_kind,
@ -888,17 +1024,36 @@ class CandidateScoring:
async def _score_sources(self,sources,jd,settings):
# 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={}
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:
source["safe_name"]=sanitize_filename(source.get("filename") or "resume.pdf")
if source.get("precheck") is not None:
code,message=source["precheck"]
fields_by_slot[slot]=candidate_failed_fields(source,code,message)
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:
resume=await asyncio.to_thread(
extract_resume,data,source["safe_name"],settings.max_resume_chars
@ -907,6 +1062,8 @@ class CandidateScoring:
if not source.get("candidate_email"):
detected,_=extract_candidate_email(resume.text)
source["candidate_email"]=detected
if not source.get("stored_resume_text"):
source["stored_resume_text"]=resume.text
except ATSError as exc:
fields_by_slot[slot]=candidate_failed_fields(source,exc.error_code,exc.public_message)
continue
@ -925,6 +1082,19 @@ class CandidateScoring:
fields_by_slot[slot]=candidate_failed_fields(source,result.error_code,result.error_message)
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):
run_id=self._as_rescan_run_id(rescan_run_id)
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 inbox.serializers import serialize_ats_result, serialize_inbox_rescan_run
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():
@ -29,6 +30,38 @@ def test_completed_fields_include_professional_summary():
source = {"safe_name": "a.pdf", "file_path": None, "sha256": "x"}
fields = candidate_completed_fields(source, result)
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():
@ -81,3 +114,39 @@ def test_serialize_inbox_rescan_run_includes_summaries():
assert payload["summaries"] == [entry]
assert payload["id"] == str(run_id)
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
* 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
* 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 { toast } = useToast()
return useMutation({
@ -95,7 +95,7 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) {
toast(typeof success === 'function' ? success(vars) : success, 'success')
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`,
})
// Score the candidate's inbox CV against the assigned job with the ATS engine.
// 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.
const canScoreAts = isLive && Boolean(live?.assigned_job_post_id && live?.message_id)
const scoreAts = useProfileWrite({
const qc = useQueryClient()
// Re-score from stored professional_summary (candidates table), not a PDF re-read.
const canRerunAts = isLive && Boolean(live) && (can('candidates.create') || can('candidates.edit'))
const rerunAts = useProfileWrite({
userId: c.userId,
mutationFn: () => candidatesApi.scoreInbox(live.assigned_job_post_id, [live.message_id]),
success: 'CV scored against the assigned job',
mutationFn: () => candidatesApi.rerunAts({
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
@ -189,9 +199,6 @@ export default function CandidateProfile({
? KANBAN_ORDER[stageIdx + 1]
: 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({
userId: c.userId,
mutationFn: () => pipelineApi.changeStage({
@ -239,28 +246,31 @@ export default function CandidateProfile({
const actions = isManager ? null : (
<>
<button
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 && (
<div className="flex gap-8" style={{ marginRight: 'auto', flexWrap: 'wrap' }}>
<button
className="btn btn-secondary"
disabled={scoreAts.isPending}
onClick={() => scoreAts.mutate()}
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
disabled={isLive && (setFavorite.isPending || !live)}
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
>
<Icon name="sparkles" /> {scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
</button>
)}
{onAtsMatch && (
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
)}
{isLive && (
<button
className="btn btn-secondary"
disabled={!live || rerunAts.isPending || !canRerunAts}
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 ? (
<button
className="btn btn-primary"

View File

@ -740,7 +740,6 @@ function RecruiterCandidates() {
onClose={() => setProfileFor(null)}
onAdvance={advance}
onToggleFav={toggleFav}
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
/>
)}

View File

@ -2917,6 +2917,14 @@ function ApplicationDetail({
)}
</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 role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>