132 lines
5.1 KiB
Python
132 lines
5.1 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.
|
|
#
|
|
# The gpt-4o family is excluded on purpose: snapshots before 2024-08-06 lack structured
|
|
# outputs, and distinguishing them by alias is not reliable.
|
|
SUPPORTED_MODEL_PREFIXES: tuple[str, ...] = ("gpt-5", "gpt-4.1", "o3", "o4")
|
|
|
|
# "-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()
|