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..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
@@ -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,14 @@ public AiAnalysisResult analyze(
originalText, translatedText, language, newsletter.getDateCandidates());
List items = analysisResponse.items();
- saveExtractedItems(newsletterId, newsletter.getUserId(), items);
+ List savedItems =
+ saveExtractedItems(newsletterId, newsletter.getUserId(), items);
+ 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);
@@ -55,25 +66,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 +128,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 +225,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..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
@@ -2,9 +2,13 @@
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;
+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 +28,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 +71,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 +94,98 @@ 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
+ 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