HR-ATS-Portal/backend/summary_gate/agent_setup.py

121 lines
4.1 KiB
Python

"""Summary-gate adapter and its process-wide instance.
Pure module: no FastAPI imports and no HTTPException.
Mirrors inbox_classifier/agent_setup.py: responses.parse into a Pydantic
verdict, delivery-status branching, shared AsyncOpenAI client.
"""
from __future__ import annotations
import logging
from openai import AsyncOpenAI
from summary_gate.models import SummarySuitabilityVerdict
from summary_gate.plugins import PROMPT_CACHE_KEY, get_gate_settings
from summary_gate.prompt import SYSTEM_PROMPT, build_input
from app.core.config import supports_reasoning
from app.core.errors import ModelRefusedError, ModelResponseInvalidError, ModelUnavailableError
logger=logging.getLogger("summary.gate")
_TRUNCATED="max_output_tokens"
_FILTERED="content_filter"
def _first_refusal(response):
for item in getattr(response,"output",None) or []:
for part in getattr(item,"content",None) or []:
if getattr(part,"type",None)=="refusal":
refusal=getattr(part,"refusal",None)
return str(refusal) if refusal else "refused"
return None
class SummaryGate:
def __init__(self, client:AsyncOpenAI, model, max_output_tokens, effort, enable_cache=True):
self._client=client
self._model=model
self._max_output_tokens=max_output_tokens
self._effort=effort
self._enable_cache=enable_cache
self._supports_reasoning=supports_reasoning(model)
async def classify(self, job_description, summary) -> SummarySuitabilityVerdict:
kwargs={
"model":self._model,
"instructions":SYSTEM_PROMPT,
"input":build_input(job_description,summary),
"text_format":SummarySuitabilityVerdict,
"max_output_tokens":self._max_output_tokens,
}
if self._supports_reasoning:
kwargs["reasoning"]={"effort":self._effort}
if self._enable_cache:
kwargs["prompt_cache_key"]=PROMPT_CACHE_KEY
response=await self._client.responses.parse(**kwargs)
status=getattr(response,"status",None)
self._log_usage(response,status)
if status=="failed":
raise ModelUnavailableError("provider reported a failed response")
if status=="incomplete":
reason=getattr(getattr(response,"incomplete_details",None),"reason",None)
if reason==_FILTERED:
raise ModelRefusedError("content filter blocked the response")
if reason==_TRUNCATED:
raise ModelResponseInvalidError("response truncated at max_output_tokens")
raise ModelResponseInvalidError(f"incomplete response: {reason}")
if _first_refusal(response) is not None:
raise ModelRefusedError("model declined to classify this summary")
parsed=getattr(response,"output_parsed",None)
if not isinstance(parsed,SummarySuitabilityVerdict):
raise ModelResponseInvalidError("response did not parse into SummarySuitabilityVerdict")
return parsed
def _log_usage(self, response, status):
usage=getattr(response,"usage",None)
input_details=getattr(usage,"input_tokens_details",None)
output_details=getattr(usage,"output_tokens_details",None)
logger.info(
"summary gate: model=%s status=%s request_id=%s in=%s out=%s cached=%s reasoning=%s",
self._model,
status,
getattr(response,"id",None),
getattr(usage,"input_tokens",None),
getattr(usage,"output_tokens",None),
getattr(input_details,"cached_tokens",None),
getattr(output_details,"reasoning_tokens",None),
)
_gate=None
def get_gate() -> SummaryGate:
global _gate
if _gate is None:
from llm_setup import get_client
settings=get_gate_settings()
_gate=SummaryGate(
get_client(),
model=settings.openai_model,
max_output_tokens=settings.openai_max_output_tokens,
effort=settings.openai_effort,
enable_cache=settings.openai_enable_prompt_cache,
)
return _gate
def close_gate():
global _gate
_gate=None
logger.info("summary gate closed")