diff --git a/app/services/newsletter_prompt.py b/app/services/newsletter_prompt.py index 88e8af4..fd8ae41 100644 --- a/app/services/newsletter_prompt.py +++ b/app/services/newsletter_prompt.py @@ -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})와 무관하게 항상 한국어로 작성한다. (이 값들은 이후 단계에서 번역 및 검수를 거쳐 사용자 언어로 변환된다.) diff --git a/app/services/openai_adapter.py b/app/services/openai_adapter.py index c1d062b..3ffd6cb 100644 --- a/app/services/openai_adapter.py +++ b/app/services/openai_adapter.py @@ -22,6 +22,8 @@ logger = logging.getLogger(__name__) +MAX_ANALYSIS_ATTEMPTS = 2 + class OpenAIAdapterError(RuntimeError): pass @@ -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", @@ -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: diff --git a/tests/test_openai_adapter.py b/tests/test_openai_adapter.py new file mode 100644 index 0000000..fca2ae8 --- /dev/null +++ b/tests/test_openai_adapter.py @@ -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()