import json import re from pydantic import BaseModel from typing import List, Optional from openai import AsyncOpenAI from app.utils.logger import get_logger from config import apikey_settings, recovery_settings from app.utils.prompts.prompts import Prompt # 로거 설정 logger = get_logger("chatgpt") class ChatGPTResponseError(Exception): """ChatGPT API 응답 에러""" def __init__(self, status: str, error_code: str = None, error_message: str = None): self.status = status self.error_code = error_code self.error_message = error_message super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}") class ChatgptService: """ChatGPT API 서비스 클래스 """ model_type : str def __init__(self, model_type:str = "gpt", timeout: float = None): self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES self.model_type = model_type match model_type: case "gpt": self.client = AsyncOpenAI( api_key=apikey_settings.CHATGPT_API_KEY, timeout=self.timeout ) case "gemini": self.client = AsyncOpenAI( api_key=apikey_settings.GEMINI_API_KEY, base_url="https://generativelanguage.googleapis.com/v1beta/openai/", timeout=self.timeout ) case _: raise NotImplementedError(f"Unknown Provider : {model_type}") async def _call_pydantic_output( self, prompt : str, output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것 model : str, img_url : str, image_detail_high : bool) -> BaseModel: content = [] if img_url: content.append({ "type" : "input_image", "image_url" : img_url, "detail": "high" if image_detail_high else "low" }) content.append({ "type": "input_text", "text": prompt} ) last_error = None for attempt in range(self.max_retries + 1): response = await self.client.responses.parse( model=model, input=[{"role": "user", "content": content}], text_format=output_format ) # Response 디버그 로깅 logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}") logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}") logger.debug(f"[ChatgptService({self.model_type})] Response status: {response.status}") logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}") # status 확인: completed, failed, incomplete, cancelled, queued, in_progress if response.status == "completed": logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}") structured_output = response.output_parsed return structured_output #.model_dump() or {} # 에러 상태 처리 if response.status == "failed": error_code = getattr(response.error, 'code', None) if response.error else None error_message = getattr(response.error, 'message', None) if response.error else None logger.warning(f"[ChatgptService({self.model_type})] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}") last_error = ChatGPTResponseError(response.status, error_code, error_message) elif response.status == "incomplete": reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None logger.warning(f"[ChatgptService({self.model_type})] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}") last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}") else: # cancelled, queued, in_progress 등 예상치 못한 상태 logger.warning(f"[ChatgptService({self.model_type})] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}") last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}") # 마지막 시도가 아니면 재시도 if attempt < self.max_retries: logger.info(f"[ChatgptService({self.model_type})] Retrying request...") # 모든 재시도 실패 logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}") raise last_error async def _call_pydantic_output_chat_completion( # alter version self, prompt : str, output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것 model : str, img_url : str, image_detail_high : bool) -> BaseModel: content = [] if img_url: content.append({ "type": "image_url", "image_url": { "url": img_url, "detail": "high" if image_detail_high else "low" } }) content.append({ "type": "text", "text": prompt }) last_error = None for attempt in range(self.max_retries + 1): response = await self.client.beta.chat.completions.parse( model=model, messages=[{"role": "user", "content": content}], response_format=output_format ) # Response 디버그 로깅 logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}") logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}") logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}") logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}") choice = response.choices[0] finish_reason = choice.finish_reason if finish_reason == "stop": output_text = choice.message.content or "" logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}") return choice.message.parsed elif finish_reason == "length": logger.warning(f"[ChatgptService({self.model_type})] Response incomplete - token limit reached (attempt {attempt + 1}/{self.max_retries + 1})") last_error = ChatGPTResponseError("incomplete", finish_reason, "Response incomplete: max tokens reached") elif finish_reason == "content_filter": logger.warning(f"[ChatgptService({self.model_type})] Response blocked by content filter (attempt {attempt + 1}/{self.max_retries + 1})") last_error = ChatGPTResponseError("failed", finish_reason, "Response blocked by content filter") else: logger.warning(f"[ChatgptService({self.model_type})] Unexpected finish_reason (attempt {attempt + 1}/{self.max_retries + 1}): {finish_reason}") last_error = ChatGPTResponseError("failed", finish_reason, f"Unexpected finish_reason: {finish_reason}") # 마지막 시도가 아니면 재시도 if attempt < self.max_retries: logger.info(f"[ChatgptService({self.model_type})] Retrying request...") # 모든 재시도 실패 logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}") raise last_error async def generate_structured_output( self, prompt : Prompt, input_data : dict, img_url : Optional[str] = None, img_detail_high : bool = False, silent : bool = False ) -> BaseModel: prompt_text = prompt.build_prompt(input_data, silent) logger.debug(f"[ChatgptService({self.model_type})] Generated Prompt (length: {len(prompt_text)})") if not silent: logger.info(f"[ChatgptService({self.model_type})] Starting GPT request with structured output with model: {prompt.prompt_model}") # GPT API 호출 #parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model) # parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high) parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high) return parsed