commit
87e0d388b1
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from shlex import join
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
@ -46,23 +47,14 @@ class Inbox(SQLModel, table=True):
|
||||||
message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
|
message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
|
||||||
messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox")
|
messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox")
|
||||||
|
|
||||||
# tz-AWARE, matching every other timestamp the analytics layer filters on.
|
|
||||||
# A naive column here made asyncpg reject the aware UTC bounds that
|
|
||||||
# analytics/views.py builds, so /analytics/hiring-trend and /analytics/kpis
|
|
||||||
# both 500'd before the query ever reached Postgres.
|
|
||||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
favorite: Optional[bool] = Field(default=False)
|
favorite: Optional[bool] = Field(default=False)
|
||||||
rating: Optional[float] = Field(default=0.0)
|
rating: Optional[float] = Field(default=0.0)
|
||||||
|
|
||||||
# The CURRENT ats_results row for this application. Written together with the
|
|
||||||
# supersede chain in CandidateScoring._sync_inbox_ats: every completed inbox
|
|
||||||
# score inserts an ats_results row and repoints this at it, so the score is
|
|
||||||
# one direct id join away instead of a filter on ats_results.is_current.
|
|
||||||
ats_id: uuid.UUID | None = Field(default=None, foreign_key="ats_results.id")
|
ats_id: uuid.UUID | None = Field(default=None, foreign_key="ats_results.id")
|
||||||
|
|
||||||
# selectin on one-to-many: joined would repeat the inbox row per child
|
|
||||||
interviews: List["Interviews"] = Relationship(
|
interviews: List["Interviews"] = Relationship(
|
||||||
back_populates="inbox",
|
back_populates="inbox",
|
||||||
sa_relationship_kwargs={"lazy": "selectin"},
|
sa_relationship_kwargs={"lazy": "selectin"},
|
||||||
|
|
@ -80,6 +72,107 @@ class Inbox(SQLModel, table=True):
|
||||||
sa_relationship_kwargs={"lazy": "joined"},
|
sa_relationship_kwargs={"lazy": "joined"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_all(cls,session:AsyncSession,job_post_id=None,limit=None,offset=0):
|
||||||
|
try:
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
qry=(
|
||||||
|
select(
|
||||||
|
cls.id.label("inbox_id"),
|
||||||
|
cls.user_id,
|
||||||
|
Users.name,
|
||||||
|
Users.email,
|
||||||
|
Inbox_Messages.candidate_phone_number.label("phone"),
|
||||||
|
Inbox_Messages.assigned_job_post_id,
|
||||||
|
Inbox_Messages.application_status,
|
||||||
|
Inbox_Messages.current_employment,
|
||||||
|
Inbox_Messages.current_title,
|
||||||
|
Inbox_Messages.experience,
|
||||||
|
cls.created_at,
|
||||||
|
JobPosts.title,
|
||||||
|
AtsResults.id.label("ats_result_id"),
|
||||||
|
AtsResults.overall_score,
|
||||||
|
AtsResults.band,
|
||||||
|
AtsResults.job_post_id.label("ats_job_post_id"),
|
||||||
|
AtsResults.computed_at,
|
||||||
|
AtsResults.candidate_id,
|
||||||
|
AtsResults.user_id.label("ats_user_id"),
|
||||||
|
)
|
||||||
|
.join(Users,cls.user_id==Users.id)
|
||||||
|
.join(Roles,Users.role_id==Roles.id)
|
||||||
|
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
|
||||||
|
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||||
|
.outerjoin(AtsResults,cls.ats_id==AtsResults.id)
|
||||||
|
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
||||||
|
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
)
|
||||||
|
if job_post_id:
|
||||||
|
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
|
||||||
|
if limit is not None:
|
||||||
|
qry=qry.limit(limit).offset(offset)
|
||||||
|
result=await session.execute(qry)
|
||||||
|
rows=[]
|
||||||
|
for row in result.mappings().all():
|
||||||
|
ats=None
|
||||||
|
if row["ats_result_id"] is not None:
|
||||||
|
ats={
|
||||||
|
"id":str(row["ats_result_id"]),
|
||||||
|
"overall_score":row["overall_score"],
|
||||||
|
"band":row["band"] or None,
|
||||||
|
"job_post_id":str(row["ats_job_post_id"]) if row["ats_job_post_id"] else None,
|
||||||
|
"computed_at":row["computed_at"].isoformat() if row["computed_at"] else None,
|
||||||
|
"candidate_id":str(row["candidate_id"]) if row["candidate_id"] else None,
|
||||||
|
"user_id":str(row["ats_user_id"]) if row["ats_user_id"] else None,
|
||||||
|
}
|
||||||
|
status=row["application_status"]
|
||||||
|
rows.append({
|
||||||
|
"inbox_id":row["inbox_id"],
|
||||||
|
"user_id":str(row["user_id"]) if row["user_id"] else None,
|
||||||
|
"name":row["name"],
|
||||||
|
"email":row["email"],
|
||||||
|
"application_status":status.value if status else None,
|
||||||
|
"phone":row["phone"],
|
||||||
|
"assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
|
||||||
|
"title":row["title"] or None,
|
||||||
|
"current_employment":row["current_employment"] or None,
|
||||||
|
"current_title":row["current_title"] or None,
|
||||||
|
"experience":row["experience"] or None,
|
||||||
|
"created_at":row["created_at"].isoformat() if row["created_at"] else None,
|
||||||
|
"ats_result":ats,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_by_status(cls,session:AsyncSession,job_post_id=None):
|
||||||
|
try:
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
qry=(
|
||||||
|
select(Inbox_Messages.application_status,func.count())
|
||||||
|
.select_from(cls)
|
||||||
|
.join(Users,cls.user_id==Users.id)
|
||||||
|
.join(Roles,Users.role_id==Roles.id)
|
||||||
|
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
|
||||||
|
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||||
|
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
||||||
|
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
||||||
|
.group_by(Inbox_Messages.application_status)
|
||||||
|
)
|
||||||
|
if job_post_id:
|
||||||
|
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
|
||||||
|
result=await session.execute(qry)
|
||||||
|
counts={}
|
||||||
|
for status,n in result.all():
|
||||||
|
key=status.value if hasattr(status,"value") else (str(status) if status else None)
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
counts[key]=int(n or 0)
|
||||||
|
return counts
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _candidate_search_filter(cls, search: str):
|
def _candidate_search_filter(cls, search: str):
|
||||||
pattern = f"%{search}%"
|
pattern = f"%{search}%"
|
||||||
|
|
@ -507,6 +600,18 @@ class Inbox_Messages(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_ats_score(cls, session: AsyncSession, record_id, score, band):
|
||||||
|
row = await cls.get_inbox_message_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.ats_score = float(score)
|
||||||
|
row.ats_band = band or ""
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def set_assigned_job_post(cls, session: AsyncSession, record_id, job_post_id):
|
async def set_assigned_job_post(cls, session: AsyncSession, record_id, job_post_id):
|
||||||
"""Set or clear assigned_job_post_id; returns the row or None if missing."""
|
"""Set or clear assigned_job_post_id; returns the row or None if missing."""
|
||||||
|
|
@ -608,9 +713,11 @@ class AtsResults(SQLModel, table=True):
|
||||||
|
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
# Inbox applications link here; NULL for upload-sourced scores, which have no
|
# Inbox applications link here; NULL for upload-sourced scores, which have no
|
||||||
# inbox row. candidate_id links every score to the candidates row it scored.
|
# inbox row. Identity is XOR: matching users.email -> user_id (candidate_id
|
||||||
|
# NULL); otherwise candidate_id (user_id NULL). Blank email is the latter.
|
||||||
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
|
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
|
||||||
candidate_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="candidates.id")
|
candidate_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="candidates.id")
|
||||||
|
user_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="users.id")
|
||||||
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
||||||
overall_score: float = Field(default=0.0)
|
overall_score: float = Field(default=0.0)
|
||||||
band: str = Field(default="")
|
band: str = Field(default="")
|
||||||
|
|
@ -641,6 +748,41 @@ class AtsResults(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_for_inbox_job(cls, session: AsyncSession, inbox_id, job_post_id):
|
||||||
|
"""Any score for this application against this job — current or superseded.
|
||||||
|
|
||||||
|
Inbox-side idempotency is (inbox_id, job_post_id) only. candidate_id may
|
||||||
|
be NULL when the CV email matched a user; do not key off it here.
|
||||||
|
"""
|
||||||
|
jid = cls._as_uuid(job_post_id)
|
||||||
|
if jid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.inbox_id == int(inbox_id), cls.job_post_id == jid)
|
||||||
|
.order_by(cls.computed_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def resolve_identity(cls, session: AsyncSession, email, candidate_id):
|
||||||
|
"""XOR identity for a score row from the scored candidate's email.
|
||||||
|
|
||||||
|
Matching users.email (case-insensitive) -> user_id, candidate_id NULL.
|
||||||
|
Missing/blank email or no user -> candidate_id, user_id NULL.
|
||||||
|
"""
|
||||||
|
normalized = (email or "").strip().lower()
|
||||||
|
if normalized:
|
||||||
|
user_id = (
|
||||||
|
await session.execute(
|
||||||
|
select(Users.id).where(func.lower(Users.email) == normalized)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user_id is not None:
|
||||||
|
return {"candidate_id": None, "user_id": user_id}
|
||||||
|
return {"candidate_id": candidate_id, "user_id": None}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_current_for_candidate(cls, session: AsyncSession, candidate_id):
|
async def get_current_for_candidate(cls, session: AsyncSession, candidate_id):
|
||||||
"""Current row for an upload-sourced score, chained per candidates row —
|
"""Current row for an upload-sourced score, chained per candidates row —
|
||||||
|
|
@ -653,9 +795,52 @@ class AtsResults(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_current_for_user(cls, session: AsyncSession, user_id, job_post_id=None):
|
||||||
|
"""Current upload-sourced score for a matched user, scoped per job."""
|
||||||
|
uid = cls._as_uuid(user_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
qry = select(cls).where(cls.user_id == uid, cls.is_current == True) # noqa: E712
|
||||||
|
jid = cls._as_uuid(job_post_id) if job_post_id is not None else None
|
||||||
|
if jid is not None:
|
||||||
|
qry = qry.where(cls.job_post_id == jid)
|
||||||
|
result = await session.execute(qry.order_by(cls.computed_at.desc()))
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def insert_result(cls, session: AsyncSession, fields: dict):
|
async def insert_result(cls, session: AsyncSession, fields: dict):
|
||||||
|
"""Insert a score row and supersede the previous current one.
|
||||||
|
|
||||||
|
Inbox scores chain on inbox_id and repoint inbox.ats_id (candidate_id
|
||||||
|
may be NULL). Upload scores chain on candidate_id, or on (user_id,
|
||||||
|
job_post_id) when identity resolved to a user. Flush the INSERT first:
|
||||||
|
with no relationship() edge the unit of work emits the UPDATEs first,
|
||||||
|
and the FKs reject a pointer to a row not yet inserted."""
|
||||||
|
inbox_id = fields.get("inbox_id")
|
||||||
|
candidate_id = fields.get("candidate_id")
|
||||||
|
user_id = fields.get("user_id")
|
||||||
|
job_post_id = fields.get("job_post_id")
|
||||||
|
if inbox_id is not None:
|
||||||
|
prev = await cls.get_current_for_inbox(session, inbox_id)
|
||||||
|
elif candidate_id is not None:
|
||||||
|
prev = await cls.get_current_for_candidate(session, candidate_id)
|
||||||
|
elif user_id is not None:
|
||||||
|
prev = await cls.get_current_for_user(session, user_id, job_post_id)
|
||||||
|
else:
|
||||||
|
prev = None
|
||||||
row = cls(**fields)
|
row = cls(**fields)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
|
await session.flush()
|
||||||
|
if prev is not None:
|
||||||
|
prev.is_current = False
|
||||||
|
prev.superseded_by_id = row.id
|
||||||
|
session.add(prev)
|
||||||
|
if inbox_id is not None:
|
||||||
|
link = await Inbox.get_inbox_by_id(session, inbox_id)
|
||||||
|
if link is not None:
|
||||||
|
link.ats_id = row.id
|
||||||
|
link.updated_at = _now()
|
||||||
|
session.add(link)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return row
|
return row
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,17 @@ from __future__ import annotations
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
from inbox.models import Inbox_Messages
|
from inbox.models import AtsResults, Inbox, Inbox_Messages
|
||||||
|
from job.candidate.models import Candidates, Manual_UPLOAD_CANDIDATE
|
||||||
from job.candidate.views import FileRead
|
from job.candidate.views import FileRead
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
@ -161,3 +165,128 @@ async def extract_resume_text(file_paths:list[str]) -> tuple[str,str]:
|
||||||
if not texts:
|
if not texts:
|
||||||
return "","; ".join(errors) if errors else "no text extracted from PDF"
|
return "","; ".join(errors) if errors else "no text extracted from PDF"
|
||||||
return "\n\n---\n\n".join(texts),""
|
return "\n\n---\n\n".join(texts),""
|
||||||
|
|
||||||
|
|
||||||
|
def _ats_score_payload(row):
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"overall_score":row.overall_score,
|
||||||
|
"band":row.band or None,
|
||||||
|
"job_post_id":str(row.job_post_id) if row.job_post_id else None,
|
||||||
|
"computed_at":row.computed_at.isoformat() if row.computed_at else None,
|
||||||
|
"candidate_id":str(row.candidate_id) if row.candidate_id else None,
|
||||||
|
"user_id":str(row.user_id) if row.user_id else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ats_score_for_user(session:AsyncSession,user_id,job_post_id=None):
|
||||||
|
"""Current ats_results overall score for a candidate -> dict, or None.
|
||||||
|
|
||||||
|
Prefer ats_results.user_id (CV email matched that user). Fall back to the
|
||||||
|
inbox.user_id join for scores whose email did not match any user, where
|
||||||
|
candidate_id is set and user_id is NULL.
|
||||||
|
|
||||||
|
Pass job_post_id to pin one application when a candidate has several;
|
||||||
|
without it the newest current score across their applications wins.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
uid=uuid.UUID(str(user_id))
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
direct=(
|
||||||
|
select(AtsResults)
|
||||||
|
.where(AtsResults.user_id==uid,AtsResults.is_current==True) # noqa: E712
|
||||||
|
.order_by(AtsResults.computed_at.desc())
|
||||||
|
)
|
||||||
|
if job_post_id:
|
||||||
|
try:
|
||||||
|
direct=direct.where(AtsResults.job_post_id==uuid.UUID(str(job_post_id)))
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
return None
|
||||||
|
row=(await session.execute(direct)).scalars().first()
|
||||||
|
if row is not None:
|
||||||
|
return _ats_score_payload(row)
|
||||||
|
|
||||||
|
qry=(
|
||||||
|
select(AtsResults)
|
||||||
|
.join(Inbox,AtsResults.inbox_id==Inbox.id)
|
||||||
|
.join(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
||||||
|
.where(
|
||||||
|
Inbox.user_id==uid,
|
||||||
|
Inbox_Messages.assigned_job_post_id.is_not(None),
|
||||||
|
AtsResults.job_post_id==Inbox_Messages.assigned_job_post_id,
|
||||||
|
AtsResults.is_current==True, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(AtsResults.computed_at.desc())
|
||||||
|
)
|
||||||
|
if job_post_id:
|
||||||
|
try:
|
||||||
|
qry=qry.where(Inbox_Messages.assigned_job_post_id==uuid.UUID(str(job_post_id)))
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _ats_score_payload((await session.execute(qry)).scalars().first())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ats_score_for_manual_user(session:AsyncSession,user_id,job_post_id=None):
|
||||||
|
"""Current ats_results overall score for an Add Candidate user -> dict, or None.
|
||||||
|
|
||||||
|
Prefer ats_results.user_id (Add Candidate always creates a users row, so a
|
||||||
|
later score against that email lands on user_id). Fall back to the
|
||||||
|
email+candidate_id join for rows written before user_id existed.
|
||||||
|
apply_via=manual_upload is the Add Candidate gate; /import never writes
|
||||||
|
that table.
|
||||||
|
|
||||||
|
Pass job_post_id to pin one application when a candidate has several;
|
||||||
|
without it the newest current score across their applications wins.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
uid=uuid.UUID(str(user_id))
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
direct=(
|
||||||
|
select(AtsResults)
|
||||||
|
.where(AtsResults.user_id==uid,AtsResults.is_current==True) # noqa: E712
|
||||||
|
.order_by(AtsResults.computed_at.desc())
|
||||||
|
)
|
||||||
|
if job_post_id:
|
||||||
|
try:
|
||||||
|
jid=uuid.UUID(str(job_post_id))
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
return None
|
||||||
|
direct=direct.where(AtsResults.job_post_id==jid)
|
||||||
|
row=(await session.execute(direct)).scalars().first()
|
||||||
|
if row is not None:
|
||||||
|
return _ats_score_payload(row)
|
||||||
|
|
||||||
|
qry=(
|
||||||
|
select(AtsResults)
|
||||||
|
.join(Candidates,AtsResults.candidate_id==Candidates.id)
|
||||||
|
.join(
|
||||||
|
Manual_UPLOAD_CANDIDATE,
|
||||||
|
(Candidates.job_id==Manual_UPLOAD_CANDIDATE.job_post_id)
|
||||||
|
&(Candidates.candidate_email==Manual_UPLOAD_CANDIDATE.candidate_email),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Manual_UPLOAD_CANDIDATE.user_id==uid,
|
||||||
|
Manual_UPLOAD_CANDIDATE.apply_via=="manual_upload",
|
||||||
|
Candidates.status=="completed",
|
||||||
|
AtsResults.job_post_id==Manual_UPLOAD_CANDIDATE.job_post_id,
|
||||||
|
AtsResults.is_current==True, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(AtsResults.computed_at.desc())
|
||||||
|
)
|
||||||
|
if job_post_id:
|
||||||
|
try:
|
||||||
|
jid=uuid.UUID(str(job_post_id))
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
return None
|
||||||
|
qry=qry.where(
|
||||||
|
Manual_UPLOAD_CANDIDATE.job_post_id==jid,
|
||||||
|
AtsResults.job_post_id==jid,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _ats_score_payload((await session.execute(qry)).scalars().first())
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,15 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from datetime import datetime,timezone
|
from datetime import datetime,timezone
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from agent.execute_agent import run_agent
|
from agent.execute_agent import run_agent
|
||||||
from db_setup import session_scope
|
from db_setup import session_scope
|
||||||
from employment_agent.execute_agent import run_employment_agent
|
from employment_agent.execute_agent import run_employment_agent
|
||||||
from inbox.models import Inbox_Messages
|
from inbox.models import Inbox_Messages,Inbox,AtsResults
|
||||||
from inbox.plugins import extract_phone,extract_resume_text
|
from inbox.plugins import extract_phone,extract_resume_text
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
from job.job_post.serializers import serialize_job_post
|
from job.job_post.serializers import serialize_job_post
|
||||||
|
|
@ -26,29 +26,27 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
|
||||||
"""ATS-score one inbox CV against one job post — the no-upload path.
|
"""ATS-score one inbox CV against one job post — the no-upload path.
|
||||||
|
|
||||||
The decoded attachment already on disk is the CV; the job post in the
|
The decoded attachment already on disk is the CV; the job post in the
|
||||||
database is the JD. Idempotent: a (message, job) pair with a completed
|
database is the JD. Idempotent: a (message, job) pair with an ats_results
|
||||||
score is never paid for twice; re-runs are a no-op.
|
row is never paid for twice; re-runs are a no-op.
|
||||||
"""
|
"""
|
||||||
# Lazy imports: inbox.plugins imports job.candidate.views, so a top-level
|
# Lazy imports: inbox.plugins imports job.candidate.views, so a top-level
|
||||||
# import here would be circular.
|
# import here would be circular.
|
||||||
from job.candidate.models import Candidates
|
|
||||||
from job.candidate.views import CandidateScoring
|
from job.candidate.views import CandidateScoring
|
||||||
|
|
||||||
mid=Candidates._as_uuid(record_id)
|
try:
|
||||||
jid=Candidates._as_uuid(job_id)
|
mid=uuid.UUID(str(record_id))
|
||||||
if mid is None or jid is None:
|
jid=uuid.UUID(str(job_id))
|
||||||
|
except ValueError:
|
||||||
raise PermanentTaskError("record_id and job_id must be uuids")
|
raise PermanentTaskError("record_id and job_id must be uuids")
|
||||||
|
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
existing=await session.execute(
|
link=await Inbox.get_inbox_by_message_id(session,mid)
|
||||||
select(Candidates).where(
|
if link is not None:
|
||||||
Candidates.inbox_message_id==mid,
|
# (inbox, job) only — candidate_id is NULL when the CV email matched
|
||||||
Candidates.job_id==jid,
|
# a user, so already_scored must not depend on it.
|
||||||
Candidates.status=="completed",
|
existing=await AtsResults.get_for_inbox_job(session,link.id,jid)
|
||||||
)
|
if existing is not None:
|
||||||
)
|
return {"status":"already_scored"}
|
||||||
if existing.scalars().first() is not None:
|
|
||||||
return {"status":"already_scored"}
|
|
||||||
job=await JobPosts.get_job_post_by_id(session,job_id)
|
job=await JobPosts.get_job_post_by_id(session,job_id)
|
||||||
if job is None or job.is_deleted:
|
if job is None or job.is_deleted:
|
||||||
raise PermanentTaskError("job post missing or deleted")
|
raise PermanentTaskError("job post missing or deleted")
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ from dotenv import load_dotenv
|
||||||
from datetime import datetime, time, timezone
|
from datetime import datetime, time, timezone
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -89,7 +90,8 @@ class FeedbackUpdate(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class StageChange(BaseModel):
|
class StageChange(BaseModel):
|
||||||
inbox_id: int
|
inbox_id: int | None = None
|
||||||
|
manual_upload_id: UUID | None = None
|
||||||
to_stage: str
|
to_stage: str
|
||||||
change_reason: str | None = None
|
change_reason: str | None = None
|
||||||
|
|
||||||
|
|
@ -666,7 +668,8 @@ async def change_candidate_stage(
|
||||||
try:
|
try:
|
||||||
service=Pipeline(session=session)
|
service=Pipeline(session=session)
|
||||||
data=await service.change_stage(
|
data=await service.change_stage(
|
||||||
payload.inbox_id,payload.to_stage,current_user,change_reason=payload.change_reason,
|
payload.to_stage,current_user,inbox_id=payload.inbox_id,
|
||||||
|
manual_upload_id=payload.manual_upload_id,change_reason=payload.change_reason,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -674,17 +677,52 @@ async def change_candidate_stage(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/pipeline/candidates/fetch")
|
||||||
|
async def fetch_pipeline_candidates(
|
||||||
|
job_post_id:Optional[uuid.UUID]=Query(None),
|
||||||
|
limit:int=Query(200,ge=1,le=1000),
|
||||||
|
offset:int=Query(0,ge=0),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Pipeline(session=session)
|
||||||
|
result=await service.get_all(job_post_id=job_post_id,limit=limit,offset=offset)
|
||||||
|
return JSONResponse(content={**result,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/candidate/score/fetch")
|
||||||
|
async def fetch_pipeline_candidate_score(
|
||||||
|
user_id:uuid.UUID=Query(...),
|
||||||
|
job_post_id:uuid.UUID=Query(...),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Pipeline(session=session)
|
||||||
|
data=await service.get_pipeline_candidates(user_id=user_id,job_post_id=job_post_id)
|
||||||
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pipeline/transitions/fetch")
|
@router.get("/pipeline/transitions/fetch")
|
||||||
async def fetch_pipeline_transitions(
|
async def fetch_pipeline_transitions(
|
||||||
transition_id:str=Query(None),
|
transition_id:str=Query(None),
|
||||||
inbox_id:int=Query(None),
|
inbox_id:int=Query(None),
|
||||||
|
manual_upload_id:UUID=Query(None),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Pipeline(session=session)
|
service=Pipeline(session=session)
|
||||||
data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id)
|
data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id,manual_upload_id=manual_upload_id)
|
||||||
total=1 if isinstance(data,dict) else len(data)
|
total=1 if isinstance(data,dict) else len(data)
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import JSON, DateTime, func, UniqueConstraint
|
from sqlalchemy import JSON, DateTime, func, UniqueConstraint
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -35,33 +36,122 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
# from job_posts.title — that is the role they applied to, not their own.
|
# from job_posts.title — that is the role they applied to, not their own.
|
||||||
# Same ALTER-on-a-populated-table reasoning as referral_by below.
|
# Same ALTER-on-a-populated-table reasoning as referral_by below.
|
||||||
current_position: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
current_position: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
|
# Add Candidate only (POST /candidate/create/candidate). /import never writes this table.
|
||||||
|
apply_via:Optional[str]=Field(nullable=True)
|
||||||
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||||
platform: str = Field(default="")
|
platform: str = Field(default="")
|
||||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||||
experience: str = Field(default="")
|
experience: str = Field(default="")
|
||||||
|
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Applied.
|
||||||
status: str = Field(default="")
|
status: str = Field(default="")
|
||||||
# Free text, not a users FK: a referrer is often someone outside the system
|
# Free text, not a users FK: a referrer is often someone outside the system
|
||||||
# (a client, a former colleague), and recruiters type whatever the candidate
|
|
||||||
# told them. "" rather than NULL keeps it consistent with the columns above.
|
|
||||||
#
|
|
||||||
# server_default is load-bearing and NOT decoration, unlike the columns above
|
|
||||||
# — they arrived with the CREATE TABLE, this one arrives as an ALTER. The
|
|
||||||
# startup autogenerate would emit `ADD COLUMN referral_by VARCHAR NOT NULL`,
|
|
||||||
# which Postgres rejects outright on a table that already holds rows. The
|
|
||||||
# DEFAULT backfills them. Pass the bare "" — SQLAlchemy quotes a plain string
|
|
||||||
# into DEFAULT '', whereas "''" would render DEFAULT '''''' instead.
|
|
||||||
referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
# The CV as uploaded: file_name is the recruiter-facing original, file_path
|
|
||||||
# the absolute location under inbox/decoded_attachments. They differ on
|
|
||||||
# purpose — the stored basename is uniquified so two candidates uploading
|
|
||||||
# "resume.pdf" cannot overwrite one another (see FileRead.save_manual_upload).
|
|
||||||
# Same ALTER-on-a-populated-table reasoning as referral_by above, so both
|
|
||||||
# carry a server default.
|
|
||||||
file_name: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
file_name: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
file_path: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
file_path: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_all(cls, session: AsyncSession, job_post_id=None, limit=None, offset=0):
|
||||||
|
try:
|
||||||
|
from inbox.models import AtsResults
|
||||||
|
from users.models import Users
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
qry=(
|
||||||
|
select(
|
||||||
|
cls.id,
|
||||||
|
cls.candidate_email,
|
||||||
|
cls.user_id,
|
||||||
|
Users.email,
|
||||||
|
cls.job_post_id,
|
||||||
|
Users.name,
|
||||||
|
cls.candidate_phone,
|
||||||
|
JobPosts.title,
|
||||||
|
cls.status,
|
||||||
|
cls.current_company,
|
||||||
|
cls.current_position,
|
||||||
|
cls.experience,
|
||||||
|
cls.created_at,
|
||||||
|
cls.updated_at,
|
||||||
|
AtsResults.id.label("ats_result_id"),
|
||||||
|
AtsResults.overall_score,
|
||||||
|
AtsResults.band,
|
||||||
|
AtsResults.job_post_id.label("ats_job_post_id"),
|
||||||
|
AtsResults.computed_at,
|
||||||
|
AtsResults.candidate_id,
|
||||||
|
AtsResults.user_id.label("ats_user_id"),
|
||||||
|
)
|
||||||
|
.join(Users,cls.user_id==Users.id)
|
||||||
|
.join(JobPosts,cls.job_post_id==JobPosts.id)
|
||||||
|
.outerjoin(
|
||||||
|
AtsResults,
|
||||||
|
(Users.id==AtsResults.user_id)
|
||||||
|
&(AtsResults.job_post_id==cls.job_post_id)
|
||||||
|
&(AtsResults.is_current==True), # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
)
|
||||||
|
if job_post_id:
|
||||||
|
qry=qry.where(cls.job_post_id==job_post_id)
|
||||||
|
if limit is not None:
|
||||||
|
qry=qry.limit(limit).offset(offset)
|
||||||
|
result=await session.execute(qry)
|
||||||
|
rows=[]
|
||||||
|
for row in result.mappings().all():
|
||||||
|
ats=None
|
||||||
|
if row["ats_result_id"] is not None:
|
||||||
|
ats={
|
||||||
|
"id":str(row["ats_result_id"]),
|
||||||
|
"overall_score":row["overall_score"],
|
||||||
|
"band":row["band"] or None,
|
||||||
|
"job_post_id":str(row["ats_job_post_id"]) if row["ats_job_post_id"] else None,
|
||||||
|
"computed_at":row["computed_at"].isoformat() if row["computed_at"] else None,
|
||||||
|
"candidate_id":str(row["candidate_id"]) if row["candidate_id"] else None,
|
||||||
|
"user_id":str(row["ats_user_id"]) if row["ats_user_id"] else None,
|
||||||
|
}
|
||||||
|
rows.append({
|
||||||
|
"id":str(row["id"]) if row["id"] else None,
|
||||||
|
"candidate_email":row["candidate_email"],
|
||||||
|
"user_id":str(row["user_id"]) if row["user_id"] else None,
|
||||||
|
"email":row["email"],
|
||||||
|
"job_post_id":str(row["job_post_id"]) if row["job_post_id"] else None,
|
||||||
|
"name":row["name"],
|
||||||
|
"candidate_phone":row["candidate_phone"],
|
||||||
|
"title":row["title"] or None,
|
||||||
|
"application_status":row["status"] or None,
|
||||||
|
"current_company":row["current_company"] or None,
|
||||||
|
"current_position":row["current_position"] or None,
|
||||||
|
"experience":row["experience"] or None,
|
||||||
|
"created_at":row["created_at"].isoformat() if row["created_at"] else None,
|
||||||
|
"updated_at":row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||||
|
"ats_result":ats,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_by_status(cls, session: AsyncSession, job_post_id=None):
|
||||||
|
try:
|
||||||
|
from users.models import Users
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
qry=(
|
||||||
|
select(cls.status,func.count())
|
||||||
|
.select_from(cls)
|
||||||
|
.join(Users,cls.user_id==Users.id)
|
||||||
|
.join(JobPosts,cls.job_post_id==JobPosts.id)
|
||||||
|
.group_by(cls.status)
|
||||||
|
)
|
||||||
|
if job_post_id:
|
||||||
|
qry=qry.where(cls.job_post_id==job_post_id)
|
||||||
|
result=await session.execute(qry)
|
||||||
|
counts={}
|
||||||
|
for status,n in result.all():
|
||||||
|
key=(status or "").strip() or "UNKNOWN"
|
||||||
|
counts[key]=counts.get(key,0)+int(n or 0)
|
||||||
|
return counts
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||||
if record_id in (None, ""):
|
if record_id in (None, ""):
|
||||||
|
|
@ -103,11 +193,12 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
full_text=fields.get("full_text") or "",
|
full_text=fields.get("full_text") or "",
|
||||||
current_company=(fields.get("current_company") or "").strip(),
|
current_company=(fields.get("current_company") or "").strip(),
|
||||||
current_position=(fields.get("current_position") or "").strip(),
|
current_position=(fields.get("current_position") or "").strip(),
|
||||||
|
apply_via="manual_upload",
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
platform=(fields.get("platform") or "").strip(),
|
platform=(fields.get("platform") or "").strip(),
|
||||||
created_by=cls._as_uuid(fields.get("created_by")),
|
created_by=cls._as_uuid(fields.get("created_by")),
|
||||||
experience=(fields.get("experience") or "").strip(),
|
experience=(fields.get("experience") or "").strip(),
|
||||||
status=(fields.get("status") or "").strip(),
|
status=(fields.get("status") or "").strip() or "PENDING",
|
||||||
referral_by=(fields.get("referral_by") or "").strip(),
|
referral_by=(fields.get("referral_by") or "").strip(),
|
||||||
file_name=(fields.get("file_name") or "").strip(),
|
file_name=(fields.get("file_name") or "").strip(),
|
||||||
file_path=(fields.get("file_path") or "").strip(),
|
file_path=(fields.get("file_path") or "").strip(),
|
||||||
|
|
@ -127,29 +218,28 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_id(cls, session: AsyncSession, record_id):
|
||||||
|
uid = cls._as_uuid(record_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(select(cls).where(cls.id == uid))
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
class Candidates(SQLModel, table=True):
|
class Candidates(SQLModel, table=True):
|
||||||
"""One scored (or failed-to-score) CV against one job post.
|
|
||||||
|
|
||||||
The dedupe key is (job_id, content_sha256), not the filename: inbox attachments
|
|
||||||
are stored by basename so different candidates can collide on "resume.pdf", while
|
|
||||||
identical bytes can arrive via both upload and email. Re-scoring the same bytes
|
|
||||||
against the same job updates the existing row (fresh model output, updated_at
|
|
||||||
bumped) instead of duplicating it. content_sha256 is NULL when the file bytes
|
|
||||||
were never readable (missing on disk); NULLs never conflict in the unique index.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "candidates"
|
__tablename__ = "candidates"
|
||||||
__table_args__ = (UniqueConstraint("job_id", "content_sha256"),)
|
__table_args__ = (UniqueConstraint("job_id", "content_sha256"),)
|
||||||
|
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
job_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True)
|
job_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True)
|
||||||
source: str = Field(default="upload") # "upload" | "inbox"
|
source: str = Field(default="upload") # "upload" | "inbox" origin metadata, not an FK
|
||||||
inbox_message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
|
|
||||||
filename: str
|
filename: str
|
||||||
file_path: str | None = Field(default=None) # decoded-attachment path (inbox only)
|
file_path: str | None = Field(default=None) # decoded-attachment path (inbox only)
|
||||||
content_sha256: str | None = Field(default=None, index=True)
|
content_sha256: str | None = Field(default=None, index=True)
|
||||||
|
candidate_email: str | None = Field(default=None)
|
||||||
candidate_name: str | None = Field(default=None)
|
candidate_name: str | None = Field(default=None)
|
||||||
job_title: str | None = Field(default=None)
|
job_title: str | None = Field(default=None)
|
||||||
current_company: str | None = Field(default=None)
|
current_company: str | None = Field(default=None)
|
||||||
|
|
@ -204,6 +294,25 @@ class Candidates(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_completed_by_email_job(cls, session: AsyncSession, email, job_id):
|
||||||
|
"""Newest completed score for this email against this job — keywords when
|
||||||
|
ats_results.candidate_id is NULL (matched-user identity)."""
|
||||||
|
normalized = (email or "").strip().lower()
|
||||||
|
jid = cls._as_uuid(job_id)
|
||||||
|
if not normalized or jid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
func.lower(cls.candidate_email) == normalized,
|
||||||
|
cls.job_id == jid,
|
||||||
|
cls.status == "completed",
|
||||||
|
)
|
||||||
|
.order_by(cls.updated_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
|
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
|
||||||
existing = None
|
existing = None
|
||||||
|
|
@ -525,16 +634,20 @@ class Feedback(SQLModel, table=True):
|
||||||
|
|
||||||
|
|
||||||
class ApplicationStageTransitions(SQLModel, table=True):
|
class ApplicationStageTransitions(SQLModel, table=True):
|
||||||
"""Temporal history of inbox_messages.application_status changes.
|
"""Temporal history of application stage changes.
|
||||||
|
|
||||||
valid_from / valid_to make time-in-stage a subtraction rather than a window
|
Inbox moves write inbox_messages.application_status; manual-upload moves
|
||||||
function. NULL valid_to means the stage is still current.
|
write manual_upload_candidate.status. Exactly one of inbox_id /
|
||||||
|
manual_upload_candidate_id is set. NULL valid_to means the stage is current.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "application_stage_transitions"
|
__tablename__ = "application_stage_transitions"
|
||||||
|
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
inbox_id: int = Field(index=True, foreign_key="inbox.id")
|
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
|
||||||
|
manual_upload_candidate_id: uuid.UUID | None = Field(
|
||||||
|
default=None, index=True, foreign_key="manual_upload_candidate.id"
|
||||||
|
)
|
||||||
from_stage: str | None = Field(default=None)
|
from_stage: str | None = Field(default=None)
|
||||||
to_stage: str
|
to_stage: str
|
||||||
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
@ -569,12 +682,28 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_open_transition(cls, session: AsyncSession, inbox_id: int):
|
async def fetch_by_manual(cls, session: AsyncSession, record_id):
|
||||||
|
uid = cls._as_uuid(record_id)
|
||||||
|
if uid is None:
|
||||||
|
return []
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(cls)
|
select(cls).where(cls.manual_upload_candidate_id == uid).order_by(cls.valid_from.desc())
|
||||||
.where(cls.inbox_id == int(inbox_id), cls.valid_to.is_(None))
|
|
||||||
.order_by(cls.valid_from.desc())
|
|
||||||
)
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_open_transition(cls, session: AsyncSession, inbox_id=None, manual_upload_candidate_id=None):
|
||||||
|
statement = select(cls).where(cls.valid_to.is_(None)).order_by(cls.valid_from.desc())
|
||||||
|
if inbox_id is not None:
|
||||||
|
statement = statement.where(cls.inbox_id == int(inbox_id))
|
||||||
|
elif manual_upload_candidate_id is not None:
|
||||||
|
uid = cls._as_uuid(manual_upload_candidate_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
statement = statement.where(cls.manual_upload_candidate_id == uid)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
result = await session.execute(statement)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -587,8 +716,10 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
||||||
return row
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def close_open(cls, session: AsyncSession, inbox_id: int, *, at: datetime | None = None, commit: bool = False):
|
async def close_open(cls, session: AsyncSession, inbox_id=None, *, manual_upload_candidate_id=None, at: datetime | None = None, commit: bool = False):
|
||||||
row = await cls.get_open_transition(session, inbox_id)
|
row = await cls.get_open_transition(
|
||||||
|
session, inbox_id=inbox_id, manual_upload_candidate_id=manual_upload_candidate_id
|
||||||
|
)
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
row.valid_to = at or _now()
|
row.valid_to = at or _now()
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,49 @@ def build_job_description(job) -> str:
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_base_fields(source):
|
||||||
|
return {
|
||||||
|
"filename": source["safe_name"],
|
||||||
|
"file_path": source["file_path"],
|
||||||
|
"content_sha256": source["sha256"],
|
||||||
|
"candidate_email": source.get("candidate_email"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_failed_fields(source, code, message):
|
||||||
|
return {
|
||||||
|
**candidate_base_fields(source),
|
||||||
|
"status": "failed",
|
||||||
|
"error_code": str(code),
|
||||||
|
"error_message": message,
|
||||||
|
"candidate_name": None,
|
||||||
|
"job_title": None,
|
||||||
|
"current_company": None,
|
||||||
|
"years_experience": None,
|
||||||
|
"match_score": None,
|
||||||
|
"matched_keywords": [],
|
||||||
|
"missing_keywords": [],
|
||||||
|
"summary_critique": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_completed_fields(source, result):
|
||||||
|
return {
|
||||||
|
**candidate_base_fields(source),
|
||||||
|
"status": "completed",
|
||||||
|
"candidate_name": result.candidate_name,
|
||||||
|
"job_title": result.job_title,
|
||||||
|
"current_company": result.current_company,
|
||||||
|
"years_experience": result.years_experience,
|
||||||
|
"match_score": result.match_score,
|
||||||
|
"matched_keywords": result.matched_keywords,
|
||||||
|
"missing_keywords": result.missing_keywords,
|
||||||
|
"summary_critique": result.summary_critique,
|
||||||
|
"error_code": None,
|
||||||
|
"error_message": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@normalize_unicode
|
@normalize_unicode
|
||||||
@despace_line
|
@despace_line
|
||||||
def normalize_spaced_text(text) -> str:
|
def normalize_spaced_text(text) -> str:
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@ def serialize_candidate(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"job_id": str(row.job_id),
|
"job_id": str(row.job_id),
|
||||||
"inbox_message_id": str(row.inbox_message_id) if row.inbox_message_id else None,
|
|
||||||
"source": row.source,
|
"source": row.source,
|
||||||
"filename": row.filename,
|
"filename": row.filename,
|
||||||
"file_path": row.file_path,
|
"file_path": row.file_path,
|
||||||
"content_sha256": row.content_sha256,
|
"content_sha256": row.content_sha256,
|
||||||
|
"candidate_email": row.candidate_email,
|
||||||
"candidate_name": row.candidate_name,
|
"candidate_name": row.candidate_name,
|
||||||
"job_title": row.job_title,
|
"job_title": row.job_title,
|
||||||
"current_company": row.current_company,
|
"current_company": row.current_company,
|
||||||
|
|
@ -44,6 +44,7 @@ def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
||||||
"full_text":row.full_text,
|
"full_text":row.full_text,
|
||||||
"current_company":row.current_company,
|
"current_company":row.current_company,
|
||||||
"current_position":row.current_position,
|
"current_position":row.current_position,
|
||||||
|
"apply_via":row.apply_via,
|
||||||
"user_id":str(row.user_id) if row.user_id else None,
|
"user_id":str(row.user_id) if row.user_id else None,
|
||||||
"platform":row.platform,
|
"platform":row.platform,
|
||||||
"created_by":str(row.created_by) if row.created_by else None,
|
"created_by":str(row.created_by) if row.created_by else None,
|
||||||
|
|
@ -72,6 +73,7 @@ def serialize_candidate_profile(
|
||||||
payload = {
|
payload = {
|
||||||
"inbox_id": link.id,
|
"inbox_id": link.id,
|
||||||
"user_id": str(link.user_id) if link.user_id else None,
|
"user_id": str(link.user_id) if link.user_id else None,
|
||||||
|
"candidate_id": None,
|
||||||
"name": user.name if user else None,
|
"name": user.name if user else None,
|
||||||
"email": user.email if user else None,
|
"email": user.email if user else None,
|
||||||
"is_active": user.is_active if user else None,
|
"is_active": user.is_active if user else None,
|
||||||
|
|
@ -138,6 +140,7 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"inbox_id": None,
|
"inbox_id": None,
|
||||||
"user_id": str(user.id) if user else (str(row.user_id) if row.user_id else None),
|
"user_id": str(user.id) if user else (str(row.user_id) if row.user_id else None),
|
||||||
|
"candidate_id": None,
|
||||||
"name": (user.name if user else None) or row.candidate_name or None,
|
"name": (user.name if user else None) or row.candidate_name or None,
|
||||||
"email": (user.email if user else None) or row.candidate_email or None,
|
"email": (user.email if user else None) or row.candidate_email or None,
|
||||||
"is_active": user.is_active if user else None,
|
"is_active": user.is_active if user else None,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ from job.candidate.models import Candidates
|
||||||
from job.candidate.plugins import (
|
from job.candidate.plugins import (
|
||||||
FILE_NOT_FOUND,
|
FILE_NOT_FOUND,
|
||||||
build_job_description,
|
build_job_description,
|
||||||
|
candidate_completed_fields,
|
||||||
|
candidate_failed_fields,
|
||||||
get_scorer,
|
get_scorer,
|
||||||
get_scoring_settings,
|
get_scoring_settings,
|
||||||
normalize_spaced_text,
|
normalize_spaced_text,
|
||||||
|
|
@ -272,7 +274,6 @@ class CandidateScoring:
|
||||||
"filename":filename or "resume.pdf",
|
"filename":filename or "resume.pdf",
|
||||||
"data":data,
|
"data":data,
|
||||||
"file_path":None,
|
"file_path":None,
|
||||||
"inbox_message_id":None,
|
|
||||||
"precheck":None,
|
"precheck":None,
|
||||||
}
|
}
|
||||||
if not (filename or "").lower().endswith(".pdf"):
|
if not (filename or "").lower().endswith(".pdf"):
|
||||||
|
|
@ -301,7 +302,8 @@ class CandidateScoring:
|
||||||
"filename":path.name,
|
"filename":path.name,
|
||||||
"data":None,
|
"data":None,
|
||||||
"file_path":str(path),
|
"file_path":str(path),
|
||||||
"inbox_message_id":row.id,
|
"inbox_message_id":row.id, # call-scoped; not persisted on Candidates
|
||||||
|
"candidate_email":(row.message_from or "").strip().lower() or None,
|
||||||
"precheck":None,
|
"precheck":None,
|
||||||
}
|
}
|
||||||
suffix=path.suffix.lower()
|
suffix=path.suffix.lower()
|
||||||
|
|
@ -344,10 +346,24 @@ class CandidateScoring:
|
||||||
jd=build_job_description(job)
|
jd=build_job_description(job)
|
||||||
if len(jd)>settings.max_jd_chars:
|
if len(jd)>settings.max_jd_chars:
|
||||||
raise HTTPException(status_code=422,detail="The job post is too large to score against")
|
raise HTTPException(status_code=422,detail="The job post is too large to score against")
|
||||||
|
fields_by_slot=await self._score_sources(sources,jd,settings)
|
||||||
|
common={
|
||||||
|
"job_id":job.id,
|
||||||
|
"source":source_kind,
|
||||||
|
"created_by":uuid.UUID(str(current_user["id"])),
|
||||||
|
"model":settings.openai_model,
|
||||||
|
}
|
||||||
|
rows=[]
|
||||||
|
for slot in range(len(sources)):
|
||||||
|
rows.append(await Candidates.upsert_candidate(self.session,{**fields_by_slot[slot],**common}))
|
||||||
|
await self._sync_ats_results(source_kind,job,rows,sources)
|
||||||
|
rows.sort(key=lambda r:(0,-(r.match_score or 0)) if r.status=="completed" else (1,0))
|
||||||
|
return [serialize_candidate(row) for row in rows]
|
||||||
|
|
||||||
# Slot-indexed like app/api/routes.py: results merge back by position, never
|
async def _score_sources(self,sources,jd,settings):
|
||||||
# by filename — inbox attachments can share a basename.
|
# Slot-indexed: results merge back by position, never by filename —
|
||||||
results_by_slot={}
|
# inbox attachments can share a basename.
|
||||||
|
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["filename"])
|
||||||
|
|
@ -355,21 +371,20 @@ class CandidateScoring:
|
||||||
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
|
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
|
||||||
if source["precheck"] is not None:
|
if source["precheck"] is not None:
|
||||||
code,message=source["precheck"]
|
code,message=source["precheck"]
|
||||||
results_by_slot[slot]=self._failed_fields(source,code,message)
|
fields_by_slot[slot]=candidate_failed_fields(source,code,message)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
# pypdf is CPU-bound: keep it off the event loop. Despace BEFORE
|
|
||||||
# scoring so keyword verification sees the exact text the model saw;
|
|
||||||
# ExtractedResume is frozen, hence dataclasses.replace.
|
|
||||||
resume=await asyncio.to_thread(
|
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
|
||||||
)
|
)
|
||||||
resume=dataclasses.replace(resume,text=normalize_spaced_text(resume.text))
|
resume=dataclasses.replace(resume,text=normalize_spaced_text(resume.text))
|
||||||
|
if not source.get("candidate_email"):
|
||||||
|
detected,_=extract_candidate_email(resume.text)
|
||||||
|
source["candidate_email"]=detected
|
||||||
except ATSError as exc:
|
except ATSError as exc:
|
||||||
results_by_slot[slot]=self._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
|
||||||
extracted.append((slot,resume))
|
extracted.append((slot,resume))
|
||||||
|
|
||||||
scored=await score_batch(
|
scored=await score_batch(
|
||||||
[resume for _,resume in extracted],
|
[resume for _,resume in extracted],
|
||||||
job_description=jd,
|
job_description=jd,
|
||||||
|
|
@ -379,178 +394,80 @@ class CandidateScoring:
|
||||||
for (slot,_),result in zip(extracted,scored,strict=True):
|
for (slot,_),result in zip(extracted,scored,strict=True):
|
||||||
source=sources[slot]
|
source=sources[slot]
|
||||||
if isinstance(result,CompletedCandidate):
|
if isinstance(result,CompletedCandidate):
|
||||||
results_by_slot[slot]={
|
fields_by_slot[slot]=candidate_completed_fields(source,result)
|
||||||
**self._base_fields(source),
|
|
||||||
"status":"completed",
|
|
||||||
"candidate_name":result.candidate_name,
|
|
||||||
"job_title":result.job_title,
|
|
||||||
"current_company":result.current_company,
|
|
||||||
"years_experience":result.years_experience,
|
|
||||||
"match_score":result.match_score,
|
|
||||||
"matched_keywords":result.matched_keywords,
|
|
||||||
"missing_keywords":result.missing_keywords,
|
|
||||||
"summary_critique":result.summary_critique,
|
|
||||||
"error_code":None,
|
|
||||||
"error_message":None,
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
results_by_slot[slot]=self._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
|
||||||
|
|
||||||
common={
|
async def _sync_ats_results(self,source_kind,job,rows,sources):
|
||||||
"job_id":job.id,
|
|
||||||
"source":source_kind,
|
|
||||||
"created_by":uuid.UUID(str(current_user["id"])),
|
|
||||||
"model":settings.openai_model,
|
|
||||||
}
|
|
||||||
rows=[]
|
|
||||||
for slot in range(len(sources)):
|
|
||||||
fields={**results_by_slot[slot],**common}
|
|
||||||
rows.append(await Candidates.upsert_candidate(self.session,fields))
|
|
||||||
|
|
||||||
# Every completed score lands in ats_results. Inbox scores additionally
|
|
||||||
# denormalise onto inbox_messages / inbox (one sync per message: a
|
|
||||||
# multi-attachment mail keeps its best completed score); upload scores
|
|
||||||
# chain per candidates row instead — they have no inbox application.
|
|
||||||
if source_kind=="inbox":
|
if source_kind=="inbox":
|
||||||
best={}
|
best={}
|
||||||
for row in rows:
|
for source,row in zip(sources,rows):
|
||||||
if row.status=="completed" and row.inbox_message_id:
|
mid=source.get("inbox_message_id")
|
||||||
cur=best.get(row.inbox_message_id)
|
if row.status=="completed" and mid:
|
||||||
|
cur=best.get(mid)
|
||||||
if cur is None or (row.match_score or 0)>(cur.match_score or 0):
|
if cur is None or (row.match_score or 0)>(cur.match_score or 0):
|
||||||
best[row.inbox_message_id]=row
|
best[mid]=row
|
||||||
for message_id,row in best.items():
|
for message_id,row in best.items():
|
||||||
try:
|
try:
|
||||||
await self._sync_inbox_ats(message_id,job,row)
|
await self._sync_inbox_ats(message_id,job,row)
|
||||||
except Exception:
|
except Exception:
|
||||||
# The candidates row is the primary outcome and is already
|
|
||||||
# committed; a denorm failure must not fail the scoring call.
|
|
||||||
await self.session.rollback()
|
await self.session.rollback()
|
||||||
logger.exception("inbox ATS denorm failed for message %s",message_id)
|
logger.exception("inbox ATS denorm failed for message %s",message_id)
|
||||||
else:
|
return
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if row.status!="completed":
|
if row.status!="completed":
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
await self._sync_upload_ats(job,row)
|
await self._sync_upload_ats(job,row)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.session.rollback()
|
await self.session.rollback()
|
||||||
logger.exception("upload ATS history failed for candidate %s",row.id)
|
logger.exception("upload ATS history failed for candidate %s",row.id)
|
||||||
|
|
||||||
# Leaderboard order: completed by score desc, failures last, stable.
|
|
||||||
rows.sort(key=lambda r:(0,-(r.match_score or 0)) if r.status=="completed" else (1,0))
|
|
||||||
return [serialize_candidate(row) for row in rows]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _base_fields(source):
|
|
||||||
return {
|
|
||||||
"inbox_message_id":source["inbox_message_id"],
|
|
||||||
"filename":source["safe_name"],
|
|
||||||
"file_path":source["file_path"],
|
|
||||||
"content_sha256":source["sha256"],
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _failed_fields(cls,source,code,message):
|
|
||||||
return {
|
|
||||||
**cls._base_fields(source),
|
|
||||||
"status":"failed",
|
|
||||||
"error_code":str(code),
|
|
||||||
"error_message":message,
|
|
||||||
"candidate_name":None,
|
|
||||||
"job_title":None,
|
|
||||||
"current_company":None,
|
|
||||||
"years_experience":None,
|
|
||||||
"match_score":None,
|
|
||||||
"matched_keywords":[],
|
|
||||||
"missing_keywords":[],
|
|
||||||
"summary_critique":None,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _sync_inbox_ats(self,message_id,job,row):
|
async def _sync_inbox_ats(self,message_id,job,row):
|
||||||
"""Land a completed score on the inbox tables (README "What is missing").
|
"""Land a completed score on inbox_messages / inbox / ats_results.
|
||||||
|
|
||||||
inbox_messages.ats_score / ats_band are what serialize_application renders
|
message_id is the scoring call's known inbox_messages PK, not a column
|
||||||
on the Applications tab; ats_results keeps the per-application history that
|
read off the Candidates row.
|
||||||
candidates' in-place upsert cannot. Precedence mirrors _recommendation: a
|
|
||||||
score against the assigned job always wins the denormalised columns, any
|
|
||||||
other job's score only lands while no completed assigned-job score exists.
|
|
||||||
The history row is appended regardless — it records the scoring event.
|
|
||||||
"""
|
"""
|
||||||
msg=await Inbox_Messages.get_inbox_message_by_id(self.session,message_id)
|
msg=await Inbox_Messages.get_inbox_message_by_id(self.session,message_id)
|
||||||
if msg is None:
|
if msg is None:
|
||||||
return
|
return
|
||||||
band=CandidateView._recommendation(row.match_score) or ""
|
band=CandidateView._recommendation(row.match_score) or ""
|
||||||
|
|
||||||
denorm=True
|
denorm=True
|
||||||
assigned=msg.assigned_job_post_id
|
assigned=msg.assigned_job_post_id
|
||||||
if assigned and str(assigned)!=str(job.id):
|
|
||||||
outranked=await self.session.execute(
|
|
||||||
select(Candidates).where(
|
|
||||||
Candidates.inbox_message_id==msg.id,
|
|
||||||
Candidates.job_id==assigned,
|
|
||||||
Candidates.status=="completed",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
denorm=outranked.scalars().first() is None
|
|
||||||
if denorm:
|
|
||||||
msg.ats_score=float(row.match_score)
|
|
||||||
msg.ats_band=band
|
|
||||||
self.session.add(msg)
|
|
||||||
|
|
||||||
# ats_results hangs off the inbox JOIN row (int PK), which only exists once
|
|
||||||
# the sender is linked to a users account; without it there is no history row.
|
|
||||||
link=await Inbox.get_inbox_by_message_id(self.session,message_id)
|
link=await Inbox.get_inbox_by_message_id(self.session,message_id)
|
||||||
if link is not None:
|
if assigned and str(assigned)!=str(job.id) and link is not None:
|
||||||
prev=await AtsResults.get_current_for_inbox(self.session,link.id)
|
existing=await AtsResults.get_for_inbox_job(self.session,link.id,assigned)
|
||||||
entry=AtsResults(
|
denorm=existing is None
|
||||||
inbox_id=link.id,
|
if denorm:
|
||||||
candidate_id=row.id,
|
await Inbox_Messages.set_ats_score(self.session,message_id,row.match_score,band)
|
||||||
job_post_id=job.id,
|
if link is None:
|
||||||
overall_score=float(row.match_score),
|
return
|
||||||
band=band,
|
identity=await AtsResults.resolve_identity(self.session,row.candidate_email,row.id)
|
||||||
model_name=row.model,
|
await AtsResults.insert_result(self.session,{
|
||||||
is_current=True,
|
"inbox_id":link.id,
|
||||||
)
|
**identity,
|
||||||
self.session.add(entry)
|
"job_post_id":job.id,
|
||||||
# Flush the INSERT before touching prev/link: with no relationship()
|
"overall_score":float(row.match_score),
|
||||||
# edge the unit of work emits the UPDATEs first, and both FKs
|
"band":band,
|
||||||
# (superseded_by_id, inbox.ats_id) reject a pointer to a row that is
|
"model_name":row.model,
|
||||||
# not inserted yet.
|
"is_current":True,
|
||||||
await self.session.flush()
|
})
|
||||||
if prev is not None:
|
|
||||||
prev.is_current=False
|
|
||||||
prev.superseded_by_id=entry.id
|
|
||||||
self.session.add(prev)
|
|
||||||
# inbox.ats_id always points at the CURRENT score row.
|
|
||||||
link.ats_id=entry.id
|
|
||||||
link.updated_at=datetime.now(timezone.utc)
|
|
||||||
self.session.add(link)
|
|
||||||
await self.session.commit()
|
|
||||||
|
|
||||||
async def _sync_upload_ats(self,job,row):
|
async def _sync_upload_ats(self,job,row):
|
||||||
"""History row for an upload-sourced score — no inbox application exists,
|
"""History row for an upload-sourced score — inbox_id stays NULL."""
|
||||||
so inbox_id stays NULL and the supersede chain runs per candidates row
|
|
||||||
(stable across re-scores: upsert keeps the id for the same job+bytes)."""
|
|
||||||
band=CandidateView._recommendation(row.match_score) or ""
|
band=CandidateView._recommendation(row.match_score) or ""
|
||||||
prev=await AtsResults.get_current_for_candidate(self.session,row.id)
|
identity=await AtsResults.resolve_identity(self.session,row.candidate_email,row.id)
|
||||||
entry=AtsResults(
|
await AtsResults.insert_result(self.session,{
|
||||||
inbox_id=None,
|
"inbox_id":None,
|
||||||
candidate_id=row.id,
|
**identity,
|
||||||
job_post_id=job.id,
|
"job_post_id":job.id,
|
||||||
overall_score=float(row.match_score),
|
"overall_score":float(row.match_score),
|
||||||
band=band,
|
"band":band,
|
||||||
model_name=row.model,
|
"model_name":row.model,
|
||||||
is_current=True,
|
"is_current":True,
|
||||||
)
|
})
|
||||||
self.session.add(entry)
|
|
||||||
# Same flush-before-pointing rule as the inbox path: the FK on
|
|
||||||
# superseded_by_id must see the new row inserted first.
|
|
||||||
await self.session.flush()
|
|
||||||
if prev is not None:
|
|
||||||
prev.is_current=False
|
|
||||||
prev.superseded_by_id=entry.id
|
|
||||||
self.session.add(prev)
|
|
||||||
await self.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class CandidateView:
|
class CandidateView:
|
||||||
|
|
@ -564,23 +481,14 @@ class CandidateView:
|
||||||
# Same bands the frontend uses (Candidates.jsx / seed.js).
|
# Same bands the frontend uses (Candidates.jsx / seed.js).
|
||||||
return "Strong Match" if score>=82 else "Potential Match" if score>=65 else "Weak Match"
|
return "Strong Match" if score>=82 else "Potential Match" if score>=65 else "Weak Match"
|
||||||
|
|
||||||
async def _scores_by_message(self,message_ids):
|
@staticmethod
|
||||||
"""Completed ATS scores (candidates table) per inbox message id, newest first.
|
def _score_from_message(record):
|
||||||
|
"""Inbox denorm on the already-loaded messages row — not a Candidates join."""
|
||||||
One batched query — the profile list would otherwise pay a query per row.
|
msg=getattr(record,"messages",None)
|
||||||
"""
|
if msg is None or msg.ats_score is None:
|
||||||
mids=[m for m in message_ids if m]
|
return None,None
|
||||||
if not mids:
|
band=(msg.ats_band or "").strip() or None
|
||||||
return {}
|
return msg.ats_score,band or CandidateView._recommendation(msg.ats_score)
|
||||||
result=await self.session.execute(
|
|
||||||
select(Candidates)
|
|
||||||
.where(Candidates.inbox_message_id.in_(mids),Candidates.status=="completed")
|
|
||||||
.order_by(Candidates.updated_at.desc())
|
|
||||||
)
|
|
||||||
scores={}
|
|
||||||
for row in result.scalars().all():
|
|
||||||
scores.setdefault(row.inbox_message_id,[]).append(row)
|
|
||||||
return scores
|
|
||||||
|
|
||||||
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,current_position=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None):
|
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,current_position=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None):
|
||||||
try:
|
try:
|
||||||
|
|
@ -632,7 +540,19 @@ class CandidateView:
|
||||||
job_post=None
|
job_post=None
|
||||||
if manual.job_post_id:
|
if manual.job_post_id:
|
||||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
||||||
return serialize_manual_candidate_profile(manual,user,job_post)
|
payload=serialize_manual_candidate_profile(manual,user,job_post)
|
||||||
|
from inbox.plugins import get_ats_score_for_manual_user
|
||||||
|
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
|
||||||
|
if score:
|
||||||
|
payload["ai_score"]=score["overall_score"]
|
||||||
|
payload["recommendation"]=self._recommendation(score["overall_score"])
|
||||||
|
payload["scored_at"]=score["computed_at"]
|
||||||
|
payload["candidate_id"]=score.get("candidate_id")
|
||||||
|
if score.get("user_id") and not payload.get("user_id"):
|
||||||
|
payload["user_id"]=score["user_id"]
|
||||||
|
if score.get("job_post_id"):
|
||||||
|
payload["scored_job_post_id"]=score["job_post_id"]
|
||||||
|
return payload
|
||||||
return await self.attach_job_posts(rows)
|
return await self.attach_job_posts(rows)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -729,7 +649,6 @@ class CandidateView:
|
||||||
"""
|
"""
|
||||||
single=not isinstance(data,list)
|
single=not isinstance(data,list)
|
||||||
records=[data] if single else list(data or [])
|
records=[data] if single else list(data or [])
|
||||||
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
|
|
||||||
|
|
||||||
payloads=[]
|
payloads=[]
|
||||||
wanted=[]
|
wanted=[]
|
||||||
|
|
@ -737,10 +656,10 @@ class CandidateView:
|
||||||
payload=serialize_candidate_profile(record)
|
payload=serialize_candidate_profile(record)
|
||||||
payload["job_posts"]=[]
|
payload["job_posts"]=[]
|
||||||
payload["assigned_job_post"]=None
|
payload["assigned_job_post"]=None
|
||||||
scored=scores.get(getattr(record,"message_id",None)) or []
|
score,band=self._score_from_message(record)
|
||||||
if scored:
|
if score is not None:
|
||||||
payload["ai_score"]=scored[0].match_score
|
payload["ai_score"]=score
|
||||||
payload["recommendation"]=self._recommendation(scored[0].match_score)
|
payload["recommendation"]=band
|
||||||
payloads.append(payload)
|
payloads.append(payload)
|
||||||
if payload.get("assigned_job_post_id"):
|
if payload.get("assigned_job_post_id"):
|
||||||
wanted.append(payload["assigned_job_post_id"])
|
wanted.append(payload["assigned_job_post_id"])
|
||||||
|
|
@ -821,26 +740,46 @@ class CandidateView:
|
||||||
)
|
)
|
||||||
notes=[serialize_note(r) for r in result.scalars().all()]
|
notes=[serialize_note(r) for r in result.scalars().all()]
|
||||||
|
|
||||||
# ATS score: join the scoring engine's `candidates` rows onto the profile
|
# ATS score from inbox denorm / ats_results via Inbox.ats_id — never from
|
||||||
# by inbox message. The serializer stubs ai_score/recommendation to None;
|
# a Candidates join on message id. Keywords live on the scored Candidates
|
||||||
# this is where they get real values. Prefer the score against the
|
# row: candidate_id when set, else email+job for the matched-user path.
|
||||||
# assigned job post, else the most recent completed score.
|
ats_ids=[r.ats_id for r in records if getattr(r,"ats_id",None)]
|
||||||
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
|
ats_rows=[]
|
||||||
scored_rows=[row for rows in scores.values() for row in rows]
|
if ats_ids:
|
||||||
if scored_rows:
|
result=await self.session.execute(select(AtsResults).where(AtsResults.id.in_(ats_ids)))
|
||||||
assigned_uid=Candidates._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
|
ats_rows=list(result.scalars().all())
|
||||||
chosen=None
|
assigned_uid=AtsResults._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
|
||||||
if assigned_uid is not None:
|
chosen=None
|
||||||
chosen=next((r for r in scored_rows if r.job_id==assigned_uid),None)
|
if assigned_uid is not None:
|
||||||
if chosen is None:
|
chosen=next((a for a in ats_rows if a.job_post_id==assigned_uid),None)
|
||||||
chosen=max(scored_rows,key=lambda r:r.updated_at)
|
if chosen is None and ats_rows:
|
||||||
base["ai_score"]=chosen.match_score
|
chosen=max(ats_rows,key=lambda a:a.computed_at)
|
||||||
base["recommendation"]=self._recommendation(chosen.match_score)
|
if chosen is not None:
|
||||||
base["matched_keywords"]=list(chosen.matched_keywords or [])
|
base["ai_score"]=chosen.overall_score
|
||||||
base["missing_keywords"]=list(chosen.missing_keywords or [])
|
base["recommendation"]=chosen.band or self._recommendation(chosen.overall_score)
|
||||||
base["summary_critique"]=chosen.summary_critique
|
base["scored_job_post_id"]=str(chosen.job_post_id) if chosen.job_post_id else None
|
||||||
base["scored_job_post_id"]=str(chosen.job_id)
|
base["scored_at"]=chosen.computed_at.isoformat() if chosen.computed_at else None
|
||||||
base["scored_at"]=chosen.updated_at.isoformat() if chosen.updated_at else None
|
base["candidate_id"]=str(chosen.candidate_id) if chosen.candidate_id else None
|
||||||
|
if chosen.user_id and not base.get("user_id"):
|
||||||
|
base["user_id"]=str(chosen.user_id)
|
||||||
|
scored=None
|
||||||
|
if chosen.candidate_id:
|
||||||
|
scored=await Candidates.get_candidate_by_id(self.session,str(chosen.candidate_id))
|
||||||
|
elif chosen.user_id:
|
||||||
|
owner=await Users.get_user_by_id(self.session,str(chosen.user_id))
|
||||||
|
if owner is not None:
|
||||||
|
scored=await Candidates.get_completed_by_email_job(self.session,owner.email,chosen.job_post_id)
|
||||||
|
if scored is not None:
|
||||||
|
base["matched_keywords"]=list(scored.matched_keywords or [])
|
||||||
|
base["missing_keywords"]=list(scored.missing_keywords or [])
|
||||||
|
base["summary_critique"]=scored.summary_critique
|
||||||
|
else:
|
||||||
|
for record in records:
|
||||||
|
score,band=self._score_from_message(record)
|
||||||
|
if score is not None:
|
||||||
|
base["ai_score"]=score
|
||||||
|
base["recommendation"]=band
|
||||||
|
break
|
||||||
|
|
||||||
activity.sort(key=lambda r:(r.get("activity_date") or ""),reverse=True)
|
activity.sort(key=lambda r:(r.get("activity_date") or ""),reverse=True)
|
||||||
base["favorite"]=favorite
|
base["favorite"]=favorite
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,29 @@
|
||||||
|
from inbox.enums import Candidate_application_Status
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_pipeline_counts(inbox_counts,manual_counts) -> dict:
|
||||||
|
by_status={stage.value:0 for stage in Candidate_application_Status}
|
||||||
|
by_status["UNKNOWN"]=0
|
||||||
|
inbox_n=0
|
||||||
|
manual_n=0
|
||||||
|
for key,n in (inbox_counts or {}).items():
|
||||||
|
n=int(n or 0)
|
||||||
|
inbox_n+=n
|
||||||
|
bucket=key if key in by_status else "UNKNOWN"
|
||||||
|
by_status[bucket]+=n
|
||||||
|
for key,n in (manual_counts or {}).items():
|
||||||
|
n=int(n or 0)
|
||||||
|
manual_n+=n
|
||||||
|
bucket=key if key in by_status else "UNKNOWN"
|
||||||
|
by_status[bucket]+=n
|
||||||
|
return {"by_status":by_status,"inbox":inbox_n,"manual_upload":manual_n}
|
||||||
|
|
||||||
|
|
||||||
def serialize_stage_transition(row) -> dict:
|
def serialize_stage_transition(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"inbox_id": row.inbox_id,
|
"inbox_id": row.inbox_id,
|
||||||
|
"manual_upload_candidate_id": str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None,
|
||||||
"from_stage": row.from_stage,
|
"from_stage": row.from_stage,
|
||||||
"to_stage": row.to_stage,
|
"to_stage": row.to_stage,
|
||||||
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
||||||
|
|
|
||||||
|
|
@ -3,55 +3,92 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from inbox.enums import Candidate_application_Status
|
from inbox.enums import Candidate_application_Status
|
||||||
from inbox.models import Inbox
|
from inbox.models import Inbox
|
||||||
from job.candidate.models import ApplicationStageTransitions
|
from job.candidate.models import ApplicationStageTransitions, Manual_UPLOAD_CANDIDATE, _now
|
||||||
from job.pipeline.serializers import serialize_stage_transition
|
from job.pipeline.serializers import serialize_pipeline_counts, serialize_stage_transition
|
||||||
|
from inbox.plugins import get_ats_score_for_manual_user, get_ats_score_for_user
|
||||||
|
|
||||||
class Pipeline:
|
class Pipeline:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
self.session=session
|
self.session=session
|
||||||
|
|
||||||
async def get_transitions(self,inbox_id=None,transition_id=None):
|
async def get_all(self,job_post_id=None,limit=None,offset=0):
|
||||||
|
# limit/offset are per-source, not a merged page: two tables, no common
|
||||||
|
# order key. limit=200 returns up to 200 inbox AND up to 200 manual rows.
|
||||||
|
try:
|
||||||
|
inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset)
|
||||||
|
manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset)
|
||||||
|
counts=serialize_pipeline_counts(
|
||||||
|
await Inbox.count_by_status(self.session,job_post_id=job_post_id),
|
||||||
|
await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"data":{"inbox":inbox_data,"manual_upload":manual_upload_data},
|
||||||
|
"counts":counts,
|
||||||
|
"total":counts["inbox"]+counts["manual_upload"],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
async def get_pipeline_candidates(self,user_id=None,job_post_id=None):
|
||||||
|
try:
|
||||||
|
manual_data=await get_ats_score_for_manual_user(self.session,user_id,job_post_id)
|
||||||
|
inbox_data=await get_ats_score_for_user(self.session,user_id,job_post_id)
|
||||||
|
data={"manual":manual_data,"inbox":inbox_data}
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
async def get_transitions(self,inbox_id=None,transition_id=None,manual_upload_id=None):
|
||||||
if transition_id:
|
if transition_id:
|
||||||
row=await ApplicationStageTransitions.get_by_id(self.session,transition_id)
|
row=await ApplicationStageTransitions.get_by_id(self.session,transition_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Transition not found")
|
raise HTTPException(status_code=404,detail="Transition not found")
|
||||||
return serialize_stage_transition(row)
|
return serialize_stage_transition(row)
|
||||||
if inbox_id is None:
|
if inbox_id is not None:
|
||||||
raise HTTPException(status_code=400,detail="transition_id or inbox_id is required")
|
rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id))
|
||||||
rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id))
|
return [serialize_stage_transition(r) for r in rows]
|
||||||
return [serialize_stage_transition(r) for r in rows]
|
if manual_upload_id is not None:
|
||||||
|
rows=await ApplicationStageTransitions.fetch_by_manual(self.session,manual_upload_id)
|
||||||
|
return [serialize_stage_transition(r) for r in rows]
|
||||||
|
raise HTTPException(status_code=400,detail="transition_id, inbox_id or manual_upload_id is required")
|
||||||
|
|
||||||
async def change_stage(self,inbox_id,to_stage,current_user,change_reason=None):
|
async def change_stage(self,to_stage,current_user,inbox_id=None,manual_upload_id=None,change_reason=None):
|
||||||
|
if (inbox_id is None)==(manual_upload_id is None):
|
||||||
|
raise HTTPException(status_code=400,detail="inbox_id or manual_upload_id is required")
|
||||||
|
try:
|
||||||
|
stage=Candidate_application_Status(to_stage)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=422,detail="Invalid to_stage")
|
||||||
|
changed_by=None
|
||||||
|
if isinstance(current_user,dict) and current_user.get("id"):
|
||||||
|
changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id"))
|
||||||
|
if inbox_id is not None:
|
||||||
|
return await self._change_inbox_stage(inbox_id,stage,changed_by,change_reason)
|
||||||
|
return await self._change_manual_stage(manual_upload_id,stage,changed_by,change_reason)
|
||||||
|
|
||||||
|
async def _change_inbox_stage(self,inbox_id,stage,changed_by,change_reason):
|
||||||
inbox=await Inbox.get_inbox_with_message(self.session,inbox_id)
|
inbox=await Inbox.get_inbox_with_message(self.session,inbox_id)
|
||||||
if not inbox:
|
if not inbox:
|
||||||
raise HTTPException(status_code=404,detail="Inbox not found")
|
raise HTTPException(status_code=404,detail="Inbox not found")
|
||||||
message=inbox.messages
|
message=inbox.messages
|
||||||
if not message:
|
if not message:
|
||||||
raise HTTPException(status_code=404,detail="Inbox message not found")
|
raise HTTPException(status_code=404,detail="Inbox message not found")
|
||||||
try:
|
|
||||||
stage=Candidate_application_Status(to_stage)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=422,detail="Invalid to_stage")
|
|
||||||
current=message.application_status
|
current=message.application_status
|
||||||
from_stage=current.value if isinstance(current,Candidate_application_Status) else str(current)
|
from_stage=current.value if isinstance(current,Candidate_application_Status) else str(current)
|
||||||
if from_stage==stage.value:
|
if from_stage==stage.value:
|
||||||
raise HTTPException(status_code=400,detail="already at stage")
|
raise HTTPException(status_code=400,detail="already at stage")
|
||||||
changed_by=None
|
|
||||||
if isinstance(current_user,dict) and current_user.get("id"):
|
|
||||||
changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id"))
|
|
||||||
await ApplicationStageTransitions.close_open(self.session,inbox.id,commit=False)
|
await ApplicationStageTransitions.close_open(self.session,inbox.id,commit=False)
|
||||||
transition_data={
|
transition=await ApplicationStageTransitions.insert_transition(
|
||||||
|
self.session,
|
||||||
|
{
|
||||||
"inbox_id":inbox.id,
|
"inbox_id":inbox.id,
|
||||||
|
"manual_upload_candidate_id":None,
|
||||||
"from_stage":from_stage,
|
"from_stage":from_stage,
|
||||||
"to_stage":stage.value,
|
"to_stage":stage.value,
|
||||||
"changed_by":changed_by,
|
"changed_by":changed_by,
|
||||||
"actor_kind":"user",
|
"actor_kind":"user",
|
||||||
"change_reason":change_reason,
|
"change_reason":change_reason,
|
||||||
}
|
},
|
||||||
transition=await ApplicationStageTransitions.insert_transition(
|
|
||||||
self.session,
|
|
||||||
transition_data,
|
|
||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
message.application_status=stage
|
message.application_status=stage
|
||||||
|
|
@ -59,6 +96,41 @@ class Pipeline:
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
return {
|
return {
|
||||||
"inbox_id":inbox.id,
|
"inbox_id":inbox.id,
|
||||||
|
"manual_upload_id":None,
|
||||||
|
"application_status":stage.value,
|
||||||
|
"transition":serialize_stage_transition(transition),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _change_manual_stage(self,manual_upload_id,stage,changed_by,change_reason):
|
||||||
|
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_upload_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404,detail="Manual upload candidate not found")
|
||||||
|
from_stage=(row.status or "").strip() or None
|
||||||
|
if from_stage==stage.value:
|
||||||
|
raise HTTPException(status_code=400,detail="already at stage")
|
||||||
|
await ApplicationStageTransitions.close_open(
|
||||||
|
self.session,manual_upload_candidate_id=row.id,commit=False,
|
||||||
|
)
|
||||||
|
transition=await ApplicationStageTransitions.insert_transition(
|
||||||
|
self.session,
|
||||||
|
{
|
||||||
|
"inbox_id":None,
|
||||||
|
"manual_upload_candidate_id":row.id,
|
||||||
|
"from_stage":from_stage,
|
||||||
|
"to_stage":stage.value,
|
||||||
|
"changed_by":changed_by,
|
||||||
|
"actor_kind":"user",
|
||||||
|
"change_reason":change_reason,
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
row.status=stage.value
|
||||||
|
row.updated_at=_now()
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.commit()
|
||||||
|
return {
|
||||||
|
"inbox_id":None,
|
||||||
|
"manual_upload_id":str(row.id),
|
||||||
"application_status":stage.value,
|
"application_status":stage.value,
|
||||||
"transition":serialize_stage_transition(transition),
|
"transition":serialize_stage_transition(transition),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,87 +8,119 @@
|
||||||
-- INSERT skips any application that already has an ats_results row.
|
-- INSERT skips any application that already has an ats_results row.
|
||||||
-- Applied automatically at startup by alembic_setup.run_manual_sql() once the
|
-- Applied automatically at startup by alembic_setup.run_manual_sql() once the
|
||||||
-- schema is at head; recorded in manual_migrations. Safe to re-run by hand.
|
-- schema is at head; recorded in manual_migrations. Safe to re-run by hand.
|
||||||
|
--
|
||||||
|
-- The Candidates.inbox_message_id join is historical: that column is dropped
|
||||||
|
-- after revision b7d4e8f1a203. Those three statements run only when the column
|
||||||
|
-- is still present (a DB that has not yet reached that revision). Fresh installs
|
||||||
|
-- skip them — there is no pre-denorm history to copy.
|
||||||
|
|
||||||
-- Winning score per message, mirroring CandidateView._recommendation precedence:
|
DO $backfill$
|
||||||
-- the completed score against the ASSIGNED job wins, else the newest completed.
|
BEGIN
|
||||||
WITH ranked AS (
|
IF EXISTS (
|
||||||
SELECT
|
SELECT 1 FROM information_schema.columns
|
||||||
c.inbox_message_id,
|
WHERE table_schema = 'app'
|
||||||
c.job_id,
|
AND table_name = 'candidates'
|
||||||
c.match_score,
|
AND column_name = 'inbox_message_id'
|
||||||
c.model,
|
) THEN
|
||||||
c.updated_at,
|
-- Winning score per message, mirroring CandidateView._recommendation precedence:
|
||||||
ROW_NUMBER() OVER (
|
-- the completed score against the ASSIGNED job wins, else the newest completed.
|
||||||
PARTITION BY c.inbox_message_id
|
EXECUTE $sql$
|
||||||
ORDER BY (c.job_id = m.assigned_job_post_id) DESC NULLS LAST,
|
WITH ranked AS (
|
||||||
c.updated_at DESC
|
SELECT
|
||||||
) AS rn
|
c.inbox_message_id,
|
||||||
FROM app.candidates c
|
c.job_id,
|
||||||
JOIN app.inbox_messages m ON m.id = c.inbox_message_id
|
c.match_score,
|
||||||
WHERE c.status = 'completed'
|
c.model,
|
||||||
AND c.inbox_message_id IS NOT NULL
|
c.updated_at,
|
||||||
)
|
ROW_NUMBER() OVER (
|
||||||
UPDATE app.inbox_messages m
|
PARTITION BY c.inbox_message_id
|
||||||
SET ats_score = r.match_score,
|
ORDER BY (c.job_id = m.assigned_job_post_id) DESC NULLS LAST,
|
||||||
ats_band = CASE
|
c.updated_at DESC
|
||||||
WHEN r.match_score >= 82 THEN 'Strong Match'
|
) AS rn
|
||||||
WHEN r.match_score >= 65 THEN 'Potential Match'
|
FROM app.candidates c
|
||||||
ELSE 'Weak Match'
|
JOIN app.inbox_messages m ON m.id = c.inbox_message_id
|
||||||
END
|
WHERE c.status = 'completed'
|
||||||
FROM ranked r
|
AND c.inbox_message_id IS NOT NULL
|
||||||
WHERE r.rn = 1
|
)
|
||||||
AND m.id = r.inbox_message_id
|
UPDATE app.inbox_messages m
|
||||||
AND m.ats_score IS NULL;
|
SET ats_score = r.match_score,
|
||||||
|
ats_band = CASE
|
||||||
|
WHEN r.match_score >= 82 THEN 'Strong Match'
|
||||||
|
WHEN r.match_score >= 65 THEN 'Potential Match'
|
||||||
|
ELSE 'Weak Match'
|
||||||
|
END
|
||||||
|
FROM ranked r
|
||||||
|
WHERE r.rn = 1
|
||||||
|
AND m.id = r.inbox_message_id
|
||||||
|
AND m.ats_score IS NULL
|
||||||
|
$sql$;
|
||||||
|
|
||||||
-- One current ats_results row per already-scored application. computed_at takes
|
-- One current ats_results row per already-scored application. computed_at takes
|
||||||
-- the candidates row's timestamp so the history reflects when the score happened.
|
-- the candidates row's timestamp so the history reflects when the score happened.
|
||||||
-- inbox can hold several join rows per message; DISTINCT ON keeps the newest.
|
-- inbox can hold several join rows per message; DISTINCT ON keeps the newest.
|
||||||
WITH ranked AS (
|
EXECUTE $sql$
|
||||||
SELECT
|
WITH ranked AS (
|
||||||
c.inbox_message_id,
|
SELECT
|
||||||
c.job_id,
|
c.inbox_message_id,
|
||||||
c.match_score,
|
c.job_id,
|
||||||
c.model,
|
c.match_score,
|
||||||
c.updated_at,
|
c.model,
|
||||||
ROW_NUMBER() OVER (
|
c.updated_at,
|
||||||
PARTITION BY c.inbox_message_id
|
ROW_NUMBER() OVER (
|
||||||
ORDER BY (c.job_id = m.assigned_job_post_id) DESC NULLS LAST,
|
PARTITION BY c.inbox_message_id
|
||||||
c.updated_at DESC
|
ORDER BY (c.job_id = m.assigned_job_post_id) DESC NULLS LAST,
|
||||||
) AS rn
|
c.updated_at DESC
|
||||||
FROM app.candidates c
|
) AS rn
|
||||||
JOIN app.inbox_messages m ON m.id = c.inbox_message_id
|
FROM app.candidates c
|
||||||
WHERE c.status = 'completed'
|
JOIN app.inbox_messages m ON m.id = c.inbox_message_id
|
||||||
AND c.inbox_message_id IS NOT NULL
|
WHERE c.status = 'completed'
|
||||||
),
|
AND c.inbox_message_id IS NOT NULL
|
||||||
links AS (
|
),
|
||||||
SELECT DISTINCT ON (message_id) message_id, id AS inbox_id
|
links AS (
|
||||||
FROM app.inbox
|
SELECT DISTINCT ON (message_id) message_id, id AS inbox_id
|
||||||
ORDER BY message_id, created_at DESC
|
FROM app.inbox
|
||||||
)
|
ORDER BY message_id, created_at DESC
|
||||||
INSERT INTO app.ats_results
|
)
|
||||||
(id, inbox_id, job_post_id, overall_score, band, is_current,
|
INSERT INTO app.ats_results
|
||||||
superseded_by_id, model_name, computed_at, created_at)
|
(id, inbox_id, job_post_id, overall_score, band, is_current,
|
||||||
SELECT
|
superseded_by_id, model_name, computed_at, created_at)
|
||||||
gen_random_uuid(),
|
SELECT
|
||||||
l.inbox_id,
|
gen_random_uuid(),
|
||||||
r.job_id,
|
l.inbox_id,
|
||||||
r.match_score,
|
r.job_id,
|
||||||
CASE
|
r.match_score,
|
||||||
WHEN r.match_score >= 82 THEN 'Strong Match'
|
CASE
|
||||||
WHEN r.match_score >= 65 THEN 'Potential Match'
|
WHEN r.match_score >= 82 THEN 'Strong Match'
|
||||||
ELSE 'Weak Match'
|
WHEN r.match_score >= 65 THEN 'Potential Match'
|
||||||
END,
|
ELSE 'Weak Match'
|
||||||
true,
|
END,
|
||||||
NULL,
|
true,
|
||||||
r.model,
|
NULL,
|
||||||
r.updated_at,
|
r.model,
|
||||||
NOW()
|
r.updated_at,
|
||||||
FROM ranked r
|
NOW()
|
||||||
JOIN links l ON l.message_id = r.inbox_message_id
|
FROM ranked r
|
||||||
WHERE r.rn = 1
|
JOIN links l ON l.message_id = r.inbox_message_id
|
||||||
AND NOT EXISTS (
|
WHERE r.rn = 1
|
||||||
SELECT 1 FROM app.ats_results a WHERE a.inbox_id = l.inbox_id
|
AND NOT EXISTS (
|
||||||
);
|
SELECT 1 FROM app.ats_results a WHERE a.inbox_id = l.inbox_id
|
||||||
|
)
|
||||||
|
$sql$;
|
||||||
|
|
||||||
|
-- Older inbox history rows predate candidate_id; link the current ones back to
|
||||||
|
-- the candidates row that produced them.
|
||||||
|
EXECUTE $sql$
|
||||||
|
UPDATE app.ats_results a
|
||||||
|
SET candidate_id = c.id
|
||||||
|
FROM app.inbox i
|
||||||
|
JOIN app.candidates c ON c.inbox_message_id = i.message_id AND c.status = 'completed'
|
||||||
|
WHERE a.inbox_id = i.id
|
||||||
|
AND a.candidate_id IS NULL
|
||||||
|
AND c.job_id = a.job_post_id
|
||||||
|
$sql$;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$backfill$;
|
||||||
|
|
||||||
-- inbox.ats_id -> the CURRENT ats_results row for that application, so the
|
-- inbox.ats_id -> the CURRENT ats_results row for that application, so the
|
||||||
-- score is one direct id join away. Newest current row wins if several exist.
|
-- score is one direct id join away. Newest current row wins if several exist.
|
||||||
|
|
@ -131,13 +163,3 @@ WHERE c.status = 'completed'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM app.ats_results a WHERE a.candidate_id = c.id
|
SELECT 1 FROM app.ats_results a WHERE a.candidate_id = c.id
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Older inbox history rows predate candidate_id; link the current ones back to
|
|
||||||
-- the candidates row that produced them.
|
|
||||||
UPDATE app.ats_results a
|
|
||||||
SET candidate_id = c.id
|
|
||||||
FROM app.inbox i
|
|
||||||
JOIN app.candidates c ON c.inbox_message_id = i.message_id AND c.status = 'completed'
|
|
||||||
WHERE a.inbox_id = i.id
|
|
||||||
AND a.candidate_id IS NULL
|
|
||||||
AND c.job_id = a.job_post_id;
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,6 @@ class Users(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
|
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
is_active: bool = Field(default=False)
|
is_active: bool = Field(default=False)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||||
<script type="module" crossorigin src="/assets/index-D1YVNnju.js"></script>
|
<script type="module" crossorigin src="/assets/index-D0GY3L2I.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,6 @@ export function toCandidateView(row) {
|
||||||
errorCode: row.error_code ?? null,
|
errorCode: row.error_code ?? null,
|
||||||
errorMessage: row.error_message ?? null,
|
errorMessage: row.error_message ?? null,
|
||||||
applied: row.created_at ? new Date(row.created_at) : null,
|
applied: row.created_at ? new Date(row.created_at) : null,
|
||||||
inboxMessageId: row.inbox_message_id ?? null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,7 +114,7 @@ export function listCandidateUsers({ roleId = 8, top = 500, skip = 0 } = {}) {
|
||||||
*
|
*
|
||||||
* A user account carries identity only. Everything the ATS produces
|
* A user account carries identity only. Everything the ATS produces
|
||||||
* (score, matched skills, critique, the job it was scored against) lives in the
|
* (score, matched skills, critique, the job it was scored against) lives in the
|
||||||
* `candidates` table keyed by inbox_message_id, with no user_id to join on, so
|
* `candidates` table keyed by job_id + content hash, with no user_id to join on, so
|
||||||
* those fields are null here by construction rather than by omission.
|
* those fields are null here by construction rather than by omission.
|
||||||
*/
|
*/
|
||||||
export function toCandidateUserView(row) {
|
export function toCandidateUserView(row) {
|
||||||
|
|
@ -141,7 +140,6 @@ export function toCandidateUserView(row) {
|
||||||
scoringStatus: null,
|
scoringStatus: null,
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
inboxMessageId: null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
pipeline.js — the kanban board's endpoints (backend/job/app.py).
|
pipeline.js — the kanban board's endpoints (backend/job/app.py).
|
||||||
|
|
||||||
The board is stitched from two modules, because there is no pipeline-specific
|
The board is one read: GET /pipeline/candidates/fetch returns inbox +
|
||||||
READ endpoint:
|
manual_upload cards plus per-status counts. Dropping a card fires
|
||||||
|
PATCH /candidate/stage with `inbox_id` or `manual_upload_id`.
|
||||||
|
|
||||||
- rows come from GET /candidate/fetch (api/candidates.js `list`), the
|
Inbox stage lives on inbox_messages.application_status; manual stage lives
|
||||||
inbox -> users -> roles join, which is the only list payload carrying BOTH
|
on manual_upload_candidate.status (same Candidate_application_Status values).
|
||||||
`application_status` (the stage) and `inbox_id` (what the write below needs);
|
History is application_stage_transitions in both cases.
|
||||||
- the write is PATCH /candidate/stage, here.
|
|
||||||
|
|
||||||
Stage lives on inbox_messages.application_status and the transition history in
|
|
||||||
application_stage_transitions; the server closes the open interval and opens a
|
|
||||||
new one in the same commit, so the board never has to touch history itself.
|
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { request } from '../lib/apiClient'
|
import { request } from '../lib/apiClient'
|
||||||
|
|
@ -60,63 +56,107 @@ export const STATUS_FROM_STAGE = {
|
||||||
/**
|
/**
|
||||||
* Move one application to another stage. Requires pipeline.edit.
|
* Move one application to another stage. Requires pipeline.edit.
|
||||||
*
|
*
|
||||||
* `inboxId` is the INTEGER inbox.id — the row the candidate profile returns as
|
* Inbox cards send `inboxId` (INTEGER inbox.id). Manual-upload cards send
|
||||||
* `inbox_id`, not the inbox_messages uuid the Inbox screen calls `id`; the route
|
* `manualUploadId` (manual_upload_candidate.id UUID). The server 400s if both
|
||||||
* runs int() on it and 404s on anything else.
|
* or neither are present, and 400s a no-op move ("already at stage").
|
||||||
*
|
|
||||||
* The server rejects a no-op move with 400 ("already at stage"), so callers must
|
|
||||||
* not fire on a drop into the card's current column.
|
|
||||||
*/
|
*/
|
||||||
export function changeStage({ inboxId, toStage, changeReason }) {
|
export function changeStage({ inboxId, manualUploadId, toStage, changeReason }) {
|
||||||
return request('/candidate/stage', {
|
return request('/candidate/stage', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { inbox_id: inboxId, to_stage: toStage, change_reason: changeReason ?? null },
|
body: {
|
||||||
|
...(inboxId != null ? { inbox_id: inboxId } : {}),
|
||||||
|
...(manualUploadId != null ? { manual_upload_id: manualUploadId } : {}),
|
||||||
|
to_stage: toStage,
|
||||||
|
change_reason: changeReason ?? null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbox + manual-upload applications for the board — GET /pipeline/candidates/fetch
|
||||||
|
* (pipeline.view). Envelope is `{ data: { inbox, manual_upload }, counts, total }`.
|
||||||
|
* `jobId === ''` (All Jobs) is dropped by buildUrl and sends no filter.
|
||||||
|
*/
|
||||||
|
export function listApplications({ jobId, limit, offset } = {}) {
|
||||||
|
return request('/pipeline/candidates/fetch', {
|
||||||
|
params: { job_post_id: jobId, limit, offset },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN)
|
||||||
|
* land in Applied, same as STAGE_FROM_STATUS's card fallback.
|
||||||
|
*/
|
||||||
|
export function toStageCounts(byStatus) {
|
||||||
|
const counts = Object.fromEntries(Object.keys(STATUS_FROM_STAGE).map((name) => [name, 0]))
|
||||||
|
for (const [status, n] of Object.entries(byStatus || {})) {
|
||||||
|
const stage = STAGE_FROM_STATUS[status] ?? 'Applied'
|
||||||
|
counts[stage] = (counts[stage] ?? 0) + (n || 0)
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage history for one application — GET /pipeline/transitions/fetch
|
* Stage history for one application — GET /pipeline/transitions/fetch
|
||||||
* (pipeline.view). Rows are valid-time intervals: `valid_to` null is the stage
|
* (pipeline.view). Rows are valid-time intervals: `valid_to` null is the stage
|
||||||
* the candidate is in now. The board itself does not render history; this is the
|
* the candidate is in now. The board itself does not render history; this is the
|
||||||
* feed behind a stage timeline on the profile.
|
* feed behind a stage timeline on the profile.
|
||||||
*
|
*
|
||||||
* One of inboxId / transitionId is required — the route 400s with neither.
|
* One of inboxId / manualUploadId / transitionId is required — the route 400s
|
||||||
|
* with none of them.
|
||||||
*/
|
*/
|
||||||
export function listTransitions({ inboxId, transitionId } = {}) {
|
export function listTransitions({ inboxId, manualUploadId, transitionId } = {}) {
|
||||||
return request('/pipeline/transitions/fetch', {
|
return request('/pipeline/transitions/fetch', {
|
||||||
params: { inbox_id: inboxId, transition_id: transitionId },
|
params: { inbox_id: inboxId, manual_upload_id: manualUploadId, transition_id: transitionId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function sourceFields(row, kind) {
|
||||||
* Candidate-profile row -> one kanban card.
|
if (kind === 'manual') {
|
||||||
*
|
return {
|
||||||
* `id` is the inbox id, not the user id: the board is one card per APPLICATION
|
id: `manual:${row.id}`,
|
||||||
* and `inbox` holds one row per (user, message), so a candidate who mailed us
|
inboxId: null,
|
||||||
* three times legitimately occupies three cards with three independent stages.
|
manualUploadId: row.id,
|
||||||
* `userId` rides along for the deep link into the profile.
|
jobId: row.job_post_id ?? null,
|
||||||
*
|
jobTitle: row.title ?? null,
|
||||||
* Skills are absent by construction — the list payload carries ai_score but not
|
currentTitle: row.current_position || null,
|
||||||
* matched_keywords (job/candidate/views.py::attach_job_posts sets only the
|
currentCompany: row.current_company || null,
|
||||||
* score), so the card drops its tag row rather than rendering three blanks.
|
}
|
||||||
*/
|
}
|
||||||
export function toBoardCard(row) {
|
|
||||||
const jobTitle = row.job_title ?? row.assigned_job_post?.title ?? null
|
|
||||||
return {
|
return {
|
||||||
id: row.inbox_id,
|
id: row.inbox_id,
|
||||||
inboxId: row.inbox_id,
|
inboxId: row.inbox_id,
|
||||||
|
manualUploadId: null,
|
||||||
|
jobId: row.assigned_job_post_id ?? null,
|
||||||
|
jobTitle: row.title ?? null,
|
||||||
|
currentTitle: row.current_title || null,
|
||||||
|
currentCompany: row.current_employment || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline inbox or manual-upload row -> one kanban card.
|
||||||
|
*
|
||||||
|
* Inbox `id` is the inbox id, not the user id: the board is one card per
|
||||||
|
* APPLICATION. `userId` rides along for the deep link into the profile.
|
||||||
|
*/
|
||||||
|
export function toBoardCard(row, kind = 'inbox') {
|
||||||
|
const src = sourceFields(row, kind)
|
||||||
|
return {
|
||||||
|
...src,
|
||||||
userId: row.user_id ?? null,
|
userId: row.user_id ?? null,
|
||||||
name: row.name || row.email || 'Unknown',
|
name: row.name || row.email || 'Unknown',
|
||||||
email: row.email ?? null,
|
email: row.email ?? null,
|
||||||
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied',
|
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied',
|
||||||
status: row.application_status ?? null,
|
status: row.application_status ?? null,
|
||||||
jobId: row.assigned_job_post_id ?? null,
|
|
||||||
jobTitle,
|
|
||||||
currentTitle: row.current_title || null,
|
|
||||||
currentCompany: row.current_employment || null,
|
|
||||||
experience: row.experience || null,
|
experience: row.experience || null,
|
||||||
aiScore: row.ai_score ?? null,
|
aiScore: row.ats_result?.overall_score ?? null,
|
||||||
recommendation: row.recommendation ?? null,
|
recommendation: row.ats_result?.band ?? null,
|
||||||
applied: row.created_at ? new Date(row.created_at) : null,
|
applied: row.created_at ? new Date(row.created_at) : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /pipeline/candidates/fetch `manual_upload` row -> one kanban card. */
|
||||||
|
export function toManualBoardCard(row) {
|
||||||
|
return toBoardCard(row, 'manual')
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,28 @@
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Pipeline — the kanban board, on live backend data.
|
Pipeline — the kanban board, on live backend data.
|
||||||
|
|
||||||
Cards come from GET /candidate/fetch (the inbox -> users -> roles join), the
|
Cards and column counts come from GET /pipeline/candidates/fetch. Dropping a
|
||||||
only list payload that carries the stage (`application_status`) together with
|
card fires PATCH /candidate/stage with `inbox_id` or `manual_upload_id`. The
|
||||||
the `inbox_id` that PATCH /candidate/stage writes against. Dropping a card
|
server closes the open application_stage_transitions interval and opens a
|
||||||
fires that PATCH; the server closes the open application_stage_transitions
|
new one in the same commit.
|
||||||
interval and opens a new one in the same commit.
|
|
||||||
|
|
||||||
The job filter reads live posts from GET /job/fetch and matches on
|
The job filter is server-side (`job_post_id`). Applications with no assigned
|
||||||
`assigned_job_post_id`, so an application nobody has assigned to a post shows
|
post are omitted by the list query, so they do not appear under All Jobs.
|
||||||
under All Jobs only.
|
|
||||||
|
|
||||||
The card's skill tags are gone: the list payload has ai_score but no
|
The card's skill tags are gone: the list payload has ats_result but no
|
||||||
matched_keywords, and the Candidates screen set the precedent that a column
|
matched_keywords, and the Candidates screen set the precedent that a column
|
||||||
with no source is dropped rather than rendered as blanks.
|
with no source is dropped rather than rendered as blanks.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { Avatar, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
import { Avatar, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
import * as candidatesApi from '../api/candidates'
|
|
||||||
import * as jobPostsApi from '../api/jobPosts'
|
import * as jobPostsApi from '../api/jobPosts'
|
||||||
import * as pipelineApi from '../api/pipeline'
|
import * as pipelineApi from '../api/pipeline'
|
||||||
|
|
||||||
|
|
@ -40,18 +37,21 @@ export const KANBAN_STAGES = [
|
||||||
{ name: 'Rejected', color: 'var(--stage-7)' },
|
{ name: 'Rejected', color: 'var(--stage-7)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/* /candidate/fetch pages with limit/offset and has no job filter, so the board
|
|
||||||
pulls one page and filters client-side. Rows past this are not on the board —
|
|
||||||
the header says so rather than silently showing a partial pipeline. */
|
|
||||||
const BOARD_LIMIT = 200
|
const BOARD_LIMIT = 200
|
||||||
const BOARD_KEY = qk.pipeline.board({ limit: BOARD_LIMIT })
|
|
||||||
|
|
||||||
const JOB_LIMIT = 100
|
const JOB_LIMIT = 100
|
||||||
|
|
||||||
async function fetchBoard() {
|
async function fetchBoard(jobId) {
|
||||||
const res = await candidatesApi.list({ limit: BOARD_LIMIT })
|
const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT })
|
||||||
const rows = candidatesApi.toRows(res)
|
const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : []
|
||||||
return { cards: rows.map(pipelineApi.toBoardCard), total: res?.total ?? rows.length }
|
const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : []
|
||||||
|
return {
|
||||||
|
cards: [
|
||||||
|
...inbox.map((row) => pipelineApi.toBoardCard(row)),
|
||||||
|
...manuals.map((row) => pipelineApi.toManualBoardCard(row)),
|
||||||
|
],
|
||||||
|
total: res?.total ?? 0,
|
||||||
|
stageCounts: pipelineApi.toStageCounts(res?.counts?.by_status),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJobs() {
|
async function fetchJobs() {
|
||||||
|
|
@ -74,53 +74,67 @@ export default function Pipeline() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const board = useQuery({ queryKey: BOARD_KEY, queryFn: fetchBoard })
|
|
||||||
const { data: jobs = [] } = useQuery({ queryKey: qk.jobPosts.list({ top: JOB_LIMIT }), queryFn: fetchJobs })
|
|
||||||
|
|
||||||
const [jobId, setJobId] = useState('')
|
const [jobId, setJobId] = useState('')
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
const [overStage, setOverStage] = useState(null)
|
const [overStage, setOverStage] = useState(null)
|
||||||
|
|
||||||
|
const boardKey = useMemo(
|
||||||
|
() => qk.pipeline.board({ limit: BOARD_LIMIT, jobId: jobId || null }),
|
||||||
|
[jobId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const board = useQuery({
|
||||||
|
queryKey: boardKey,
|
||||||
|
queryFn: () => fetchBoard(jobId),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
const { data: jobs = [] } = useQuery({ queryKey: qk.jobPosts.list({ top: JOB_LIMIT }), queryFn: fetchJobs })
|
||||||
|
|
||||||
/* The route is behind pipeline.view, but the WRITE needs pipeline.edit — a
|
/* The route is behind pipeline.view, but the WRITE needs pipeline.edit — a
|
||||||
viewer gets a read-only board instead of drags that 403 on drop. */
|
viewer gets a read-only board instead of drags that 403 on drop. */
|
||||||
const canEdit = can('pipeline.edit')
|
const canEdit = can('pipeline.edit')
|
||||||
|
|
||||||
const candidates = board.data?.cards ?? []
|
const candidates = board.data?.cards ?? []
|
||||||
const total = board.data?.total ?? 0
|
const total = board.data?.total ?? 0
|
||||||
|
const stageCounts = board.data?.stageCounts ?? {}
|
||||||
const list = useMemo(
|
|
||||||
() => (jobId ? candidates.filter((c) => c.jobId === jobId) : candidates),
|
|
||||||
[candidates, jobId],
|
|
||||||
)
|
|
||||||
|
|
||||||
const byStage = useMemo(() => {
|
const byStage = useMemo(() => {
|
||||||
const map = Object.fromEntries(KANBAN_STAGES.map((s) => [s.name, []]))
|
const map = Object.fromEntries(KANBAN_STAGES.map((s) => [s.name, []]))
|
||||||
for (const c of list) if (map[c.stage]) map[c.stage].push(c)
|
for (const c of candidates) if (map[c.stage]) map[c.stage].push(c)
|
||||||
return map
|
return map
|
||||||
}, [list])
|
}, [candidates])
|
||||||
|
|
||||||
/* Optimistic: a drag that only repaints after the round trip reads as a failed
|
/* Optimistic: a drag that only repaints after the round trip reads as a failed
|
||||||
drop. The card snaps back on error and the server's own value wins on the
|
drop. The card snaps back on error and the server's own value wins on the
|
||||||
refetch in onSettled. */
|
refetch in onSettled. Counts move with the card so badges do not lag. */
|
||||||
const move = useMutation({
|
const move = useMutation({
|
||||||
mutationFn: ({ card, stage }) =>
|
mutationFn: ({ card, stage }) =>
|
||||||
pipelineApi.changeStage({
|
pipelineApi.changeStage({
|
||||||
inboxId: card.inboxId,
|
inboxId: card.inboxId,
|
||||||
|
manualUploadId: card.manualUploadId,
|
||||||
toStage: pipelineApi.STATUS_FROM_STAGE[stage],
|
toStage: pipelineApi.STATUS_FROM_STAGE[stage],
|
||||||
}),
|
}),
|
||||||
onMutate: async ({ card, stage }) => {
|
onMutate: async ({ card, stage }) => {
|
||||||
await qc.cancelQueries({ queryKey: BOARD_KEY })
|
await qc.cancelQueries({ queryKey: boardKey })
|
||||||
const previous = qc.getQueryData(BOARD_KEY)
|
const previous = qc.getQueryData(boardKey)
|
||||||
qc.setQueryData(BOARD_KEY, (old) =>
|
qc.setQueryData(boardKey, (old) => {
|
||||||
old && {
|
if (!old) return old
|
||||||
|
const from = card.stage
|
||||||
|
const nextCounts = { ...old.stageCounts }
|
||||||
|
if (from && from !== stage) {
|
||||||
|
nextCounts[from] = Math.max(0, (nextCounts[from] ?? 0) - 1)
|
||||||
|
nextCounts[stage] = (nextCounts[stage] ?? 0) + 1
|
||||||
|
}
|
||||||
|
return {
|
||||||
...old,
|
...old,
|
||||||
cards: old.cards.map((c) => (c.inboxId === card.inboxId ? { ...c, stage } : c)),
|
cards: old.cards.map((c) => (c.id === card.id ? { ...c, stage } : c)),
|
||||||
},
|
stageCounts: nextCounts,
|
||||||
)
|
}
|
||||||
|
})
|
||||||
return { previous }
|
return { previous }
|
||||||
},
|
},
|
||||||
onError: (err, _vars, ctx) => {
|
onError: (err, _vars, ctx) => {
|
||||||
if (ctx?.previous) qc.setQueryData(BOARD_KEY, ctx.previous)
|
if (ctx?.previous) qc.setQueryData(boardKey, ctx.previous)
|
||||||
toast(friendlyAuthError(err, 'Could not move the candidate.'), 'error')
|
toast(friendlyAuthError(err, 'Could not move the candidate.'), 'error')
|
||||||
},
|
},
|
||||||
onSuccess: (_data, { card, stage }) => toast(`${card.name} moved to ${stage}`, 'success'),
|
onSuccess: (_data, { card, stage }) => toast(`${card.name} moved to ${stage}`, 'success'),
|
||||||
|
|
@ -140,7 +154,7 @@ export default function Pipeline() {
|
||||||
const cand = candidates.find((c) => c.id === id)
|
const cand = candidates.find((c) => c.id === id)
|
||||||
// A no-op move is a 400 server-side ("already at stage"), so it never leaves.
|
// A no-op move is a 400 server-side ("already at stage"), so it never leaves.
|
||||||
if (!cand || cand.stage === stage) return
|
if (!cand || cand.stage === stage) return
|
||||||
if (cand.inboxId == null) {
|
if (cand.inboxId == null && cand.manualUploadId == null) {
|
||||||
toast(`${cand.name} has no application to move`, 'warning')
|
toast(`${cand.name} has no application to move`, 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +204,7 @@ export default function Pipeline() {
|
||||||
<div className="kanban-col-head">
|
<div className="kanban-col-head">
|
||||||
<span className="k-dot" style={{ background: st.color }} />
|
<span className="k-dot" style={{ background: st.color }} />
|
||||||
<h4>{st.name}</h4>
|
<h4>{st.name}</h4>
|
||||||
<span className="k-count">{cards.length}</span>
|
<span className="k-count">{stageCounts[st.name] ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`kanban-cards${overStage === st.name ? ' drag-over' : ''}`}
|
className={`kanban-cards${overStage === st.name ? ' drag-over' : ''}`}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
|
||||||
const filename =
|
const filename =
|
||||||
live?.documents?.[0]?.name || c.filename || null
|
live?.documents?.[0]?.name || c.filename || null
|
||||||
const matchSummary = live?.match_summary ?? null
|
const matchSummary = live?.match_summary ?? null
|
||||||
const messageId = live?.message_id ?? c.inboxMessageId ?? null
|
const messageId = live?.message_id ?? null
|
||||||
const aiScore = live?.ai_score ?? c.aiScore ?? null
|
const aiScore = live?.ai_score ?? c.aiScore ?? null
|
||||||
const matchedSkills = live?.matched_keywords ?? c.matchedSkills ?? []
|
const matchedSkills = live?.matched_keywords ?? c.matchedSkills ?? []
|
||||||
const missingSkills = live?.missing_keywords ?? c.missingSkills ?? []
|
const missingSkills = live?.missing_keywords ?? c.missingSkills ?? []
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue