From 3783fcb058d3ff7f22c4ef3b0a88409e19620acb Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 20 May 2026 13:22:13 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20OpenAI=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=96=B4=EB=8C=91=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 +- README.md | 5 +- app/config.py | 42 +++++++ app/routers/newsletters.py | 16 ++- app/services/newsletter_extractor.py | 20 ++++ app/services/openai_adapter.py | 112 +++++++++++++++++++ docs/env.md | 18 ++- docs/newsletter-extraction.md | 161 ++++----------------------- 8 files changed, 231 insertions(+), 149 deletions(-) create mode 100644 app/config.py create mode 100644 app/services/openai_adapter.py diff --git a/.env.example b/.env.example index 9847a1d..22f53fa 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,5 @@ -OPENAI_API_KEY= \ No newline at end of file +OPENAI_ENABLED=false +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_TIMEOUT_SECONDS=60 diff --git a/README.md b/README.md index cc9cb60..4292b40 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ GACHI 프로젝트의 AI 서버입니다. BE와 분리된 FastAPI 애플리케 - 가정통신문 원문과 날짜 후보를 기반으로 제목, 요약, 일정/마감/체크리스트 항목을 분석합니다. - AI 서버는 분석 결과 JSON만 반환하고, DB 저장은 BE가 담당합니다. -- OpenAI API 연결 전에도 검증할 수 있도록 비용 없는 rule-based baseline과 prompt-preview API를 제공합니다. +- `OPENAI_ENABLED=true`일 때 OpenAI API를 호출하고, 기본값에서는 비용 없는 rule-based baseline으로 응답합니다. +- 기존 baseline API와 prompt-preview API를 유지합니다. ## 로컬 실행 @@ -42,5 +43,5 @@ Windows PowerShell에서는 다음처럼 가상환경을 활성화합니다. - `docs/env.md`: 환경변수 - `docs/deploy.md`: Docker image와 EC2 배포 방식 -- `docs/newsletter-extraction.md`: 가정통신문 분석 API 스펙과 프롬프트 흐름 +- `docs/newsletter-extraction.md`: 가정통신문 분석 구현 경계와 유지 결정 - `docs/newsletter-labeling-guide.md`: 정답 데이터 라벨링 기준 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..86f5a28 --- /dev/null +++ b/app/config.py @@ -0,0 +1,42 @@ +import os +from dataclasses import dataclass + + +def _read_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _read_float(name: str, default: float) -> float: + value = os.getenv(name) + if value is None or value.strip() == "": + return default + try: + return float(value) + except ValueError: + return default + + +@dataclass(frozen=True) +class OpenAISettings: + enabled: bool + api_key: str | None + model: str + base_url: str + timeout_seconds: float + + @classmethod + def from_env(cls) -> "OpenAISettings": + return cls( + enabled=_read_bool("OPENAI_ENABLED", default=False), + api_key=os.getenv("OPENAI_API_KEY") or None, + model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"), + base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), + timeout_seconds=_read_float("OPENAI_TIMEOUT_SECONDS", 60.0), + ) + + +def get_openai_settings() -> OpenAISettings: + return OpenAISettings.from_env() diff --git a/app/routers/newsletters.py b/app/routers/newsletters.py index 39cb56b..d3f0b9a 100644 --- a/app/routers/newsletters.py +++ b/app/routers/newsletters.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, status from app.schemas import ( NewsletterAnalysisRequest, @@ -9,13 +9,25 @@ ) from app.services.newsletter_extractor import analyze_newsletter, extract_newsletter_items from app.services.newsletter_prompt import ANALYSIS_RESPONSE_SCHEMA, build_prompt_messages +from app.services.openai_adapter import OpenAIAdapterError, OpenAIConfigurationError router = APIRouter(prefix="/ai/newsletters", tags=["newsletters"]) @router.post("/analyze", response_model=NewsletterAnalysisResponse) def analyze(req: NewsletterAnalysisRequest) -> NewsletterAnalysisResponse: - return analyze_newsletter(req) + try: + return analyze_newsletter(req) + except OpenAIConfigurationError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except OpenAIAdapterError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc @router.post("/extract-items", response_model=NewsletterExtractionResponse) diff --git a/app/services/newsletter_extractor.py b/app/services/newsletter_extractor.py index 8b234a8..9884c59 100644 --- a/app/services/newsletter_extractor.py +++ b/app/services/newsletter_extractor.py @@ -1,6 +1,8 @@ +import logging import re from collections.abc import Iterable +from app.config import get_openai_settings from app.schemas import ( DateCandidate, DateStatus, @@ -12,6 +14,9 @@ NewsletterExtractionResponse, SelectedDateCandidate, ) +from app.services.openai_adapter import OpenAINewsletterAdapter + +logger = logging.getLogger(__name__) DEADLINE_KEYWORDS = ( "마감", @@ -62,6 +67,21 @@ def extract_newsletter_items( def analyze_newsletter( request: NewsletterAnalysisRequest, ) -> NewsletterAnalysisResponse: + settings = get_openai_settings() + if settings.enabled: + logger.info("[NewsletterAnalysis] OpenAI 분석 모드로 실행합니다. model=%s", settings.model) + response = OpenAINewsletterAdapter(settings).analyze(request) + meta = dict(response.meta) + meta.update( + { + "mode": "openai", + "model": settings.model, + "dateCandidateCount": len(request.date_candidates), + "requiresLLMReview": False, + } + ) + return response.model_copy(update={"meta": meta}) + items = _extract_items(request) return NewsletterAnalysisResponse( title=_extract_document_title(request), diff --git a/app/services/openai_adapter.py b/app/services/openai_adapter.py new file mode 100644 index 0000000..fe5b9e5 --- /dev/null +++ b/app/services/openai_adapter.py @@ -0,0 +1,112 @@ +import json +import logging +import urllib.error +import urllib.request +from typing import Any + +from pydantic import ValidationError + +from app.config import OpenAISettings +from app.schemas import NewsletterAnalysisRequest, NewsletterAnalysisResponse +from app.services.newsletter_prompt import ANALYSIS_RESPONSE_SCHEMA, build_prompt_messages + +logger = logging.getLogger(__name__) + + +class OpenAIAdapterError(RuntimeError): + pass + + +class OpenAIConfigurationError(OpenAIAdapterError): + pass + + +class OpenAINewsletterAdapter: + def __init__(self, settings: OpenAISettings) -> None: + self.settings = settings + + def analyze(self, request: NewsletterAnalysisRequest) -> NewsletterAnalysisResponse: + if not self.settings.api_key: + raise OpenAIConfigurationError("OPENAI_API_KEY가 설정되어 있지 않습니다.") + + payload = { + "model": self.settings.model, + "input": build_prompt_messages(request), + "text": { + "format": { + "type": "json_schema", + "name": "newsletter_analysis", + "schema": ANALYSIS_RESPONSE_SCHEMA, + "strict": False, + } + }, + } + + response_body = self._post_json("/responses", payload) + parsed = self._extract_output_json(response_body) + try: + return NewsletterAnalysisResponse.model_validate(parsed) + except ValidationError as exc: + logger.warning("[OpenAIAdapter] 응답 스키마 검증 실패. error=%s", exc) + raise OpenAIAdapterError("OpenAI 응답이 분석 스키마와 일치하지 않습니다.") from exc + + def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + url = self.settings.base_url.rstrip("/") + path + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {self.settings.api_key}", + "Content-Type": "application/json", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=self.settings.timeout_seconds) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + logger.warning( + "[OpenAIAdapter] OpenAI 호출 실패. status=%s, body=%s", + exc.code, + error_body[:1000], + ) + raise OpenAIAdapterError(f"OpenAI 호출 실패. status={exc.code}") from exc + except urllib.error.URLError as exc: + logger.warning("[OpenAIAdapter] OpenAI 통신 오류. reason=%s", exc.reason) + raise OpenAIAdapterError("OpenAI 통신 오류가 발생했습니다.") from exc + except TimeoutError as exc: + logger.warning( + "[OpenAIAdapter] OpenAI 호출 timeout. timeout=%s", + self.settings.timeout_seconds, + ) + raise OpenAIAdapterError("OpenAI 호출 시간이 초과되었습니다.") from exc + except json.JSONDecodeError as exc: + logger.warning("[OpenAIAdapter] OpenAI 응답 JSON 파싱 실패. error=%s", exc) + raise OpenAIAdapterError("OpenAI 응답을 JSON으로 해석할 수 없습니다.") from exc + + def _extract_output_json(self, response_body: dict[str, Any]) -> dict[str, Any]: + output_text = response_body.get("output_text") + if isinstance(output_text, str) and output_text.strip(): + return self._loads_model_json(output_text) + + for output in response_body.get("output", []): + for content in output.get("content", []): + text = content.get("text") + if isinstance(text, str) and text.strip(): + return self._loads_model_json(text) + + raise OpenAIAdapterError("OpenAI 응답에서 출력 텍스트를 찾을 수 없습니다.") + + def _loads_model_json(self, value: str) -> dict[str, Any]: + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + logger.warning("[OpenAIAdapter] 모델 출력 JSON 파싱 실패. output=%s", value[:1000]) + raise OpenAIAdapterError("OpenAI 모델 출력이 JSON 형식이 아닙니다.") from exc + + if not isinstance(parsed, dict): + raise OpenAIAdapterError("OpenAI 모델 출력이 JSON object가 아닙니다.") + return parsed diff --git a/docs/env.md b/docs/env.md index 8cc1b13..06e2b19 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1,13 +1,19 @@ # AI 서버 환경변수 -## 필수 +## OpenAI 호출 -- `OPENAI_API_KEY`: 추후 LLM API 호출을 붙일 때 사용할 OpenAI API key +- `OPENAI_ENABLED`: OpenAI 실제 호출 활성화 여부. 기본값은 `false` +- `OPENAI_API_KEY`: OpenAI API key. `OPENAI_ENABLED=true`일 때 필요 +- `OPENAI_MODEL`: 사용할 모델. 기본값은 `gpt-4o-mini` +- `OPENAI_BASE_URL`: OpenAI API base URL. 기본값은 `https://api.openai.com/v1` +- `OPENAI_TIMEOUT_SECONDS`: OpenAI 호출 timeout 초. 기본값은 `60` -## 선택 +비용 방지를 위해 로컬과 배포 기본값은 `OPENAI_ENABLED=false`로 둔다. +이 상태에서 `/ai/newsletters/analyze`는 기존 rule-based baseline으로 응답한다. -- `LOG_LEVEL`: 로그 레벨. 기본값은 `INFO` +`OPENAI_ENABLED=true`인데 `OPENAI_API_KEY`가 없으면 `/ai/newsletters/analyze`는 `503`을 반환한다. +OpenAI 호출 또는 응답 파싱이 실패하면 `502`를 반환하고, 로그에는 상태 코드나 예외 사유를 남긴다. -## 현재 상태 +## 기타 -현재 구현은 OpenAI API를 직접 호출하지 않습니다. `OPENAI_API_KEY`는 기존 EC2 compose 환경과 향후 LLM client 연결을 고려해 유지합니다. +- `LOG_LEVEL`: 로그 레벨. 기본값은 `INFO` diff --git a/docs/newsletter-extraction.md b/docs/newsletter-extraction.md index 5384732..770b6da 100644 --- a/docs/newsletter-extraction.md +++ b/docs/newsletter-extraction.md @@ -1,147 +1,32 @@ -# 가정통신문 분석 API 스펙 +# 가정통신문 분석 구현 메모 -## 목적 +이 문서는 API 명세서가 아니라 AI 서버 구현 경계와 유지 결정만 정리한다. +상세 request/response 명세는 노션을 기준으로 관리한다. -BE가 가정통신문 저장에 필요한 `title`, `summary`, `items`를 AI 서버에서 한 번에 받을 수 있도록 최종 분석 API 계약을 정의한다. +## 구현 범위 -AI 서버의 책임은 원문과 날짜 후보를 분석해 JSON을 반환하는 것이다. DB 저장 여부, 저장 모델 매핑, 사용자 확인 플로우는 BE가 담당한다. +- AI 서버는 가정통신문 원문을 분석해 JSON 결과만 반환한다. +- DB 저장, 저장 모델 매핑, 사용자 확인 플로우는 BE가 담당한다. +- `POST /ai/newsletters/analyze`는 문서 제목, 요약, 항목, 메타데이터를 반환한다. +- `POST /ai/newsletters/extract-items`는 기존 항목 추출 baseline 확인용으로 유지한다. +- `POST /ai/newsletters/prompt-preview`는 LLM 호출 전 prompt와 응답 schema를 확인하는 용도로 유지한다. -## 엔드포인트 +## 유지 결정 -### `POST /ai/newsletters/analyze` +- `dateCandidates` 입력 형식은 기존 `extract-items` 계약을 유지한다. +- `startOffset`, `endOffset`은 기존 계약과 동일하게 필수다. +- `items` 응답 형식은 기존 `extract-items` 응답 구조를 유지한다. +- top-level `title`은 문서 제목이고, `items[].title`은 항목 제목이다. +- `meta`는 저장 모델과 직접 매핑하지 않는 분석 보조 정보다. -가정통신문 전체 분석 API다. 제목, 요약, 일정/마감/체크리스트 항목, 분석 메타데이터를 반환한다. +## 책임 경계 -### `POST /ai/newsletters/extract-items` - -기존 항목 추출 API다. `items` 응답 형식 검증과 비용 없는 rule-based baseline 확인 용도로 유지한다. - -### `POST /ai/newsletters/prompt-preview` - -LLM 호출 전 system/user prompt와 최종 분석 응답 JSON schema를 확인하는 API다. `responseSchema`는 `/ai/newsletters/analyze` 응답 구조를 기준으로 한다. - -## Analyze 요청 스키마 - -`/ai/newsletters/analyze`는 기존 `extract-items` 입력 계약을 그대로 사용한다. 따라서 `dateCandidates`의 `startOffset`, `endOffset`도 기존과 동일하게 필수다. - -| 필드 | 타입 | 필수 | 설명 | -| --- | --- | --- | --- | -| `originalText` | `string` | 예 | 가정통신문 원문 | -| `translatedText` | `string \| null` | 아니오 | 번역 또는 OCR 보정 텍스트. 없으면 `null` | -| `language` | `string` | 아니오 | 원문 언어. 기본값은 `KO` | -| `referenceDate` | `date \| null` | 아니오 | 상대 날짜 해석 기준일. ISO-8601 날짜 | -| `timezone` | `string` | 아니오 | 날짜/시간 기준 타임존. 기본값은 `Asia/Seoul` | -| `dateCandidates` | `DateCandidate[]` | 아니오 | BE 또는 전처리 단계가 추출한 날짜 후보 목록 | - -### `DateCandidate` - -| 필드 | 타입 | 필수 | 설명 | -| --- | --- | --- | --- | -| `candidateId` | `string \| null` | 아니오 | BE가 후보 추적에 사용할 수 있는 식별자 | -| `originalText` | `string` | 예 | 원문에 등장한 날짜 표현 | -| `normalizedDate` | `date` | 예 | 정규화된 날짜 | -| `startOffset` | `integer` | 예 | 원문 기준 시작 offset | -| `endOffset` | `integer` | 예 | 원문 기준 종료 offset | -| `extractionType` | `string \| null` | 아니오 | 후보 추출 방식. 예: `REGEX`, `OCR`, `MANUAL` | - -```json -{ - "originalText": "2026학년도 체험학습 신청 안내\n5월 10일까지 참가 신청서를 제출해 주세요.", - "translatedText": null, - "language": "KO", - "referenceDate": "2026-05-06", - "timezone": "Asia/Seoul", - "dateCandidates": [ - { - "candidateId": "dc_1", - "originalText": "5월 10일", - "normalizedDate": "2026-05-10", - "startOffset": 18, - "endOffset": 24, - "extractionType": "REGEX" - } - ] -} -``` - -## Analyze 응답 스키마 - -| 필드 | 타입 | 필수 | 설명 | -| --- | --- | --- | --- | -| `title` | `string` | 예 | 문서 제목. BE의 가정통신문 제목 저장값으로 사용 가능 | -| `summary` | `string` | 예 | 문서 요약. BE의 요약 저장값으로 사용 가능 | -| `items` | `ExtractedItem[]` | 예 | 일정, 마감, 체크리스트, 알림 항목 | -| `meta` | `object` | 예 | 분석 모드, 후보 개수 등 저장 비대상 메타데이터 | - -### `ExtractedItem` - -기존 `extract-items` 응답 형식을 유지한다. - -| 필드 | 타입 | 필수 | 설명 | -| --- | --- | --- | --- | -| `type` | `schedule \| deadline \| checklist \| reminder` | 예 | 항목 분류 | -| `title` | `string` | 예 | 항목 제목. 문서 제목인 top-level `title`과 다름 | -| `selectedDateCandidate` | `SelectedDateCandidate \| null` | 예 | 선택된 날짜 후보. 날짜가 없으면 `null` | -| `dateStatus` | `confirmed \| ambiguous \| missing` | 예 | 날짜 신뢰 상태 | -| `datetime` | `string \| null` | 예 | 저장 가능한 날짜/시간 문자열. 없으면 `null` | -| `timezone` | `string` | 예 | 항목 날짜/시간 기준 타임존 | -| `evidenceText` | `string` | 예 | 원문 근거 | -| `confidence` | `number` | 예 | 0~1 범위 신뢰도 | -| `needsUserConfirmation` | `boolean` | 예 | 사용자 확인 필요 여부 | -| `confirmationQuestion` | `string \| null` | 예 | 사용자에게 물어볼 확인 질문 | - -### `SelectedDateCandidate` - -| 필드 | 타입 | 필수 | 설명 | -| --- | --- | --- | --- | -| `index` | `integer` | 예 | 요청 `dateCandidates` 배열의 index | -| `candidateId` | `string \| null` | 예 | 요청 후보의 `candidateId` | -| `originalText` | `string` | 예 | 요청 후보의 `originalText` | -| `normalizedDate` | `date` | 예 | 요청 후보의 `normalizedDate` | - -```json -{ - "title": "2026학년도 체험학습 신청 안내", - "summary": "체험학습 참가 신청서를 5월 10일까지 제출해야 합니다.", - "items": [ - { - "type": "deadline", - "title": "체험학습 참가 신청서 제출", - "selectedDateCandidate": { - "index": 0, - "candidateId": "dc_1", - "originalText": "5월 10일", - "normalizedDate": "2026-05-10" - }, - "dateStatus": "confirmed", - "datetime": "2026-05-10", - "timezone": "Asia/Seoul", - "evidenceText": "5월 10일까지 참가 신청서를 제출해 주세요.", - "confidence": 0.86, - "needsUserConfirmation": false, - "confirmationQuestion": null - } - ], - "meta": { - "mode": "rule_based_baseline", - "dateCandidateCount": 1, - "requiresLLMReview": true - } -} -``` - -## 유지 여부 검토 - -- `dateCandidates` 입력 형식은 유지한다. 날짜 정규화 책임을 BE 또는 전처리 단계에 두고, AI 서버는 후보 중 하나를 선택한다. -- `startOffset`, `endOffset`은 기존 `extract-items` 입력 계약과 동일하게 필수다. AI 서버가 원문 근거 범위를 안정적으로 찾기 위해 사용한다. -- `items` 응답 형식은 유지한다. 기존 `extract-items` 소비 코드가 항목 구조를 그대로 검증할 수 있어야 한다. -- `items[].title`은 항목 제목이고, top-level `title`은 문서 제목이다. BE 매핑 시 두 필드를 구분해야 한다. -- `meta`는 저장 모델과 직접 매핑하지 않는다. 디버깅, 분석 모드 표시, 후보 개수 확인용이다. -- `selectedDateCandidate`, `evidenceText`, `confidence`, `needsUserConfirmation`, `confirmationQuestion`은 BE 저장 정책에 따라 저장하거나 무시할 수 있는 분석 보조 필드다. -- `datetime`은 `dateStatus`가 `confirmed`일 때만 캘린더/알림 저장 대상으로 보는 것을 권장한다. `ambiguous` 또는 `missing`은 사용자 확인 플로우로 넘긴다. +- BE: 원문 저장, 날짜 후보 생성 또는 전달, 분석 결과 저장, 사용자 확인 처리. +- AI 서버: 제목/요약/항목 추출, 날짜 후보 매칭, JSON 반환. +- AI 서버 요청/응답에는 DB 테이블명, 저장 ID, ORM 모델을 포함하지 않는다. -## 경계 +## 문서 관리 원칙 -- BE: 원문 저장, 날짜 후보 생성 또는 전달, 분석 결과 저장, 사용자 확인 처리. -- AI 서버: 원문 분석, 제목/요약/항목 추출, 날짜 후보 매칭, JSON 반환. -- AI 서버는 DB 테이블명, 저장 ID, ORM 모델을 요청이나 응답 스키마에 포함하지 않는다. +- API 명세의 원본은 노션으로 둔다. +- repo 문서는 코드 변경 시 같이 봐야 하는 구현 결정만 남긴다. +- 한글 표 정렬이 깨지는 문제를 피하기 위해 긴 필드 표는 repo 문서에 두지 않는다. From f393436b7c972445bcb5ccaa97e942fcd41d3aed Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 20 May 2026 17:01:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20OpenAI=20=EC=96=B4=EB=8C=91=ED=84=B0?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 24 ++++++++++++++++++------ app/services/openai_adapter.py | 24 +++++++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/app/config.py b/app/config.py index 86f5a28..a3dc697 100644 --- a/app/config.py +++ b/app/config.py @@ -9,12 +9,23 @@ def _read_bool(name: str, default: bool = False) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} -def _read_float(name: str, default: float) -> float: +def _read_str(name: str, default: str | None = None) -> str | None: + value = os.getenv(name) + if value is None: + return default + normalized = value.strip() + return normalized or default + + +def _read_float(name: str, default: float, *, min_value: float | None = None) -> float: value = os.getenv(name) if value is None or value.strip() == "": return default try: - return float(value) + parsed = float(value) + if min_value is not None and parsed < min_value: + return default + return parsed except ValueError: return default @@ -31,10 +42,11 @@ class OpenAISettings: def from_env(cls) -> "OpenAISettings": return cls( enabled=_read_bool("OPENAI_ENABLED", default=False), - api_key=os.getenv("OPENAI_API_KEY") or None, - model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"), - base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), - timeout_seconds=_read_float("OPENAI_TIMEOUT_SECONDS", 60.0), + api_key=_read_str("OPENAI_API_KEY"), + model=_read_str("OPENAI_MODEL", "gpt-4o-mini") or "gpt-4o-mini", + base_url=_read_str("OPENAI_BASE_URL", "https://api.openai.com/v1") + or "https://api.openai.com/v1", + timeout_seconds=_read_float("OPENAI_TIMEOUT_SECONDS", 60.0, min_value=0.001), ) diff --git a/app/services/openai_adapter.py b/app/services/openai_adapter.py index fe5b9e5..d0d8009 100644 --- a/app/services/openai_adapter.py +++ b/app/services/openai_adapter.py @@ -69,9 +69,9 @@ def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: except urllib.error.HTTPError as exc: error_body = exc.read().decode("utf-8", errors="replace") logger.warning( - "[OpenAIAdapter] OpenAI 호출 실패. status=%s, body=%s", + "[OpenAIAdapter] OpenAI 호출 실패. status=%s, body_length=%s", exc.code, - error_body[:1000], + len(error_body), ) raise OpenAIAdapterError(f"OpenAI 호출 실패. status={exc.code}") from exc except urllib.error.URLError as exc: @@ -92,8 +92,19 @@ def _extract_output_json(self, response_body: dict[str, Any]) -> dict[str, Any]: if isinstance(output_text, str) and output_text.strip(): return self._loads_model_json(output_text) - for output in response_body.get("output", []): - for content in output.get("content", []): + outputs = response_body.get("output", []) + if not isinstance(outputs, list): + raise OpenAIAdapterError("OpenAI 응답의 output 형식이 올바르지 않습니다.") + + for output in outputs: + if not isinstance(output, dict): + continue + contents = output.get("content", []) + if not isinstance(contents, list): + continue + for content in contents: + if not isinstance(content, dict): + continue text = content.get("text") if isinstance(text, str) and text.strip(): return self._loads_model_json(text) @@ -104,7 +115,10 @@ def _loads_model_json(self, value: str) -> dict[str, Any]: try: parsed = json.loads(value) except json.JSONDecodeError as exc: - logger.warning("[OpenAIAdapter] 모델 출력 JSON 파싱 실패. output=%s", value[:1000]) + logger.warning( + "[OpenAIAdapter] 모델 출력 JSON 파싱 실패. output_length=%s", + len(value), + ) raise OpenAIAdapterError("OpenAI 모델 출력이 JSON 형식이 아닙니다.") from exc if not isinstance(parsed, dict):