From 79a479ba3697af2e48b3d1b9f416424cff07989c Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 10 Sep 2026 17:24:22 +0500 Subject: [PATCH] advanced to openai --- backend/inbox/models.py | 17 ++ backend/job/app.py | 28 +++ backend/job/candidate/models.py | 28 ++- backend/job/candidate/plugins.py | 10 +- backend/job/candidate/serializers.py | 2 + backend/job/candidate/views.py | 202 ++++++++++++++++-- .../manual/036_candidate_source_links.sql | 21 ++ backend/tests/test_professional_summary.py | 69 ++++++ frontend/src/api/candidates.js | 15 ++ frontend/src/screens/CandidateProfile.jsx | 72 ++++--- frontend/src/screens/Candidates.jsx | 1 - frontend/src/screens/Inbox.jsx | 8 + 12 files changed, 422 insertions(+), 51 deletions(-) create mode 100644 backend/migrations/manual/036_candidate_source_links.sql diff --git a/backend/inbox/models.py b/backend/inbox/models.py index bcd4048..871fd89 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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) diff --git a/backend/job/app.py b/backend/job/app.py index 31ff34c..0f4eec9 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 5962b09..e1d56de 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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, diff --git a/backend/job/candidate/plugins.py b/backend/job/candidate/plugins.py index 4c35902..39b22a3 100644 --- a/backend/job/candidate/plugins.py +++ b/backend/job/candidate/plugins.py @@ -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): diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 0cdd6a7..f00bc98 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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, diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index f8bf6be..37ca323 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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 idxsettings.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": diff --git a/backend/migrations/manual/036_candidate_source_links.sql b/backend/migrations/manual/036_candidate_source_links.sql new file mode 100644 index 0000000..679487f --- /dev/null +++ b/backend/migrations/manual/036_candidate_source_links.sql @@ -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); diff --git a/backend/tests/test_professional_summary.py b/backend/tests/test_professional_summary.py index a56d243..b40db76 100644 --- a/backend/tests/test_professional_summary.py +++ b/backend/tests/test_professional_summary.py @@ -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 diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index c5f005c..4249b67 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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 diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 35ec9dc..cd4ff2e 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -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 : ( <> - - {canScoreAts && ( +
- )} - {onAtsMatch && ( - - )} + {isLive && ( + + )} +
{isLive ? (