257 lines
12 KiB
Python
257 lines
12 KiB
Python
import json
|
|
import re
|
|
from pydantic import BaseModel, ValidationError
|
|
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):
|
|
try:
|
|
response = await self.client.beta.chat.completions.parse(
|
|
model=model,
|
|
messages=[{"role": "user", "content": content}],
|
|
response_format=output_format
|
|
)
|
|
except (ValidationError, json.JSONDecodeError) as e:
|
|
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
|
|
# 확률적 출력 문제일 수 있으므로 재시도 대상에 포함한다.
|
|
logger.warning(
|
|
f"[ChatgptService({self.model_type})] Structured output parse failed "
|
|
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
|
|
)
|
|
last_error = ChatGPTResponseError("parse_error", type(e).__name__, str(e))
|
|
if attempt < self.max_retries:
|
|
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
|
continue
|
|
# 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_multi_image(
|
|
self,
|
|
prompt_text: str,
|
|
output_format: BaseModel,
|
|
model: str,
|
|
img_urls: List[str],
|
|
image_detail_high: bool = True,
|
|
) -> BaseModel:
|
|
"""여러 이미지를 한 번에 보고 구조화 출력을 생성합니다 (썸네일 비전 선택용).
|
|
|
|
sheet 기반 Prompt를 거치지 않고 코드에서 조립한 프롬프트 텍스트와
|
|
이미지 URL 리스트를 직접 받는다. 이미지들은 프롬프트에 나열된 순서와
|
|
동일하게 첨부되므로, 프롬프트에서 "N번째 이미지"로 지칭할 수 있다.
|
|
"""
|
|
content = []
|
|
for url in img_urls:
|
|
content.append({
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": url,
|
|
"detail": "high" if image_detail_high else "low",
|
|
},
|
|
})
|
|
content.append({"type": "text", "text": prompt_text})
|
|
|
|
last_error = None
|
|
for attempt in range(self.max_retries + 1):
|
|
try:
|
|
response = await self.client.beta.chat.completions.parse(
|
|
model=model,
|
|
messages=[{"role": "user", "content": content}],
|
|
response_format=output_format,
|
|
)
|
|
except (ValidationError, json.JSONDecodeError) as e:
|
|
logger.warning(
|
|
f"[ChatgptService({self.model_type})] multi-image parse failed "
|
|
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
|
|
)
|
|
last_error = ChatGPTResponseError("parse_error", type(e).__name__, str(e))
|
|
if attempt < self.max_retries:
|
|
continue
|
|
raise last_error
|
|
|
|
choice = response.choices[0]
|
|
if choice.finish_reason == "stop":
|
|
return choice.message.parsed
|
|
logger.warning(
|
|
f"[ChatgptService({self.model_type})] multi-image unexpected finish_reason "
|
|
f"(attempt {attempt + 1}/{self.max_retries + 1}): {choice.finish_reason}"
|
|
)
|
|
last_error = ChatGPTResponseError("failed", choice.finish_reason, "multi-image call failed")
|
|
|
|
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 = True
|
|
) -> 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 |