Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/services/newsletter_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ def _build_system_prompt(language: str) -> str:

- response schema에 맞는 JSON만 반환한다.
- AI 서버는 DB 저장을 직접 알지 않는다. 저장 판단은 BE가 하며, AI 서버는 분석 결과만 반환한다.
- items 배열의 모든 원소는 반드시 JSON object여야 하며, 문자열 조각(예: "}},{{")이나
JSON을 흉내 낸 문자열을 배열 원소로 넣지 않는다.
- checklistItems 배열의 모든 원소도 반드시 JSON object여야 한다.
- title, summary, items[].title, checklistItems[].content, checklistItems[].detail,
conversationTopics[].topic은 사용자 언어({language_name})와 무관하게 항상 한국어로 작성한다.
(이 값들은 이후 단계에서 번역 및 검수를 거쳐 사용자 언어로 변환된다.)
Expand Down
66 changes: 57 additions & 9 deletions app/services/openai_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

logger = logging.getLogger(__name__)

MAX_ANALYSIS_ATTEMPTS = 2


class OpenAIAdapterError(RuntimeError):
pass
Expand All @@ -39,9 +41,38 @@ def analyze(self, request: NewsletterAnalysisRequest) -> NewsletterAnalysisRespo
if not self.settings.api_key:
raise OpenAIConfigurationError("OPENAI_API_KEY가 설정되어 있지 않습니다.")

payload = {
messages = build_prompt_messages(request)
last_validation_error: ValidationError | None = None
for attempt in range(1, MAX_ANALYSIS_ATTEMPTS + 1):
response_body = self._post_json("/responses", self._analysis_payload(messages))
parsed = self._extract_output_json(response_body)
try:
return NewsletterAnalysisResponse.model_validate(parsed)
except ValidationError as exc:
last_validation_error = exc
logger.warning(
"[OpenAIAdapter] 응답 스키마 검증 실패. attempt=%s/%s, errors=%s",
attempt,
MAX_ANALYSIS_ATTEMPTS,
self._summarize_validation_errors(exc, include_message=False),
)
if attempt < MAX_ANALYSIS_ATTEMPTS:
messages = [
*messages,
{
"role": "user",
"content": self._build_schema_retry_message(exc),
},
]

raise OpenAIAdapterError("OpenAI 응답이 분석 스키마와 일치하지 않습니다.") from (
last_validation_error
)

def _analysis_payload(self, messages: list[dict[str, str]]) -> dict[str, Any]:
return {
"model": self.settings.model,
"input": build_prompt_messages(request),
"input": messages,
"text": {
"format": {
"type": "json_schema",
Expand All @@ -52,13 +83,30 @@ def analyze(self, request: NewsletterAnalysisRequest) -> NewsletterAnalysisRespo
},
}

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 _build_schema_retry_message(self, exc: ValidationError) -> str:
summarized_errors = self._summarize_validation_errors(exc, include_message=True)
return (
"이전 응답은 NewsletterAnalysisResponse 스키마 검증에 실패했습니다.\n"
"아래 오류를 반드시 수정해 같은 schema의 JSON object만 다시 반환하세요.\n"
"- items 배열의 모든 원소는 문자열이 아니라 JSON object여야 합니다.\n"
"- checklistItems 배열의 모든 원소도 JSON object여야 합니다.\n"
"- 누락된 required 필드가 있으면 schema에 맞게 모두 채우세요.\n"
f"\nvalidationErrors:\n{json.dumps(summarized_errors, ensure_ascii=False)}"
)

def _summarize_validation_errors(
self, exc: ValidationError, *, include_message: bool
) -> list[dict[str, str | None]]:
summarized_errors = []
for err in exc.errors()[:10]:
summary = {
"loc": ".".join(str(part) for part in err.get("loc", ())),
"type": err.get("type"),
}
if include_message:
summary["msg"] = err.get("msg")
summarized_errors.append(summary)
return summarized_errors

def refine_translation(self, request: TranslationRefineRequest) -> TranslationRefineResponse:
if not self.settings.api_key:
Expand Down
125 changes: 125 additions & 0 deletions tests/test_openai_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import json
import unittest
from datetime import date

from app.config import OpenAISettings
from app.schemas import NewsletterAnalysisRequest
from app.services.openai_adapter import OpenAIAdapterError, OpenAINewsletterAdapter


def _i18n(text: str) -> dict[str, str]:
return {"KO": text, "US": text, "ZH": text, "VI": text}


def _valid_response() -> dict:
return {
"title": "수영 실기교육 안내",
"titleI18n": _i18n("수영 실기교육 안내"),
"summary": "수영 실기교육 일정을 확인하세요.",
"items": [
{
"type": "schedule",
"title": "수영 실기교육",
"titleI18n": _i18n("수영 실기교육"),
"selectedDateCandidate": {
"index": 0,
"candidateId": "dc_1",
"originalText": "2026. 6. 15",
"normalizedDate": "2026-06-15",
},
"dateStatus": "confirmed",
"datetime": "2026-06-15",
"timezone": "Asia/Seoul",
"evidenceText": "2026. 6. 15 수영 실기교육",
"confidence": 0.9,
"needsUserConfirmation": False,
"confirmationQuestion": None,
"checklistItems": [
{
"content": "수영복 준비하기",
"contentI18n": _i18n("수영복 준비하기"),
"detail": "수영복, 수영모, 물안경을 준비합니다.",
"detailI18n": _i18n("수영복, 수영모, 물안경을 준비합니다."),
}
],
}
],
"conversationTopics": [],
"meta": {
"mode": "openai",
"dateCandidateCount": 1,
"requiresLLMReview": False,
"outputLanguage": "KO",
"localizedOutput": True,
},
}


class StubOpenAINewsletterAdapter(OpenAINewsletterAdapter):
def __init__(self, responses: list[dict]) -> None:
super().__init__(
OpenAISettings(
enabled=True,
api_key="test-key",
model="test-model",
base_url="https://example.test/v1",
timeout_seconds=1,
)
)
self.responses = responses
self.payloads: list[dict] = []

def _post_json(self, path: str, payload: dict) -> dict:
self.payloads.append(payload)
index = len(self.payloads) - 1
return self.responses[index]


class OpenAIAdapterSchemaRetryTest(unittest.TestCase):
def test_analyze_retries_once_when_schema_validation_fails(self):
invalid = _valid_response()
invalid["items"] = [invalid["items"][0], "},{"]
adapter = StubOpenAINewsletterAdapter(
[
{"output_text": json.dumps(invalid, ensure_ascii=False)},
{"output_text": json.dumps(_valid_response(), ensure_ascii=False)},
]
)
request = NewsletterAnalysisRequest(
originalText="2026. 6. 15 수영 실기교육 안내",
language="KO",
referenceDate=date(2026, 6, 12),
)

response = adapter.analyze(request)

self.assertEqual(response.title, "수영 실기교육 안내")
self.assertEqual(len(adapter.payloads), 2)
retry_messages = adapter.payloads[1]["input"]
self.assertIn("스키마 검증에 실패", retry_messages[-1]["content"])
self.assertIn("items 배열의 모든 원소", retry_messages[-1]["content"])
self.assertNotIn("},{", retry_messages[-1]["content"])
self.assertIn('"loc": "items.1"', retry_messages[-1]["content"])

def test_analyze_raises_after_retry_also_fails_validation(self):
invalid = _valid_response()
invalid["items"] = [invalid["items"][0], "},{"]
adapter = StubOpenAINewsletterAdapter(
[
{"output_text": json.dumps(invalid, ensure_ascii=False)},
{"output_text": json.dumps(invalid, ensure_ascii=False)},
]
)
request = NewsletterAnalysisRequest(
originalText="2026. 6. 15 수영 실기교육 안내",
language="KO",
referenceDate=date(2026, 6, 12),
)

with self.assertRaises(OpenAIAdapterError):
adapter.analyze(request)
self.assertEqual(len(adapter.payloads), 2)


if __name__ == "__main__":
unittest.main()