HR-ATS-Portal/app/core/config.py

134 lines
5.3 KiB
Python

"""Environment-backed settings.
Deliberate omissions:
* No ``temperature`` / ``top_p``. The reasoning models this service targets reject
them, and sampling was never the right lever for a scoring task anyway. Steer the
model with the system prompt and structured outputs instead.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Sole secrets file: backend/.env (repo root .env is not used).
_BACKEND_ENV = Path(__file__).resolve().parents[2] / "backend" / ".env"
# Model families that support structured outputs (``responses.parse``) and a reasoning
# effort setting. A prefix check rather than an exact allowlist: OpenAI ships point
# releases faster than this file can be updated, and rejecting a brand-new gpt-5.x
# would be worse than the small risk of admitting one with a different feature set.
#
# gpt-4o-mini is admitted: its only snapshot (2024-07-18) supports structured outputs
# and it is the production model. The wider gpt-4o family stays excluded because
# snapshots before 2024-08-06 lack structured outputs and aliases do not say which
# snapshot you get. It is not a reasoning model, so `reasoning` is omitted for it.
SUPPORTED_MODEL_PREFIXES: tuple[str, ...] = ("gpt-5", "gpt-4.1", "o3", "o4", "gpt-4o-mini")
# "-chat-latest" variants track the ChatGPT product surface rather than the API model
# line and do not expose reasoning effort.
UNSUPPORTED_MODEL_SUFFIXES: tuple[str, ...] = ("-chat-latest",)
# Mirrors openai.types.shared.reasoning_effort.ReasoningEffort. Per-model support
# varies; the API rejects a level the chosen model does not implement.
EFFORT_LEVELS: frozenset[str] = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
# Families that accept a `reasoning` parameter. gpt-4.1 is allowed as a model but is
# not a reasoning model -- sending `reasoning` to it is a 400, so the adapter omits it.
REASONING_MODEL_PREFIXES: tuple[str, ...] = ("gpt-5", "o3", "o4")
def supports_reasoning(model: str) -> bool:
return model.startswith(REASONING_MODEL_PREFIXES)
class Settings(BaseSettings):
"""Runtime configuration. Immutable once constructed."""
model_config = SettingsConfigDict(
env_file=_BACKEND_ENV,
# utf-8-sig, not utf-8: Windows editors and PowerShell's `-Encoding utf8`
# write a BOM, which would otherwise become part of the first variable's
# name and silently blank out that setting.
env_file_encoding="utf-8-sig",
extra="ignore",
frozen=True,
)
openai_api_key: str = ""
openai_model: str = "gpt-5.4-mini"
# Floor, not a suggestion: on a reasoning model this budget covers reasoning
# tokens *and* the visible response. Anything lower truncates mid-JSON and the
# candidate fails with MODEL_RESPONSE_INVALID.
openai_max_output_tokens: int = Field(default=4000, ge=2048)
openai_effort: str = "low"
openai_max_retries: int = Field(default=3, ge=0)
openai_timeout_seconds: float = Field(default=120.0, gt=0)
# OpenAI prompt caching is automatic and cannot be switched off. This toggle only
# controls whether a `prompt_cache_key` routing hint is sent (see services/llm.py).
openai_enable_prompt_cache: bool = True
scoring_concurrency: int = Field(default=5, ge=1)
max_resumes_per_request: int = Field(default=50, ge=1)
max_pdf_size_mb: int = Field(default=10, ge=1)
max_jd_chars: int = Field(default=30_000, ge=1)
max_resume_chars: int = Field(default=60_000, ge=1)
log_format: str = "json"
log_level: str = "INFO"
@field_validator("openai_model")
@classmethod
def _validate_model(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("OPENAI_MODEL must not be empty.")
if value.endswith(UNSUPPORTED_MODEL_SUFFIXES):
raise ValueError(
f"OPENAI_MODEL={value!r} is a chat-product variant and does not expose "
"reasoning effort. Use the corresponding API model instead."
)
if not value.startswith(SUPPORTED_MODEL_PREFIXES):
families = ", ".join(SUPPORTED_MODEL_PREFIXES)
raise ValueError(
f"OPENAI_MODEL={value!r} is not a known structured-outputs model family. "
f"Expected one of: {families}. If a newer family should be allowed, add "
"its prefix to SUPPORTED_MODEL_PREFIXES."
)
return value
@field_validator("openai_effort")
@classmethod
def _validate_effort(cls, value: str) -> str:
value = value.strip().lower()
if value not in EFFORT_LEVELS:
raise ValueError(
f"OPENAI_EFFORT={value!r} is invalid. "
f"Supported: {', '.join(sorted(EFFORT_LEVELS))}."
)
return value
@field_validator("log_format")
@classmethod
def _validate_log_format(cls, value: str) -> str:
value = value.strip().lower()
if value not in {"json", "text"}:
raise ValueError("LOG_FORMAT must be 'json' or 'text'.")
return value
@property
def max_pdf_size_bytes(self) -> int:
return self.max_pdf_size_mb * 1024 * 1024
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()