"""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