111 lines
3.6 KiB
Python
111 lines
3.6 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)
|
|
professional_summary: str | None = Field(default=None, 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", "professional_summary", 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
|