From 225f5753ad1a2611db2a319a58a956ec4f48f581 Mon Sep 17 00:00:00 2001 From: minju Date: Sun, 24 May 2026 20:45:10 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20AI=20=EA=B0=80=EC=A0=95=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EB=AC=B8=20=EC=A0=84=EC=B2=B4=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A0=80=EC=9E=A5=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/newsletter-ai-analyze-integration.md | 26 +++++ .../pipeline/AiNewsletterClient.java | 34 ++++-- .../pipeline/NewsletterAiAnalyzer.java | 29 ++++- .../pipeline/AiNewsletterClientTest.java | 85 ++++++++++++++ .../pipeline/NewsletterAiAnalyzerTest.java | 104 ++++++++++++++++++ 5 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 docs/newsletter-ai-analyze-integration.md create mode 100644 src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java create mode 100644 src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java diff --git a/docs/newsletter-ai-analyze-integration.md b/docs/newsletter-ai-analyze-integration.md new file mode 100644 index 0000000..45ff266 --- /dev/null +++ b/docs/newsletter-ai-analyze-integration.md @@ -0,0 +1,26 @@ +# 가정통신문 AI 전체 분석 응답 저장 연동 + +이 문서는 BE가 AI 서버의 가정통신문 전체 분석 응답을 저장 모델에 반영하는 구현 결정을 정리한다. 상세 API 명세는 노션을 기준으로 관리한다. + +## 결정 사항 + +- BE는 가정통신문 파이프라인의 AI 분석 단계에서 `POST /ai/newsletters/analyze`를 호출한다. +- AI 서버 응답의 top-level `title`, `summary`를 `newsletter.title`, `newsletter.summary`에 저장한다. +- AI 서버 응답의 `items`는 기존 checklist 저장 흐름에 연결한다. +- `dateCandidates` 요청 형식과 `items` 응답 형식은 기존 `extract-items` 계약을 유지한다. +- `meta`는 운영 보조 정보로 받고, 현재 BE 저장 모델에는 직접 저장하지 않는다. +- AI 서버 호출 실패 정책은 `docs/newsletter-ai-failure-policy.md`를 따른다. + +## 저장 정책 + +- `title`: AI 응답 제목을 우선 저장한다. 빈 값이면 기존 BE fallback 제목을 사용한다. +- `summary`: AI 응답 요약을 우선 저장한다. 빈 값이면 기존 BE fallback 요약을 사용한다. +- `items`: `title`이 비어 있지 않은 항목만 checklist로 저장한다. +- `datetime`: TODO 저장 시 `targetDate`, `targetDateLabel` 생성에 사용한다. 파싱 실패 시 날짜 없이 저장한다. +- `dateStatus`: 현재 저장 모델에는 별도 컬럼이 없으므로 직접 저장하지 않는다. 날짜 확정 여부가 필요한 후속 정책은 calendar preview 저장 구조와 함께 별도 이슈에서 다룬다. + +## 호환 정책 + +- AI 서버의 `extract-items` baseline API는 유지하지만, BE 파이프라인은 `analyze`를 우선 사용한다. +- AI 서버가 `items`를 빈 배열로 반환해도 `title`, `summary`는 저장할 수 있다. +- AI 서버 장애 시에는 OCR/번역/dateCandidates 스냅샷을 보존하고 newsletter 상태를 `FAILED`로 둔다. diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java index 92bd2a5..89f791b 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java @@ -16,6 +16,7 @@ import java.time.ZoneId; import java.util.ArrayList; import java.util.List; +import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -24,7 +25,7 @@ public class AiNewsletterClient { private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Seoul"); - private static final String EXTRACT_ITEMS_PATH = "/ai/newsletters/extract-items"; + private static final String ANALYZE_PATH = "/ai/newsletters/analyze"; private final AiServerProperties aiServerProperties; private final ObjectMapper objectMapper; @@ -39,7 +40,7 @@ public AiNewsletterClient(AiServerProperties aiServerProperties, ObjectMapper ob .build(); } - public List extractItems( + public AnalysisResponse analyze( String originalText, String translatedText, String language, @@ -47,7 +48,7 @@ public List extractItems( try { String requestBody = objectMapper.writeValueAsString( - new ExtractionRequest( + new AnalysisRequest( originalText, translatedText, language != null ? language : "KO", @@ -57,7 +58,7 @@ public List extractItems( HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(normalizedBaseUrl() + EXTRACT_ITEMS_PATH)) + .uri(URI.create(normalizedBaseUrl() + ANALYZE_PATH)) .header("Content-Type", "application/json") .timeout(Duration.ofSeconds(aiServerProperties.getReadTimeoutSeconds())) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) @@ -68,16 +69,14 @@ public List extractItems( if (response.statusCode() < 200 || response.statusCode() >= 300) { log.error( - "[AiNewsletterClient] AI 서버 항목 추출 실패. status={}, body={}", + "[AiNewsletterClient] AI 서버 분석 실패. status={}, bodyLength={}", response.statusCode(), - response.body()); + response.body() != null ? response.body().length() : 0); throw new ExternalApiException( - ErrorCode.EXTERNAL_API_ERROR, "AI 서버 항목 추출 실패. status=" + response.statusCode()); + ErrorCode.EXTERNAL_API_ERROR, "AI 서버 분석 실패. status=" + response.statusCode()); } - ExtractionResponse extractionResponse = - objectMapper.readValue(response.body(), ExtractionResponse.class); - return extractionResponse.items() != null ? extractionResponse.items() : List.of(); + return objectMapper.readValue(response.body(), AnalysisResponse.class).normalized(); } catch (ExternalApiException e) { throw e; } catch (InterruptedException e) { @@ -119,7 +118,7 @@ private List toDateCandidateRequests( return requests; } - record ExtractionRequest( + record AnalysisRequest( String originalText, String translatedText, String language, @@ -136,12 +135,23 @@ record DateCandidateRequest( String extractionType) {} @JsonIgnoreProperties(ignoreUnknown = true) - record ExtractionResponse(List items) {} + public record AnalysisResponse( + String title, String summary, List items, Map meta) { + + AnalysisResponse normalized() { + return new AnalysisResponse(title, summary, items != null ? items : List.of(), meta); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SelectedDateCandidate( + Integer index, String candidateId, String originalText, LocalDate normalizedDate) {} @JsonIgnoreProperties(ignoreUnknown = true) public record ExtractedItem( String type, String title, + SelectedDateCandidate selectedDateCandidate, String datetime, String timezone, String evidenceText, diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index 407ec40..e2b78c6 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -4,6 +4,7 @@ import com.gachi.be.domain.checklist.entity.enums.ChecklistType; import com.gachi.be.domain.checklist.repository.ChecklistRepository; import com.gachi.be.domain.newsletter.entity.Newsletter; +import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.AnalysisResponse; import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.ExtractedItem; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import java.time.LocalDate; @@ -39,14 +40,16 @@ public AiAnalysisResult analyze( new IllegalStateException( "[AiAnalyzer] newsletter를 찾을 수 없습니다. newsletterId=" + newsletterId)); - List items = - aiNewsletterClient.extractItems( + AnalysisResponse analysisResponse = + aiNewsletterClient.analyze( originalText, translatedText, language, newsletter.getDateCandidates()); + List items = + analysisResponse.items() != null ? analysisResponse.items() : List.of(); saveExtractedItems(newsletterId, newsletter.getUserId(), items); - String title = inferTitle(originalText); - String summary = buildBaselineSummary(translatedText != null ? translatedText : originalText); + String title = normalizeTitle(analysisResponse.title(), originalText); + String summary = normalizeSummary(analysisResponse.summary(), translatedText, originalText); log.info( "[AiAnalyzer] AI 서버 분석 완료. newsletterId={}, extractedItems={}", newsletterId, items.size()); @@ -109,6 +112,20 @@ private LocalDate parseTargetDate(String value) { } } + private String normalizeTitle(String aiTitle, String originalText) { + if (aiTitle != null && !aiTitle.isBlank()) { + return trimToMax(compact(aiTitle), TITLE_MAX_LENGTH); + } + return inferTitle(originalText); + } + + private String normalizeSummary(String aiSummary, String translatedText, String originalText) { + if (aiSummary != null && !aiSummary.isBlank()) { + return trimToMax(compact(aiSummary), SUMMARY_MAX_LENGTH); + } + return buildBaselineSummary(firstNonBlank(translatedText, originalText)); + } + private String inferTitle(String originalText) { if (originalText == null || originalText.isBlank()) { return DEFAULT_TITLE; @@ -134,6 +151,10 @@ private String compact(String text) { return text == null ? "" : text.replaceAll("\\s+", " ").trim(); } + private String firstNonBlank(String primary, String fallback) { + return primary != null && !primary.isBlank() ? primary : fallback; + } + private String trimNullable(String value, int maxLength) { if (value == null || value.isBlank()) { return null; diff --git a/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java b/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java new file mode 100644 index 0000000..e606892 --- /dev/null +++ b/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java @@ -0,0 +1,85 @@ +package com.gachi.be.domain.newsletter.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.AnalysisResponse; +import com.gachi.be.global.config.external.AiServerProperties; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class AiNewsletterClientTest { + + @Test + void analyzeCallsAnalyzeEndpointAndParsesTitleSummaryItems() throws IOException { + AtomicReference requestPath = new AtomicReference<>(); + AtomicReference requestBody = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + ExecutorService executor = Executors.newSingleThreadExecutor(); + server.createContext( + "/ai/newsletters/analyze", + exchange -> { + requestPath.set(exchange.getRequestURI().getPath()); + requestBody.set( + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + + byte[] response = + """ + { + "title": "AI 제목", + "summary": "AI 요약", + "items": [ + { + "type": "checklist", + "title": "동의서 제출", + "selectedDateCandidate": null, + "datetime": "2026-05-25", + "timezone": "Asia/Seoul", + "evidenceText": "5월 25일까지 동의서를 제출해 주세요.", + "dateStatus": "confirmed", + "confidence": 0.9, + "needsUserConfirmation": false, + "confirmationQuestion": null + } + ], + "meta": {"mode": "test"} + } + """ + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.setExecutor(executor); + server.start(); + + try { + AiServerProperties properties = new AiServerProperties(); + properties.setBaseUrl("http://localhost:" + server.getAddress().getPort()); + properties.setConnectTimeoutSeconds(3); + properties.setReadTimeoutSeconds(3); + AiNewsletterClient client = + new AiNewsletterClient(properties, new ObjectMapper().findAndRegisterModules()); + + AnalysisResponse response = client.analyze("원문", "번역문", "KO", List.of()); + + assertThat(requestPath.get()).isEqualTo("/ai/newsletters/analyze"); + assertThat(requestBody.get()).contains("\"originalText\":\"원문\""); + assertThat(response.title()).isEqualTo("AI 제목"); + assertThat(response.summary()).isEqualTo("AI 요약"); + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).title()).isEqualTo("동의서 제출"); + } finally { + server.stop(0); + executor.shutdownNow(); + } + } +} diff --git a/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java new file mode 100644 index 0000000..e0ef31f --- /dev/null +++ b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java @@ -0,0 +1,104 @@ +package com.gachi.be.domain.newsletter.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.gachi.be.domain.checklist.entity.Checklist; +import com.gachi.be.domain.checklist.repository.ChecklistRepository; +import com.gachi.be.domain.newsletter.entity.Newsletter; +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; +import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.AnalysisResponse; +import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.ExtractedItem; +import com.gachi.be.domain.newsletter.pipeline.NewsletterAiAnalyzer.AiAnalysisResult; +import com.gachi.be.domain.newsletter.repository.NewsletterRepository; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class NewsletterAiAnalyzerTest { + + @Mock private AiNewsletterClient aiNewsletterClient; + @Mock private ChecklistRepository checklistRepository; + @Mock private NewsletterRepository newsletterRepository; + + @InjectMocks private NewsletterAiAnalyzer newsletterAiAnalyzer; + + @Test + void analyzeUsesAiTitleSummaryAndSavesItems() { + Long newsletterId = 10L; + Newsletter newsletter = + Newsletter.builder() + .userId(20L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + ExtractedItem item = + new ExtractedItem( + "reminder", + "준비물 확인", + null, + "2026-05-25", + "Asia/Seoul", + "체육복을 준비해 주세요.", + "confirmed", + 0.91, + false, + null); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("원문", "번역문", "KO", List.of())) + .thenReturn(new AnalysisResponse("AI 제목", "AI 요약", List.of(item), Map.of())); + + AiAnalysisResult result = newsletterAiAnalyzer.analyze(newsletterId, "원문", "번역문", "KO"); + + assertThat(result.title()).isEqualTo("AI 제목"); + assertThat(result.summary()).isEqualTo("AI 요약"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(checklistRepository).saveAll(captor.capture()); + + List savedItems = captor.getValue(); + assertThat(savedItems).hasSize(1); + assertThat(savedItems.get(0).getNewsletterId()).isEqualTo(newsletterId); + assertThat(savedItems.get(0).getUserId()).isEqualTo(20L); + assertThat(savedItems.get(0).getContent()).isEqualTo("준비물 확인"); + assertThat(savedItems.get(0).getDetail()).isEqualTo("체육복을 준비해 주세요."); + } + + @Test + void analyzeFallsBackWhenAiTitleSummaryAreBlank() { + Long newsletterId = 11L; + Newsletter newsletter = + Newsletter.builder() + .userId(21L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("가정통신문 제목\n본문입니다.", "번역 요약 대상", "KO", List.of())) + .thenReturn(new AnalysisResponse(" ", "", List.of(), Map.of())); + + AiAnalysisResult result = + newsletterAiAnalyzer.analyze(newsletterId, "가정통신문 제목\n본문입니다.", "번역 요약 대상", "KO"); + + assertThat(result.title()).isEqualTo("가정통신문 제목"); + assertThat(result.summary()).isEqualTo("번역 요약 대상"); + verify(checklistRepository, org.mockito.Mockito.never()).saveAll(anyList()); + } +} From f0447a84629d656ce9022ad5ddec94a801361aec Mon Sep 17 00:00:00 2001 From: minju Date: Sun, 24 May 2026 21:18:30 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20AI=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A0=80=EC=9E=A5=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/NewsletterAiAnalyzer.java | 3 +- .../pipeline/AiNewsletterClientTest.java | 128 ++++++++++++++---- .../pipeline/NewsletterAiAnalyzerTest.java | 62 ++++++++- 3 files changed, 160 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index e2b78c6..f8349fd 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -43,8 +43,7 @@ public AiAnalysisResult analyze( AnalysisResponse analysisResponse = aiNewsletterClient.analyze( originalText, translatedText, language, newsletter.getDateCandidates()); - List items = - analysisResponse.items() != null ? analysisResponse.items() : List.of(); + List items = analysisResponse.items(); saveExtractedItems(newsletterId, newsletter.getUserId(), items); diff --git a/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java b/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java index e606892..05d63c2 100644 --- a/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java +++ b/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java @@ -1,10 +1,13 @@ package com.gachi.be.domain.newsletter.pipeline; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.ObjectMapper; import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.AnalysisResponse; +import com.gachi.be.global.code.ErrorCode; import com.gachi.be.global.config.external.AiServerProperties; +import com.gachi.be.global.exception.ExternalApiException; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.net.InetSocketAddress; @@ -13,16 +16,29 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; class AiNewsletterClientTest { + private HttpServer server; + private ExecutorService executor; + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + if (executor != null) { + executor.shutdownNow(); + } + } + @Test void analyzeCallsAnalyzeEndpointAndParsesTitleSummaryItems() throws IOException { AtomicReference requestPath = new AtomicReference<>(); AtomicReference requestBody = new AtomicReference<>(); - HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); - ExecutorService executor = Executors.newSingleThreadExecutor(); + startServer(); server.createContext( "/ai/newsletters/analyze", exchange -> { @@ -53,33 +69,93 @@ void analyzeCallsAnalyzeEndpointAndParsesTitleSummaryItems() throws IOException } """ .getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, response.length); - exchange.getResponseBody().write(response); - exchange.close(); + sendResponse(exchange, 200, response); }); + + AiNewsletterClient client = newClient(3); + + AnalysisResponse response = client.analyze("원문", "번역문", "KO", List.of()); + + assertThat(requestPath.get()).isEqualTo("/ai/newsletters/analyze"); + assertThat(requestBody.get()).contains("\"originalText\":\"원문\""); + assertThat(response.title()).isEqualTo("AI 제목"); + assertThat(response.summary()).isEqualTo("AI 요약"); + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).title()).isEqualTo("동의서 제출"); + } + + @Test + void analyzeThrowsExternalApiExceptionWhenAiServerReturnsError() throws IOException { + startServer(); + server.createContext( + "/ai/newsletters/analyze", + exchange -> sendResponse(exchange, 500, "{\"detail\":\"server error\"}".getBytes())); + + AiNewsletterClient client = newClient(3); + + assertThatThrownBy(() -> client.analyze("원문", null, "KO", List.of())) + .isInstanceOf(ExternalApiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.EXTERNAL_API_ERROR); + } + + @Test + void analyzeThrowsExternalApiExceptionWhenAiServerResponseTimesOut() throws IOException { + startServer(); + server.createContext( + "/ai/newsletters/analyze", + exchange -> { + try { + Thread.sleep(1500); + sendResponse(exchange, 200, "{}".getBytes(StandardCharsets.UTF_8)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + AiNewsletterClient client = newClient(1); + + assertThatThrownBy(() -> client.analyze("원문", null, "KO", List.of())) + .isInstanceOf(ExternalApiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.EXTERNAL_API_ERROR); + } + + @Test + void analyzeThrowsExternalApiExceptionWhenAiServerReturnsMalformedJson() throws IOException { + startServer(); + server.createContext( + "/ai/newsletters/analyze", + exchange -> sendResponse(exchange, 200, "{".getBytes(StandardCharsets.UTF_8))); + + AiNewsletterClient client = newClient(3); + + assertThatThrownBy(() -> client.analyze("원문", null, "KO", List.of())) + .isInstanceOf(ExternalApiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.EXTERNAL_API_ERROR); + } + + private void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + executor = Executors.newSingleThreadExecutor(); server.setExecutor(executor); server.start(); + } - try { - AiServerProperties properties = new AiServerProperties(); - properties.setBaseUrl("http://localhost:" + server.getAddress().getPort()); - properties.setConnectTimeoutSeconds(3); - properties.setReadTimeoutSeconds(3); - AiNewsletterClient client = - new AiNewsletterClient(properties, new ObjectMapper().findAndRegisterModules()); - - AnalysisResponse response = client.analyze("원문", "번역문", "KO", List.of()); - - assertThat(requestPath.get()).isEqualTo("/ai/newsletters/analyze"); - assertThat(requestBody.get()).contains("\"originalText\":\"원문\""); - assertThat(response.title()).isEqualTo("AI 제목"); - assertThat(response.summary()).isEqualTo("AI 요약"); - assertThat(response.items()).hasSize(1); - assertThat(response.items().get(0).title()).isEqualTo("동의서 제출"); - } finally { - server.stop(0); - executor.shutdownNow(); - } + private AiNewsletterClient newClient(int readTimeoutSeconds) { + AiServerProperties properties = new AiServerProperties(); + properties.setBaseUrl("http://localhost:" + server.getAddress().getPort()); + properties.setConnectTimeoutSeconds(3); + properties.setReadTimeoutSeconds(readTimeoutSeconds); + return new AiNewsletterClient(properties, new ObjectMapper().findAndRegisterModules()); + } + + private void sendResponse(com.sun.net.httpserver.HttpExchange exchange, int status, byte[] body) + throws IOException { + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); } } diff --git a/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java index e0ef31f..c1a150c 100644 --- a/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java +++ b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java @@ -13,12 +13,14 @@ import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.ExtractedItem; import com.gachi.be.domain.newsletter.pipeline.NewsletterAiAnalyzer.AiAnalysisResult; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -30,6 +32,8 @@ class NewsletterAiAnalyzerTest { @Mock private ChecklistRepository checklistRepository; @Mock private NewsletterRepository newsletterRepository; + @Captor private ArgumentCaptor> checklistsCaptor; + @InjectMocks private NewsletterAiAnalyzer newsletterAiAnalyzer; @Test @@ -66,16 +70,16 @@ void analyzeUsesAiTitleSummaryAndSavesItems() { assertThat(result.title()).isEqualTo("AI 제목"); assertThat(result.summary()).isEqualTo("AI 요약"); - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(checklistRepository).saveAll(captor.capture()); + verify(checklistRepository).saveAll(checklistsCaptor.capture()); - List savedItems = captor.getValue(); + List savedItems = checklistsCaptor.getValue(); assertThat(savedItems).hasSize(1); assertThat(savedItems.get(0).getNewsletterId()).isEqualTo(newsletterId); assertThat(savedItems.get(0).getUserId()).isEqualTo(20L); assertThat(savedItems.get(0).getContent()).isEqualTo("준비물 확인"); assertThat(savedItems.get(0).getDetail()).isEqualTo("체육복을 준비해 주세요."); + assertThat(savedItems.get(0).getTargetDate()).isEqualTo(LocalDate.parse("2026-05-25")); + assertThat(savedItems.get(0).getTargetDateLabel()).isEqualTo("5월 25일"); } @Test @@ -92,7 +96,7 @@ void analyzeFallsBackWhenAiTitleSummaryAreBlank() { when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); when(aiNewsletterClient.analyze("가정통신문 제목\n본문입니다.", "번역 요약 대상", "KO", List.of())) - .thenReturn(new AnalysisResponse(" ", "", List.of(), Map.of())); + .thenReturn(new AnalysisResponse(" ", " ", List.of(), Map.of())); AiAnalysisResult result = newsletterAiAnalyzer.analyze(newsletterId, "가정통신문 제목\n본문입니다.", "번역 요약 대상", "KO"); @@ -101,4 +105,52 @@ void analyzeFallsBackWhenAiTitleSummaryAreBlank() { assertThat(result.summary()).isEqualTo("번역 요약 대상"); verify(checklistRepository, org.mockito.Mockito.never()).saveAll(anyList()); } + + @Test + void analyzeFallsBackOnlySummaryWhenAiSummaryIsBlank() { + Long newsletterId = 12L; + Newsletter newsletter = + Newsletter.builder() + .userId(22L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("원문 제목\n본문입니다.", "번역 요약 대상", "KO", List.of())) + .thenReturn(new AnalysisResponse("AI 제목", " ", List.of(), Map.of())); + + AiAnalysisResult result = + newsletterAiAnalyzer.analyze(newsletterId, "원문 제목\n본문입니다.", "번역 요약 대상", "KO"); + + assertThat(result.title()).isEqualTo("AI 제목"); + assertThat(result.summary()).isEqualTo("번역 요약 대상"); + verify(checklistRepository, org.mockito.Mockito.never()).saveAll(anyList()); + } + + @Test + void analyzeFallsBackOnlyTitleWhenAiTitleIsBlank() { + Long newsletterId = 13L; + Newsletter newsletter = + Newsletter.builder() + .userId(23L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("원문 제목\n본문입니다.", "번역 요약 대상", "KO", List.of())) + .thenReturn(new AnalysisResponse("", "AI 요약", List.of(), Map.of())); + + AiAnalysisResult result = + newsletterAiAnalyzer.analyze(newsletterId, "원문 제목\n본문입니다.", "번역 요약 대상", "KO"); + + assertThat(result.title()).isEqualTo("원문 제목"); + assertThat(result.summary()).isEqualTo("AI 요약"); + verify(checklistRepository, org.mockito.Mockito.never()).saveAll(anyList()); + } } From aefbcdeb9c280dd5316e5e153982bc37d4c12b2b Mon Sep 17 00:00:00 2001 From: minju Date: Sun, 24 May 2026 21:50:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20AI=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20OpenAI=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/.env.example | 4 ++++ deploy/docker-compose.yml | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/deploy/.env.example b/deploy/.env.example index ac31d36..2c563b7 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -32,7 +32,11 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true SPRING_MAIL_USERNAME= AI_SERVER_BASE_URL=http://ai:8000 +OPENAI_ENABLED=false OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_TIMEOUT_SECONDS=60 AUTH_EMAIL_FROM_ADDRESS= AUTH_EMAIL_NOOP_ALLOWED=false diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 03c78c1..ac9d5b5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -120,7 +120,11 @@ services: image: ${AI_IMAGE:-gachi-ai:latest} container_name: gachi-ai environment: - OPENAI_API_KEY: ${OPENAI_API_KEY} + OPENAI_ENABLED: ${OPENAI_ENABLED:-false} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o-mini} + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} + OPENAI_TIMEOUT_SECONDS: ${OPENAI_TIMEOUT_SECONDS:-60} healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:8000/ai/health || exit 1"] interval: 30s