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
2 changes: 1 addition & 1 deletion deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion docs/newsletter-ai-analyze-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`로 둔다.
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
/**
* 캘린더 일정 등록 플로우에서 사용하는 Redis 임시 데이터 관리 서비스.
*
* <p>흐름 요약: 1. 가정통신문 AI 분석 완료 → AI 파이프라인에서 Redis에 preview 데이터 저장 (추후 연결) 2. GET /calendar/preview →
* Redis에서 읽어 팝업에 표시 3. PATCH /calendar/dates → Redis에서 날짜 수정 후 다시 저장 4. POST /calendar → Redis 데이터
* 기반으로 calendar_events insert → Redis 키 삭제
* <p>흐름 요약: 1. 가정통신문 AI 분석 완료 → AI 파이프라인에서 Redis에 preview 데이터 저장 2. GET /calendar/preview → Redis에서
* 읽어 팝업에 표시 3. PATCH /calendar/dates → Redis에서 날짜 수정 후 다시 저장 4. POST /calendar → Redis 데이터 기반으로
* calendar_events insert → Redis 키 삭제
*
* <p>Redis 키 형식: newsletter:preview:{newsletterId} - TTL: 1시간 (사용자가 팝업을 열고 이탈해도 자동 만료)
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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(
Expand All @@ -45,7 +49,14 @@ public AiAnalysisResult analyze(
originalText, translatedText, language, newsletter.getDateCandidates());
List<ExtractedItem> items = analysisResponse.items();

saveExtractedItems(newsletterId, newsletter.getUserId(), items);
List<SavedExtractedItem> 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);
Expand All @@ -55,25 +66,31 @@ public AiAnalysisResult analyze(
return new AiAnalysisResult(title, summary);
}

private void saveExtractedItems(Long newsletterId, Long userId, List<ExtractedItem> items) {
private List<SavedExtractedItem> saveExtractedItems(
Long newsletterId, Long userId, List<ExtractedItem> items) {
if (items == null || items.isEmpty()) {
log.warn("[AiAnalyzer] AI 서버 항목 추출 결과 없음. newsletterId={}", newsletterId);
return;
return List.of();
}

List<ExtractedItem> validItems =
items.stream().filter(item -> item.title() != null && !item.title().isBlank()).toList();
List<Checklist> 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<Checklist> savedEntities = checklistRepository.saveAll(entities);
log.debug("[AiAnalyzer] AI 서버 추출 항목 {}개 저장 완료.", entities.size());

List<SavedExtractedItem> 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) {
Expand Down Expand Up @@ -111,6 +128,46 @@ private LocalDate parseTargetDate(String value) {
}
}

private void saveCalendarPreview(Long newsletterId, List<SavedExtractedItem> savedItems) {
List<CalendarPreviewEvent> 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<Long> 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);
Expand Down Expand Up @@ -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) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<List<Checklist>> checklistsCaptor;
@Captor private ArgumentCaptor<List<CalendarPreviewEvent>> previewEventsCaptor;

@InjectMocks private NewsletterAiAnalyzer newsletterAiAnalyzer;

Expand Down Expand Up @@ -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<Checklist> checklists = invocation.getArgument(0);
ReflectionTestUtils.setField(checklists.get(0), "id", 501L);
return checklists;
});

AiAnalysisResult result = newsletterAiAnalyzer.analyze(newsletterId, "원문", "번역문", "KO");

Expand All @@ -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<CalendarPreviewEvent> 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<Checklist> 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
Expand Down