Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into SQS_BROKER
commit
25220d237b
|
|
@ -23,9 +23,11 @@ _BACKEND_ENV = Path(__file__).resolve().parents[2] / "backend" / ".env"
|
||||||
# releases faster than this file can be updated, and rejecting a brand-new gpt-5.x
|
# 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.
|
# 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
|
# gpt-4o-mini is admitted: its only snapshot (2024-07-18) supports structured outputs
|
||||||
# outputs, and distinguishing them by alias is not reliable.
|
# and it is the production model. The wider gpt-4o family stays excluded because
|
||||||
SUPPORTED_MODEL_PREFIXES: tuple[str, ...] = ("gpt-5", "gpt-4.1", "o3", "o4","gpt-4o-mini")
|
# 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
|
# "-chat-latest" variants track the ChatGPT product surface rather than the API model
|
||||||
# line and do not expose reasoning effort.
|
# line and do not expose reasoning effort.
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,9 @@ class ATSScore(StrictModel):
|
||||||
def _normalize(cls, value: Any) -> Any:
|
def _normalize(cls, value: Any) -> Any:
|
||||||
return _normalize_keywords(value)
|
return _normalize_keywords(value)
|
||||||
|
|
||||||
@field_validator("candidate_name", "job_title", "current_company", "professional_summary", mode="before")
|
@field_validator(
|
||||||
|
"candidate_name", "job_title", "current_company", "professional_summary", mode="before"
|
||||||
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
def _blank_profile_text_to_none(cls, value: Any) -> Any:
|
def _blank_profile_text_to_none(cls, value: Any) -> Any:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,13 @@ APIFY_PROFILE_MODE=Full
|
||||||
APIFY_TIMEOUT=30
|
APIFY_TIMEOUT=30
|
||||||
|
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_MODEL=gpt-5.4-mini
|
# Production model. Not a reasoning model: OPENAI_EFFORT is accepted and ignored.
|
||||||
|
# Define every OPENAI_* name once; python-dotenv takes the LAST occurrence.
|
||||||
|
OPENAI_MODEL=gpt-4o-mini-2024-07-18
|
||||||
# Blank omits the parameter, for reasoning models that reject it.
|
# Blank omits the parameter, for reasoning models that reject it.
|
||||||
OPENAI_TEMPERATURE=0
|
OPENAI_TEMPERATURE=0
|
||||||
OPENAI_MAX_OUTPUT_TOKENS=4096
|
# gpt-4o-mini rejects values above 16384 with a 400.
|
||||||
|
OPENAI_MAX_OUTPUT_TOKENS=4000
|
||||||
OPENAI_TIMEOUT=60
|
OPENAI_TIMEOUT=60
|
||||||
OPENAI_MAX_RETRIES=3
|
OPENAI_MAX_RETRIES=3
|
||||||
OPENAI_CONNECT_RETRIES=3
|
OPENAI_CONNECT_RETRIES=3
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,18 @@ Respond with JSON only:
|
||||||
|
|
||||||
|
|
||||||
def user_prompt(state) -> str:
|
def user_prompt(state) -> str:
|
||||||
|
"""The user turn as JSON.
|
||||||
|
|
||||||
|
job_posts comes first on purpose: it is identical for every CV in a sync run,
|
||||||
|
and OpenAI prompt caching works on an exact token prefix. With the stable
|
||||||
|
block ahead of the per-candidate subject and resume, every CV after the first
|
||||||
|
reads the whole job list from cache at the discounted input rate.
|
||||||
|
"""
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
|
"job_posts": state.get("job_posts") or [],
|
||||||
"subject": state.get("subject") or "",
|
"subject": state.get("subject") or "",
|
||||||
"resume_text": state.get("resume_text") or "",
|
"resume_text": state.get("resume_text") or "",
|
||||||
"job_posts": state.get("job_posts") or [],
|
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -483,8 +483,10 @@ class Email:
|
||||||
return serialize_inbox_rescan_run(row)
|
return serialize_inbox_rescan_run(row)
|
||||||
|
|
||||||
async def plan_on_hold_pairs(self,channel,sheet=None):
|
async def plan_on_hold_pairs(self,channel,sheet=None):
|
||||||
"""Build (candidate, job) pairs that have never been ATS-scored.
|
"""Build (candidate, active job) pairs that have never been ATS-scored.
|
||||||
|
|
||||||
|
Only active openings are scored: a score against a closed role is never
|
||||||
|
shown for shortlisting, and each pair is a paid model call.
|
||||||
When professional_summary is present, the summary-vs-job gradient runs
|
When professional_summary is present, the summary-vs-job gradient runs
|
||||||
before the already-scored pair skip: an obvious mismatch never reaches ATS.
|
before the already-scored pair skip: an obvious mismatch never reaches ATS.
|
||||||
Skip a candidate who already has a score against an active job.
|
Skip a candidate who already has a score against an active job.
|
||||||
|
|
@ -496,8 +498,8 @@ class Email:
|
||||||
from summary_gate.execute_agent import allow_ats
|
from summary_gate.execute_agent import allow_ats
|
||||||
from summary_gate.plugins import GATE_ENABLED
|
from summary_gate.plugins import GATE_ENABLED
|
||||||
|
|
||||||
job_ids=[str(jid) for jid in await JobPosts.list_ids(self.session)]
|
job_ids=[str(jid) for jid in await JobPosts.list_ids(self.session,active_only=True)]
|
||||||
active_ids={str(jid) for jid in await JobPosts.list_ids(self.session,active_only=True)}
|
active_ids=set(job_ids)
|
||||||
jd_by_id={}
|
jd_by_id={}
|
||||||
if GATE_ENABLED:
|
if GATE_ENABLED:
|
||||||
jobs=await JobPosts.get_by_ids(self.session,job_ids,active_only=False)
|
jobs=await JobPosts.get_by_ids(self.session,job_ids,active_only=False)
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@ OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL") or None
|
||||||
OPENAI_ORGANIZATION = os.getenv("OPENAI_ORGANIZATION") or None
|
OPENAI_ORGANIZATION = os.getenv("OPENAI_ORGANIZATION") or None
|
||||||
OPENAI_PROJECT = os.getenv("OPENAI_PROJECT") or None
|
OPENAI_PROJECT = os.getenv("OPENAI_PROJECT") or None
|
||||||
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5.4-mini")
|
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5.4-mini")
|
||||||
OPENAI_MAX_OUTPUT_TOKENS = int(os.getenv("OPENAI_MAX_OUTPUT_TOKENS") or 32768)
|
# Default kept under gpt-4o-mini's 16384 completion ceiling: a larger value is a 400
|
||||||
|
# on every call, not a bigger budget.
|
||||||
|
OPENAI_MAX_OUTPUT_TOKENS = int(os.getenv("OPENAI_MAX_OUTPUT_TOKENS") or 4096)
|
||||||
OPENAI_TIMEOUT = float(os.getenv("OPENAI_TIMEOUT") or 60)
|
OPENAI_TIMEOUT = float(os.getenv("OPENAI_TIMEOUT") or 60)
|
||||||
OPENAI_MAX_RETRIES = int(os.getenv("OPENAI_MAX_RETRIES") or 3)
|
OPENAI_MAX_RETRIES = int(os.getenv("OPENAI_MAX_RETRIES") or 3)
|
||||||
OPENAI_CONNECT_RETRIES = int(os.getenv("OPENAI_CONNECT_RETRIES") or 3)
|
OPENAI_CONNECT_RETRIES = int(os.getenv("OPENAI_CONNECT_RETRIES") or 3)
|
||||||
|
|
@ -80,6 +82,7 @@ async def llm_call(system, user, *, model=None, temperature=None, json_mode=Fals
|
||||||
kwargs["response_format"] = {"type": "json_object"}
|
kwargs["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
response = await get_client().chat.completions.create(**kwargs)
|
response = await get_client().chat.completions.create(**kwargs)
|
||||||
|
_log_usage(response, kwargs["model"])
|
||||||
content = (response.choices[0].message.content or "").strip()
|
content = (response.choices[0].message.content or "").strip()
|
||||||
if not json_mode:
|
if not json_mode:
|
||||||
return content
|
return content
|
||||||
|
|
@ -89,6 +92,22 @@ async def llm_call(system, user, *, model=None, temperature=None, json_mode=Fals
|
||||||
raise RuntimeError(f"model did not return valid JSON: {content[:200]}") from exc
|
raise RuntimeError(f"model did not return valid JSON: {content[:200]}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _log_usage(response, model) -> None:
|
||||||
|
"""Per-call token and cache visibility. Never logs prompt or reply text."""
|
||||||
|
usage = getattr(response, "usage", None)
|
||||||
|
if usage is None:
|
||||||
|
return
|
||||||
|
prompt_details = getattr(usage, "prompt_tokens_details", None)
|
||||||
|
logger.info(
|
||||||
|
"llm usage: model=%s request_id=%s prompt_tokens=%s completion_tokens=%s cached_tokens=%s",
|
||||||
|
model,
|
||||||
|
getattr(response, "_request_id", None),
|
||||||
|
getattr(usage, "prompt_tokens", None),
|
||||||
|
getattr(usage, "completion_tokens", None),
|
||||||
|
getattr(prompt_details, "cached_tokens", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def check_connection(retries=None, delay=1.0):
|
async def check_connection(retries=None, delay=1.0):
|
||||||
"""Confirm the key works, retrying with a capped backoff."""
|
"""Confirm the key works, retrying with a capped backoff."""
|
||||||
attempts = OPENAI_CONNECT_RETRIES if retries is None else retries
|
attempts = OPENAI_CONNECT_RETRIES if retries is None else retries
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue