HR-ATS-Portal/app/models/scoring.py

110 lines
3.5 KiB
Python

"""Request/result models.
``extra="forbid"`` is load-bearing: it emits ``additionalProperties: false`` in the
generated JSON Schema, which structured outputs requires.
The remaining constraints (``ge``/``le``, string lengths, list lengths) are *not*
expressible in structured outputs -- the SDK strips them from the schema it sends and
re-applies them client-side during validation. They therefore act as a post-hoc
validation gate, not as a generation constraint. Normalization runs in ``mode="before"``
validators so that de-duplication happens *before* the length ceiling is enforced; a
model that returns 31 near-duplicate keywords collapses under the limit instead of
failing the candidate.
"""
from __future__ import annotations
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
def _normalize_keywords(value: Any) -> Any:
"""Trim, drop empties, de-duplicate case-insensitively, preserve first spelling."""
if not isinstance(value, list):
return value
seen: set[str] = set()
normalized: list[str] = []
for item in value:
if not isinstance(item, str):
continue
collapsed = " ".join(item.split())
if not collapsed:
continue
key = collapsed.casefold()
if key in seen:
continue
seen.add(key)
normalized.append(collapsed)
return normalized
class ATSScore(StrictModel):
# Profile fields are extracted verbatim from the resume; all are nullable because a
# resume may simply not state them, and null must stay distinguishable from "".
candidate_name: str | None = Field(default=None, max_length=120)
job_title: str | None = Field(default=None, max_length=120)
current_company: str | None = Field(default=None, max_length=120)
years_experience: int | None = Field(default=None, ge=0, le=60)
match_score: int = Field(ge=0, le=100)
matched_keywords: list[str] = Field(default_factory=list, max_length=30)
missing_keywords: list[str] = Field(default_factory=list, max_length=30)
summary_critique: str = Field(min_length=1, max_length=500)
@field_validator("matched_keywords", "missing_keywords", mode="before")
@classmethod
def _normalize(cls, value: Any) -> Any:
return _normalize_keywords(value)
@field_validator("candidate_name", "job_title", "current_company", mode="before")
@classmethod
def _blank_profile_text_to_none(cls, value: Any) -> Any:
if isinstance(value, str):
collapsed = " ".join(value.split())
return collapsed or None
return value
@field_validator("summary_critique", mode="before")
@classmethod
def _collapse_whitespace(cls, value: Any) -> Any:
if isinstance(value, str):
return " ".join(value.split())
return value
class CompletedCandidate(ATSScore):
filename: str
status: Literal["completed"] = "completed"
class FailedCandidate(StrictModel):
filename: str
status: Literal["failed"] = "failed"
error_code: str
error_message: str
CandidateResult = Annotated[
CompletedCandidate | FailedCandidate,
Field(discriminator="status"),
]
class ScoreResponse(StrictModel):
request_id: str
total: int
succeeded: int
failed: int
results: list[CandidateResult]
class ErrorResponse(StrictModel):
request_id: str
error_code: str
error_message: str