Integrate bulk-ats scoring into backend: candidates table + scoring routes
Embeds the tested bulk-ats engine (pip install -e ., app.services.*) rather than duplicating it. New Candidates table (auto-migrated) persists one row per CV per job, deduped on (job_id, content_sha256) so re-scores update in place. CandidateScoring service extracts (pypdf off-loop), despaces, scores with bounded concurrency + cache priming via llm_setup's shared client, and isolates per-file failures as failed rows. Routes: /candidate/score (uploads), /candidate/score_inbox (decoded attachments), /candidate/fetch, /candidate/fetch_by_id. Complements the agent inbox-match flow: it routes, this scores. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Dashboard_Wiring
parent
c5c150d02d
commit
a68d4bc2f6
|
|
@ -44,6 +44,16 @@ OPENAI_BASE_URL=
|
||||||
OPENAI_ORGANIZATION=
|
OPENAI_ORGANIZATION=
|
||||||
OPENAI_PROJECT=
|
OPENAI_PROJECT=
|
||||||
|
|
||||||
|
# ATS scoring (bulk-ats engine embedded via `pip install -e ..`).
|
||||||
|
# OPENAI_API_KEY / OPENAI_MODEL / OPENAI_MAX_OUTPUT_TOKENS above are shared.
|
||||||
|
OPENAI_EFFORT=low
|
||||||
|
OPENAI_ENABLE_PROMPT_CACHE=true
|
||||||
|
SCORING_CONCURRENCY=5
|
||||||
|
MAX_RESUMES_PER_REQUEST=50
|
||||||
|
MAX_PDF_SIZE_MB=10
|
||||||
|
MAX_JD_CHARS=30000
|
||||||
|
MAX_RESUME_CHARS=60000
|
||||||
|
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
TASKIQ_QUEUE_NAME=inbox
|
TASKIQ_QUEUE_NAME=inbox
|
||||||
TASKIQ_MAX_RETRIES=3
|
TASKIQ_MAX_RETRIES=3
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ from fastapi import APIRouter,Depends,Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
from job.candidate.views import FileRead
|
from job.candidate.views import CandidateScoring,FileRead
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from users.permissions import PermissionTag, require_permission
|
from users.permissions import PermissionTag, require_permission
|
||||||
from job.job_post.views import JobPost,JobPostCreate
|
from job.job_post.views import JobPost,JobPostCreate
|
||||||
import logging
|
import logging
|
||||||
from job.job_post.plugins import PlatformAlias
|
from job.job_post.plugins import PlatformAlias
|
||||||
from fastapi import UploadFile, File
|
from fastapi import UploadFile, File, Form
|
||||||
|
from pydantic import BaseModel
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from datetime import datetime, time, timezone
|
from datetime import datetime, time, timezone
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
@ -102,21 +103,75 @@ async def buffer_channels(
|
||||||
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("/candidate/fetch")
|
class InboxScoreRequest(BaseModel):
|
||||||
# async def fetch_candidate(
|
job_id: str
|
||||||
# user_id:str=Query(None),
|
message_ids: list[str] # inbox_messages PK uuids, not Graph message ids
|
||||||
# current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
|
||||||
# session: AsyncSession = Depends(get_session),
|
|
||||||
# ):
|
@router.post("/candidate/score")
|
||||||
# try:
|
async def score_candidates(
|
||||||
# service=CandidateView(session=session)
|
job_id: str = Form(...),
|
||||||
# if user_id:
|
files: list[UploadFile] = File(...),
|
||||||
# data=await service.get_candidate(user_id=user_id)
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
# else:
|
session: AsyncSession = Depends(get_session),
|
||||||
# data=await service.get_candidate()
|
):
|
||||||
# return JSONResponse(content={"data":data,"status_code":200})
|
"""Score uploaded CV PDFs against a job post; persists and returns the leaderboard."""
|
||||||
# except HTTPException:
|
try:
|
||||||
# raise
|
pairs=[(f.filename,await f.read()) for f in files]
|
||||||
# except Exception as e:
|
service=CandidateScoring(session=session)
|
||||||
# raise HTTPException(status_code=500,detail=str(e))
|
data=await service.score_uploads(job_id,pairs,current_user)
|
||||||
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidate/score_inbox")
|
||||||
|
async def score_inbox_candidates(
|
||||||
|
payload: InboxScoreRequest,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Score the decoded attachments of inbox messages against a job post."""
|
||||||
|
try:
|
||||||
|
service=CandidateScoring(session=session)
|
||||||
|
data=await service.score_inbox(payload.job_id,payload.message_ids,current_user)
|
||||||
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidate/fetch")
|
||||||
|
async def fetch_candidates(
|
||||||
|
job_id: str = Query(...),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Persisted leaderboard for a job: completed by score desc, failures last."""
|
||||||
|
try:
|
||||||
|
service=CandidateScoring(session=session)
|
||||||
|
data=await service.fetch_candidates(job_id)
|
||||||
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidate/fetch_by_id")
|
||||||
|
async def fetch_candidate_by_id(
|
||||||
|
candidate_id: str = Query(...),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=CandidateScoring(session=session)
|
||||||
|
data=await service.fetch_candidate_by_id(candidate_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))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,121 @@
|
||||||
# from sqlmodel import SQLModel, Field
|
import uuid
|
||||||
# from uuid import UUID, uuid4
|
from datetime import datetime, timezone
|
||||||
# from datetime import datetime
|
|
||||||
# from enum import Enum
|
|
||||||
|
|
||||||
# class CV_extraction(SQLModel,table=True):
|
from sqlalchemy import JSON, DateTime, UniqueConstraint
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlmodel import Field, SQLModel, select
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
__table_args__ = (UniqueConstraint("job_id", "content_sha256"),)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
job_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True)
|
||||||
|
source: str = Field(default="upload") # "upload" | "inbox"
|
||||||
|
inbox_message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
|
||||||
|
filename: str
|
||||||
|
file_path: str | None = Field(default=None) # decoded-attachment path (inbox only)
|
||||||
|
content_sha256: str | None = Field(default=None, index=True)
|
||||||
|
|
||||||
|
candidate_name: str | None = Field(default=None)
|
||||||
|
job_title: str | None = Field(default=None)
|
||||||
|
current_company: str | None = Field(default=None)
|
||||||
|
years_experience: int | None = Field(default=None)
|
||||||
|
match_score: int | None = Field(default=None) # None on failed rows
|
||||||
|
matched_keywords: list[str] = Field(default_factory=list, sa_type=JSON)
|
||||||
|
missing_keywords: list[str] = Field(default_factory=list, sa_type=JSON)
|
||||||
|
summary_critique: str | None = Field(default=None)
|
||||||
|
|
||||||
|
status: str # "completed" | "failed"
|
||||||
|
error_code: str | None = Field(default=None)
|
||||||
|
error_message: str | None = Field(default=None)
|
||||||
|
model: str | None = Field(default=None) # which OPENAI_MODEL produced the score
|
||||||
|
|
||||||
|
created_by: uuid.UUID = Field(foreign_key="users.id")
|
||||||
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(record_id))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_candidate_by_id(cls, session: AsyncSession, record_id: str):
|
||||||
|
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()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_candidates_by_job(cls, session: AsyncSession, job_id: str):
|
||||||
|
"""Leaderboard order: completed by score desc, failures last, ties stable."""
|
||||||
|
uid = cls._as_uuid(job_id)
|
||||||
|
if uid is None:
|
||||||
|
return []
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.job_id == uid)
|
||||||
|
.order_by(
|
||||||
|
cls.status.asc(), # "completed" < "failed"
|
||||||
|
cls.match_score.desc().nulls_last(),
|
||||||
|
cls.created_at.asc(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
|
||||||
|
existing = None
|
||||||
|
sha = fields.get("content_sha256")
|
||||||
|
if sha:
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls).where(cls.job_id == fields["job_id"], cls.content_sha256 == sha)
|
||||||
|
)
|
||||||
|
existing = result.scalars().first()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
row = cls(**fields)
|
||||||
|
session.add(row)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# A concurrent request inserted the same (job_id, sha) first; take over
|
||||||
|
# that row and update it instead.
|
||||||
|
await session.rollback()
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls).where(cls.job_id == fields["job_id"], cls.content_sha256 == sha)
|
||||||
|
)
|
||||||
|
existing = result.scalars().first()
|
||||||
|
if existing is None:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
for key, value in fields.items():
|
||||||
|
setattr(existing, key, value)
|
||||||
|
existing.updated_at = _now()
|
||||||
|
session.add(existing)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(existing)
|
||||||
|
return existing
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""CV text cleanup helpers for the PDF extractor.
|
"""CV text cleanup, scoring-JD builder, and the ATS scorer singleton.
|
||||||
|
|
||||||
Pure module: no FastAPI imports and no HTTPException.
|
Pure module: no FastAPI imports and no HTTPException.
|
||||||
|
|
||||||
|
|
@ -6,14 +6,93 @@ Designer-made resumes position every glyph individually, so pypdf hands back
|
||||||
"S K I L L S" instead of "SKILLS". In that layout a single space is glyph
|
"S K I L L S" instead of "SKILLS". In that layout a single space is glyph
|
||||||
padding and a run of two or more spaces is the real word gap, which is what
|
padding and a run of two or more spaces is the real word gap, which is what
|
||||||
the `despace_line` decorator keys off to rebuild readable lines.
|
the `despace_line` decorator keys off to rebuild readable lines.
|
||||||
|
|
||||||
|
The scoring pieces reuse the bulk-ats engine, installed editable from the repo
|
||||||
|
root (`pip install -e ..` -> `import app.*`), and share llm_setup's process-wide
|
||||||
|
AsyncOpenAI client rather than opening a second connection pool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.services.llm import OpenAIScorer
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from job.candidate.decorators import despace_line, normalize_unicode
|
from job.candidate.decorators import despace_line, normalize_unicode
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Backend-local per-candidate error code for cases the bulk-ats engine never sees
|
||||||
|
# (its uploads always have bytes; inbox attachments can vanish from disk).
|
||||||
|
FILE_NOT_FOUND = "FILE_NOT_FOUND"
|
||||||
|
|
||||||
|
_scorer: OpenAIScorer | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_scoring_settings() -> Settings:
|
||||||
|
"""Validated scoring knobs (model family, token floor, size limits).
|
||||||
|
|
||||||
|
Reads real env vars, which load_dotenv() above has populated from the nearest
|
||||||
|
.env (backend/.env, else the repo root). Shared names (OPENAI_MODEL,
|
||||||
|
OPENAI_MAX_OUTPUT_TOKENS) therefore match what llm_setup uses.
|
||||||
|
"""
|
||||||
|
return get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def get_scorer() -> OpenAIScorer:
|
||||||
|
"""Process-wide scorer over llm_setup's shared AsyncOpenAI client.
|
||||||
|
|
||||||
|
Lazy so that a missing/invalid OPENAI configuration surfaces on the first
|
||||||
|
scoring request, not at import; llm_setup.init_llm() in the app lifespan has
|
||||||
|
normally created and verified the client before this ever runs.
|
||||||
|
"""
|
||||||
|
global _scorer
|
||||||
|
if _scorer is None:
|
||||||
|
from llm_setup import get_client
|
||||||
|
|
||||||
|
settings = get_scoring_settings()
|
||||||
|
_scorer = OpenAIScorer(
|
||||||
|
get_client(),
|
||||||
|
model=settings.openai_model,
|
||||||
|
max_output_tokens=settings.openai_max_output_tokens,
|
||||||
|
effort=settings.openai_effort,
|
||||||
|
enable_cache=settings.openai_enable_prompt_cache,
|
||||||
|
)
|
||||||
|
return _scorer
|
||||||
|
|
||||||
|
|
||||||
|
def build_job_description(job) -> str:
|
||||||
|
"""Deterministic scoring JD from a JobPosts row.
|
||||||
|
|
||||||
|
Byte-stable per job: derived only from stored column values, in fixed order,
|
||||||
|
because OpenAI prompt caching works on exact prefix match — one volatile byte
|
||||||
|
(an id, a timestamp) would stop the whole batch from reusing the cache.
|
||||||
|
Deliberately excludes post_text (hashtags, salary, LinkedIn formatting) and
|
||||||
|
salary; list order is taken as stored.
|
||||||
|
"""
|
||||||
|
lines = [f"Job Title: {job.title}"]
|
||||||
|
if job.employment_type:
|
||||||
|
lines.append(f"Employment Type: {job.employment_type}")
|
||||||
|
if job.location:
|
||||||
|
lines.append(f"Location: {job.location}")
|
||||||
|
if job.experience_min is not None and job.experience_max is not None:
|
||||||
|
lines.append(f"Experience Required: {job.experience_min}-{job.experience_max} years")
|
||||||
|
elif job.experience_min is not None:
|
||||||
|
lines.append(f"Experience Required: {job.experience_min}+ years")
|
||||||
|
elif job.experience_max is not None:
|
||||||
|
lines.append(f"Experience Required: up to {job.experience_max} years")
|
||||||
|
if job.description:
|
||||||
|
lines += ["", "Description:", job.description.strip()]
|
||||||
|
if job.requirements:
|
||||||
|
lines += ["", "Mandatory Requirements:"]
|
||||||
|
lines += [f"- {item}" for item in job.requirements]
|
||||||
|
if job.optional_skills:
|
||||||
|
lines += ["", "Preferred (nice to have):"]
|
||||||
|
lines += [f"- {item}" for item in job.optional_skills]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@normalize_unicode
|
@normalize_unicode
|
||||||
@despace_line
|
@despace_line
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
def serialize_candidate(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(row.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,
|
||||||
|
"filename": row.filename,
|
||||||
|
"file_path": row.file_path,
|
||||||
|
"content_sha256": row.content_sha256,
|
||||||
|
"candidate_name": row.candidate_name,
|
||||||
|
"job_title": row.job_title,
|
||||||
|
"current_company": row.current_company,
|
||||||
|
"years_experience": row.years_experience,
|
||||||
|
"match_score": row.match_score,
|
||||||
|
"matched_keywords": list(row.matched_keywords or []),
|
||||||
|
"missing_keywords": list(row.missing_keywords or []),
|
||||||
|
"summary_critique": row.summary_critique,
|
||||||
|
"status": row.status,
|
||||||
|
"error_code": row.error_code,
|
||||||
|
"error_message": row.error_message,
|
||||||
|
"model": row.model,
|
||||||
|
"created_by": str(row.created_by),
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,25 @@
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import os,logging,io
|
import asyncio,dataclasses,hashlib,os,logging,io,uuid
|
||||||
from datetime import datetime,timezone
|
from datetime import datetime,timezone
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pypdf import PdfReader
|
from pypdf import PdfReader
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlmodel import true
|
from sqlmodel import true
|
||||||
|
from app.core.errors import ATSError,ErrorCode
|
||||||
|
from app.models.scoring import CompletedCandidate
|
||||||
|
from app.services.pdf import extract_resume,sanitize_filename
|
||||||
|
from app.services.scoring import score_batch
|
||||||
from inbox.models import Inbox_Messages
|
from inbox.models import Inbox_Messages
|
||||||
from job.candidate.plugins import normalize_spaced_text
|
from job.candidate.models import Candidates
|
||||||
|
from job.candidate.plugins import (
|
||||||
|
FILE_NOT_FOUND,
|
||||||
|
build_job_description,
|
||||||
|
get_scorer,
|
||||||
|
get_scoring_settings,
|
||||||
|
normalize_spaced_text,
|
||||||
|
)
|
||||||
|
from job.candidate.serializers import serialize_candidate
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
class FileRead:
|
class FileRead:
|
||||||
def __init__(self,session:AsyncSession,filename=None,file=None):
|
def __init__(self,session:AsyncSession,filename=None,file=None):
|
||||||
|
|
@ -68,6 +81,199 @@ class FileRead:
|
||||||
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)
|
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)
|
||||||
# get_file=
|
# get_file=
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateScoring:
|
||||||
|
"""ATS scoring of CVs against one job post, persisted to the candidates table.
|
||||||
|
|
||||||
|
Complements the agent's inbox-match flow: the agent suggests WHICH job a CV is
|
||||||
|
for; this service scores HOW WELL a CV fits a chosen job (0-100 leaderboard).
|
||||||
|
|
||||||
|
Deviation from the bulk-ats HTTP API (which rejects a whole batch with 415/413
|
||||||
|
on a bad file): here per-file problems become persisted rows with
|
||||||
|
status="failed" so one broken attachment never sinks the rest of the batch.
|
||||||
|
Request-level errors (unknown job, too many files) still raise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,session:AsyncSession):
|
||||||
|
self.session=session
|
||||||
|
|
||||||
|
async def score_uploads(self,job_id,files,current_user):
|
||||||
|
"""files: list of (filename, bytes) pairs from the route handler."""
|
||||||
|
settings=get_scoring_settings()
|
||||||
|
if len(files)>settings.max_resumes_per_request:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"At most {settings.max_resumes_per_request} resumes per request",
|
||||||
|
)
|
||||||
|
sources=[]
|
||||||
|
for filename,data in files:
|
||||||
|
source={
|
||||||
|
"filename":filename or "resume.pdf",
|
||||||
|
"data":data,
|
||||||
|
"file_path":None,
|
||||||
|
"inbox_message_id":None,
|
||||||
|
"precheck":None,
|
||||||
|
}
|
||||||
|
if not (filename or "").lower().endswith(".pdf"):
|
||||||
|
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"Only PDF resumes are supported.")
|
||||||
|
elif len(data)>settings.max_pdf_size_bytes:
|
||||||
|
source["precheck"]=(ErrorCode.PAYLOAD_TOO_LARGE,"The file exceeds the size limit.")
|
||||||
|
sources.append(source)
|
||||||
|
return await self._score_and_persist(job_id,sources,"upload",current_user)
|
||||||
|
|
||||||
|
async def score_inbox(self,job_id,message_ids,current_user):
|
||||||
|
"""Score the decoded attachments of inbox messages (PK uuids, not Graph ids)."""
|
||||||
|
# Local import: inbox.plugins imports this module (FileRead), so a top-level
|
||||||
|
# import would be circular — same pattern as match_inbox_cv above.
|
||||||
|
from inbox.plugins import resolve_attachment_path
|
||||||
|
|
||||||
|
sources=[]
|
||||||
|
for mid in message_ids:
|
||||||
|
row=await Inbox_Messages.get_inbox_message_by_id(self.session,mid)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404,detail=f"Inbox message {mid} not found")
|
||||||
|
if not row.file_path:
|
||||||
|
continue
|
||||||
|
for path_str in (p.strip() for p in row.file_path.split(",") if p.strip()):
|
||||||
|
path=resolve_attachment_path(path_str)
|
||||||
|
source={
|
||||||
|
"filename":path.name,
|
||||||
|
"data":None,
|
||||||
|
"file_path":str(path),
|
||||||
|
"inbox_message_id":row.id,
|
||||||
|
"precheck":None,
|
||||||
|
}
|
||||||
|
suffix=path.suffix.lower()
|
||||||
|
if suffix in (".doc",".docx"):
|
||||||
|
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"DOC/DOCX extraction is not supported yet.")
|
||||||
|
elif suffix!=".pdf":
|
||||||
|
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"Only PDF resumes are supported.")
|
||||||
|
elif not path.is_file():
|
||||||
|
source["precheck"]=(FILE_NOT_FOUND,"The decoded attachment is missing on disk.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
source["data"]=await asyncio.to_thread(path.read_bytes)
|
||||||
|
except OSError:
|
||||||
|
source["precheck"]=(FILE_NOT_FOUND,"The decoded attachment could not be read.")
|
||||||
|
sources.append(source)
|
||||||
|
if not sources:
|
||||||
|
raise HTTPException(status_code=400,detail="No attachments found for the given message(s)")
|
||||||
|
return await self._score_and_persist(job_id,sources,"inbox",current_user)
|
||||||
|
|
||||||
|
async def fetch_candidates(self,job_id):
|
||||||
|
job=await JobPosts.get_job_post_by_id(self.session,job_id)
|
||||||
|
if job is None or job.is_deleted:
|
||||||
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
|
rows=await Candidates.get_candidates_by_job(self.session,job_id)
|
||||||
|
return [serialize_candidate(row) for row in rows]
|
||||||
|
|
||||||
|
async def fetch_candidate_by_id(self,candidate_id):
|
||||||
|
row=await Candidates.get_candidate_by_id(self.session,candidate_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404,detail="Candidate not found")
|
||||||
|
return serialize_candidate(row)
|
||||||
|
|
||||||
|
async def _score_and_persist(self,job_id,sources,source_kind,current_user):
|
||||||
|
job=await JobPosts.get_job_post_by_id(self.session,job_id)
|
||||||
|
if job is None or job.is_deleted:
|
||||||
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
|
settings=get_scoring_settings()
|
||||||
|
jd=build_job_description(job)
|
||||||
|
if len(jd)>settings.max_jd_chars:
|
||||||
|
raise HTTPException(status_code=422,detail="The job post is too large to score against")
|
||||||
|
|
||||||
|
# Slot-indexed like app/api/routes.py: results merge back by position, never
|
||||||
|
# by filename — inbox attachments can share a basename.
|
||||||
|
results_by_slot={}
|
||||||
|
extracted=[]
|
||||||
|
for slot,source in enumerate(sources):
|
||||||
|
source["safe_name"]=sanitize_filename(source["filename"])
|
||||||
|
data=source["data"]
|
||||||
|
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
|
||||||
|
if source["precheck"] is not None:
|
||||||
|
code,message=source["precheck"]
|
||||||
|
results_by_slot[slot]=self._failed_fields(source,code,message)
|
||||||
|
continue
|
||||||
|
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(
|
||||||
|
extract_resume,data,source["safe_name"],settings.max_resume_chars
|
||||||
|
)
|
||||||
|
resume=dataclasses.replace(resume,text=normalize_spaced_text(resume.text))
|
||||||
|
except ATSError as exc:
|
||||||
|
results_by_slot[slot]=self._failed_fields(source,exc.error_code,exc.public_message)
|
||||||
|
continue
|
||||||
|
extracted.append((slot,resume))
|
||||||
|
|
||||||
|
scored=await score_batch(
|
||||||
|
[resume for _,resume in extracted],
|
||||||
|
job_description=jd,
|
||||||
|
scorer=get_scorer(),
|
||||||
|
concurrency=settings.scoring_concurrency,
|
||||||
|
)
|
||||||
|
for (slot,_),result in zip(extracted,scored,strict=True):
|
||||||
|
source=sources[slot]
|
||||||
|
if isinstance(result,CompletedCandidate):
|
||||||
|
results_by_slot[slot]={
|
||||||
|
**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:
|
||||||
|
results_by_slot[slot]=self._failed_fields(source,result.error_code,result.error_message)
|
||||||
|
|
||||||
|
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)):
|
||||||
|
fields={**results_by_slot[slot],**common}
|
||||||
|
rows.append(await Candidates.upsert_candidate(self.session,fields))
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
}
|
||||||
|
|
||||||
# class CandidateView:
|
# class CandidateView:
|
||||||
# def __init__(self,session:AsyncSession):
|
# def __init__(self,session:AsyncSession):
|
||||||
# self.session=session
|
# self.session=session
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,9 @@ redis>=5.0,<6.0 # DLQ middleware (taskiq_management/middleware.py) as
|
||||||
# --- LLM -------------------------------------------------------------------
|
# --- LLM -------------------------------------------------------------------
|
||||||
openai==2.53.0 # AsyncOpenAI client in llm_setup.py
|
openai==2.53.0 # AsyncOpenAI client in llm_setup.py
|
||||||
langgraph==1.2.10 # StateGraph agent framework in agent/agent_setup.py
|
langgraph==1.2.10 # StateGraph agent framework in agent/agent_setup.py
|
||||||
|
|
||||||
|
# --- ATS scoring -----------------------------------------------------------
|
||||||
|
# The bulk-ats scoring engine (app.services.pdf / llm / scoring) is installed
|
||||||
|
# editable from the repo root — run once per environment:
|
||||||
|
# pip install -e ..
|
||||||
|
# Its dependencies are already satisfied by the pins above.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue