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