From 5316f9813467a48a6b2a386466687de90ac681a0 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 25 May 2026 15:41:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20AI=20=EB=B6=84=EC=84=9D=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EA=B8=B0=EB=B0=98=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/.env.example | 2 +- deploy/docker-compose.yml | 2 +- docs/newsletter-ai-analyze-integration.md | 4 +- .../service/CalendarPreviewRedisService.java | 6 +- .../pipeline/NewsletterAiAnalyzer.java | 72 ++++++++++++++++--- .../pipeline/NewsletterAiAnalyzerTest.java | 59 +++++++++++++++ 6 files changed, 130 insertions(+), 15 deletions(-) diff --git a/deploy/.env.example b/deploy/.env.example index 2c563b7..8d0f866 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -34,7 +34,7 @@ SPRING_MAIL_USERNAME= AI_SERVER_BASE_URL=http://ai:8000 OPENAI_ENABLED=false OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o-mini +OPENAI_MODEL=gpt-4.1-mini OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_TIMEOUT_SECONDS=60 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ac9d5b5..c357c30 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -122,7 +122,7 @@ services: environment: OPENAI_ENABLED: ${OPENAI_ENABLED:-false} OPENAI_API_KEY: ${OPENAI_API_KEY:-} - OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o-mini} + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} OPENAI_TIMEOUT_SECONDS: ${OPENAI_TIMEOUT_SECONDS:-60} healthcheck: diff --git a/docs/newsletter-ai-analyze-integration.md b/docs/newsletter-ai-analyze-integration.md index 45ff266..46043be 100644 --- a/docs/newsletter-ai-analyze-integration.md +++ b/docs/newsletter-ai-analyze-integration.md @@ -17,10 +17,12 @@ - `summary`: AI 응답 요약을 우선 저장한다. 빈 값이면 기존 BE fallback 요약을 사용한다. - `items`: `title`이 비어 있지 않은 항목만 checklist로 저장한다. - `datetime`: TODO 저장 시 `targetDate`, `targetDateLabel` 생성에 사용한다. 파싱 실패 시 날짜 없이 저장한다. -- `dateStatus`: 현재 저장 모델에는 별도 컬럼이 없으므로 직접 저장하지 않는다. 날짜 확정 여부가 필요한 후속 정책은 calendar preview 저장 구조와 함께 별도 이슈에서 다룬다. +- `dateStatus`: checklist 저장 모델에는 별도 컬럼이 없으므로 직접 저장하지 않는다. 단, `confirmed`이고 `datetime`이 파싱 가능한 항목은 calendar preview 생성 대상으로 사용한다. +- `calendar preview`: AI 분석으로 저장된 checklist/TODO ID와 확정 날짜를 묶어 Redis preview에 저장한다. `ambiguous`, `missing`, 날짜 파싱 실패 항목은 사용자가 확인해야 하므로 preview에 자동 포함하지 않는다. ## 호환 정책 - AI 서버의 `extract-items` baseline API는 유지하지만, BE 파이프라인은 `analyze`를 우선 사용한다. - AI 서버가 `items`를 빈 배열로 반환해도 `title`, `summary`는 저장할 수 있다. +- 확정 날짜가 없는 분석 결과는 기존 Redis preview를 비워, 재분석 후 오래된 일정 후보가 남지 않게 한다. - AI 서버 장애 시에는 OCR/번역/dateCandidates 스냅샷을 보존하고 newsletter 상태를 `FAILED`로 둔다. diff --git a/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java b/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java index 515f40d..31a81ba 100644 --- a/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java +++ b/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java @@ -13,9 +13,9 @@ /** * 캘린더 일정 등록 플로우에서 사용하는 Redis 임시 데이터 관리 서비스. * - *

흐름 요약: 1. 가정통신문 AI 분석 완료 → AI 파이프라인에서 Redis에 preview 데이터 저장 (추후 연결) 2. GET /calendar/preview → - * Redis에서 읽어 팝업에 표시 3. PATCH /calendar/dates → Redis에서 날짜 수정 후 다시 저장 4. POST /calendar → Redis 데이터 - * 기반으로 calendar_events insert → Redis 키 삭제 + *

흐름 요약: 1. 가정통신문 AI 분석 완료 → AI 파이프라인에서 Redis에 preview 데이터 저장 2. GET /calendar/preview → Redis에서 + * 읽어 팝업에 표시 3. PATCH /calendar/dates → Redis에서 날짜 수정 후 다시 저장 4. POST /calendar → Redis 데이터 기반으로 + * calendar_events insert → Redis 키 삭제 * *

Redis 키 형식: newsletter:preview:{newsletterId} - TTL: 1시간 (사용자가 팝업을 열고 이탈해도 자동 만료) */ 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 f8349fd..1677cad 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 @@ -1,5 +1,7 @@ package com.gachi.be.domain.newsletter.pipeline; +import com.gachi.be.domain.calendar.dto.CalendarPreviewEvent; +import com.gachi.be.domain.calendar.service.CalendarPreviewRedisService; import com.gachi.be.domain.checklist.entity.Checklist; import com.gachi.be.domain.checklist.entity.enums.ChecklistType; import com.gachi.be.domain.checklist.repository.ChecklistRepository; @@ -9,6 +11,7 @@ import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import java.time.LocalDate; import java.time.format.DateTimeParseException; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,6 +29,7 @@ public class NewsletterAiAnalyzer { private final AiNewsletterClient aiNewsletterClient; private final ChecklistRepository checklistRepository; + private final CalendarPreviewRedisService calendarPreviewRedisService; private final NewsletterRepository newsletterRepository; public AiAnalysisResult analyze( @@ -45,7 +49,9 @@ public AiAnalysisResult analyze( originalText, translatedText, language, newsletter.getDateCandidates()); List items = analysisResponse.items(); - saveExtractedItems(newsletterId, newsletter.getUserId(), items); + List savedItems = + saveExtractedItems(newsletterId, newsletter.getUserId(), items); + saveCalendarPreview(newsletterId, savedItems); String title = normalizeTitle(analysisResponse.title(), originalText); String summary = normalizeSummary(analysisResponse.summary(), translatedText, originalText); @@ -55,25 +61,31 @@ public AiAnalysisResult analyze( return new AiAnalysisResult(title, summary); } - private void saveExtractedItems(Long newsletterId, Long userId, List items) { + private List saveExtractedItems( + Long newsletterId, Long userId, List items) { if (items == null || items.isEmpty()) { log.warn("[AiAnalyzer] AI 서버 항목 추출 결과 없음. newsletterId={}", newsletterId); - return; + return List.of(); } + List validItems = + items.stream().filter(item -> item.title() != null && !item.title().isBlank()).toList(); List entities = - items.stream() - .filter(item -> item.title() != null && !item.title().isBlank()) - .map(item -> toChecklist(newsletterId, userId, item)) - .toList(); + validItems.stream().map(item -> toChecklist(newsletterId, userId, item)).toList(); if (entities.isEmpty()) { log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); - return; + return List.of(); } - checklistRepository.saveAll(entities); + List savedEntities = checklistRepository.saveAll(entities); log.debug("[AiAnalyzer] AI 서버 추출 항목 {}개 저장 완료.", entities.size()); + + List savedItems = new ArrayList<>(); + for (int i = 0; i < validItems.size(); i++) { + savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i))); + } + return savedItems; } private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item) { @@ -111,6 +123,46 @@ private LocalDate parseTargetDate(String value) { } } + private void saveCalendarPreview(Long newsletterId, List savedItems) { + List previewEvents = new ArrayList<>(); + + for (SavedExtractedItem savedItem : savedItems) { + ExtractedItem item = savedItem.item(); + String extractedDate = normalizePreviewDate(item.datetime()); + if (!"confirmed".equalsIgnoreCase(item.dateStatus()) || extractedDate == null) { + continue; + } + + previewEvents.add( + new CalendarPreviewEvent( + "ai_evt_" + (previewEvents.size() + 1), + trimToMax(item.title().trim(), CHECKLIST_TEXT_MAX_LENGTH), + extractedDate, + true, + checklistIdList(savedItem.checklist()))); + } + + if (previewEvents.isEmpty()) { + // 재분석 결과에 확정 날짜가 없으면 이전 미리보기 데이터가 남아 잘못 등록될 수 있어 비운다. + calendarPreviewRedisService.deletePreview(newsletterId); + log.debug("[AiAnalyzer] 캘린더 preview 생성 대상 없음. newsletterId={}", newsletterId); + return; + } + + calendarPreviewRedisService.savePreview(newsletterId, previewEvents); + log.debug( + "[AiAnalyzer] 캘린더 preview {}개 저장 완료. newsletterId={}", previewEvents.size(), newsletterId); + } + + private String normalizePreviewDate(String value) { + LocalDate targetDate = parseTargetDate(value); + return targetDate != null ? targetDate.toString() : null; + } + + private List checklistIdList(Checklist checklist) { + return checklist.getId() != null ? List.of(checklist.getId()) : List.of(); + } + private String normalizeTitle(String aiTitle, String originalText) { if (aiTitle != null && !aiTitle.isBlank()) { return trimToMax(compact(aiTitle), TITLE_MAX_LENGTH); @@ -168,5 +220,7 @@ private String trimToMax(String value, int maxLength) { return value.substring(0, maxLength - 3).stripTrailing() + "..."; } + private record SavedExtractedItem(ExtractedItem item, Checklist checklist) {} + public record AiAnalysisResult(String title, String summary) {} } 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 c1a150c..e349357 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 @@ -2,9 +2,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.gachi.be.domain.calendar.dto.CalendarPreviewEvent; +import com.gachi.be.domain.calendar.service.CalendarPreviewRedisService; import com.gachi.be.domain.checklist.entity.Checklist; import com.gachi.be.domain.checklist.repository.ChecklistRepository; import com.gachi.be.domain.newsletter.entity.Newsletter; @@ -24,15 +27,18 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class NewsletterAiAnalyzerTest { @Mock private AiNewsletterClient aiNewsletterClient; @Mock private ChecklistRepository checklistRepository; + @Mock private CalendarPreviewRedisService calendarPreviewRedisService; @Mock private NewsletterRepository newsletterRepository; @Captor private ArgumentCaptor> checklistsCaptor; + @Captor private ArgumentCaptor> previewEventsCaptor; @InjectMocks private NewsletterAiAnalyzer newsletterAiAnalyzer; @@ -64,6 +70,13 @@ void analyzeUsesAiTitleSummaryAndSavesItems() { when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); when(aiNewsletterClient.analyze("원문", "번역문", "KO", List.of())) .thenReturn(new AnalysisResponse("AI 제목", "AI 요약", List.of(item), Map.of())); + when(checklistRepository.saveAll(anyList())) + .thenAnswer( + invocation -> { + List checklists = invocation.getArgument(0); + ReflectionTestUtils.setField(checklists.get(0), "id", 501L); + return checklists; + }); AiAnalysisResult result = newsletterAiAnalyzer.analyze(newsletterId, "원문", "번역문", "KO"); @@ -80,6 +93,52 @@ void analyzeUsesAiTitleSummaryAndSavesItems() { 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일"); + + verify(calendarPreviewRedisService) + .savePreview(eq(newsletterId), previewEventsCaptor.capture()); + List previewEvents = previewEventsCaptor.getValue(); + assertThat(previewEvents).hasSize(1); + assertThat(previewEvents.get(0).tempEventId()).isEqualTo("ai_evt_1"); + assertThat(previewEvents.get(0).title()).isEqualTo("준비물 확인"); + assertThat(previewEvents.get(0).extractedDate()).isEqualTo("2026-05-25"); + assertThat(previewEvents.get(0).isDateExtracted()).isTrue(); + assertThat(previewEvents.get(0).checklistIds()).containsExactly(501L); + } + + @Test + void analyzeSkipsCalendarPreviewWhenDateIsNotConfirmed() { + Long newsletterId = 14L; + Newsletter newsletter = + Newsletter.builder() + .userId(24L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + ExtractedItem item = + new ExtractedItem( + "deadline", + "신청서 제출", + null, + "2026-05-25", + "Asia/Seoul", + "체험학습 3일 전까지 신청서를 제출해 주세요.", + "ambiguous", + 0.7, + true, + "체험학습 날짜를 확인해 주세요."); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("원문", "번역문", "KO", List.of())) + .thenReturn(new AnalysisResponse("AI 제목", "AI 요약", List.of(item), Map.of())); + when(checklistRepository.saveAll(anyList())) + .thenAnswer(invocation -> invocation.getArgument(0)); + + newsletterAiAnalyzer.analyze(newsletterId, "원문", "번역문", "KO"); + + verify(calendarPreviewRedisService).deletePreview(newsletterId); } @Test From 812a4ba47143302b1a7f7be5f5c2c69c42a25192 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 25 May 2026 16:04:10 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/NewsletterAiAnalyzer.java | 7 ++- .../pipeline/NewsletterAiAnalyzerTest.java | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) 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 1677cad..ca25419 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 @@ -51,7 +51,12 @@ public AiAnalysisResult analyze( List savedItems = saveExtractedItems(newsletterId, newsletter.getUserId(), items); - saveCalendarPreview(newsletterId, savedItems); + try { + saveCalendarPreview(newsletterId, savedItems); + } catch (RuntimeException e) { + log.warn( + "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e); + } String title = normalizeTitle(analysisResponse.title(), originalText); String summary = normalizeSummary(analysisResponse.summary(), translatedText, originalText); 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 e349357..0d98593 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 @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -141,6 +142,52 @@ void analyzeSkipsCalendarPreviewWhenDateIsNotConfirmed() { verify(calendarPreviewRedisService).deletePreview(newsletterId); } + @Test + void analyzeContinuesWhenCalendarPreviewSaveFails() { + Long newsletterId = 15L; + Newsletter newsletter = + Newsletter.builder() + .userId(25L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + ExtractedItem item = + new ExtractedItem( + "schedule", + "현장학습 참석", + null, + "2026-05-25", + "Asia/Seoul", + "5월 25일 현장학습에 참석합니다.", + "confirmed", + 0.9, + 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())); + when(checklistRepository.saveAll(anyList())) + .thenAnswer( + invocation -> { + List checklists = invocation.getArgument(0); + ReflectionTestUtils.setField(checklists.get(0), "id", 502L); + return checklists; + }); + doThrow(new RuntimeException("Redis down")) + .when(calendarPreviewRedisService) + .savePreview(eq(newsletterId), anyList()); + + AiAnalysisResult result = newsletterAiAnalyzer.analyze(newsletterId, "원문", "번역문", "KO"); + + assertThat(result.title()).isEqualTo("AI 제목"); + assertThat(result.summary()).isEqualTo("AI 요약"); + verify(checklistRepository).saveAll(anyList()); + } + @Test void analyzeFallsBackWhenAiTitleSummaryAreBlank() { Long newsletterId = 11L;