mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-13 03:39:10 +00:00
Chore: 회의록 상세조회 API 수정
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package com.unicorn.hgzero.meeting.biz.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 분석 결과 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AiAnalysisDTO {
|
||||
|
||||
private String minutesId;
|
||||
private String analysisId;
|
||||
private String status; // PENDING, IN_PROGRESS, COMPLETED, FAILED
|
||||
private LocalDateTime requestedAt;
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
// AI 분석 결과
|
||||
private AnalysisResult result;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class AnalysisResult {
|
||||
|
||||
// 핵심내용 (최대 4개)
|
||||
private List<KeyPoint> keyPoints;
|
||||
|
||||
// 키워드 (해시태그 형태)
|
||||
private List<String> keywords;
|
||||
|
||||
// 전체 요약
|
||||
private String summary;
|
||||
|
||||
// 결정사항
|
||||
private List<Decision> decisions;
|
||||
|
||||
// 관련회의록 추천
|
||||
private List<RelatedMinutes> relatedMinutes;
|
||||
|
||||
// 감정 분석 (선택사항)
|
||||
private SentimentAnalysis sentiment;
|
||||
|
||||
// 분석 품질 점수 (0-100)
|
||||
private int qualityScore;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class KeyPoint {
|
||||
private int index;
|
||||
private String content;
|
||||
private double confidence; // 신뢰도 (0.0 - 1.0)
|
||||
private String category; // DECISION, DISCUSSION, ACTION_ITEM 등
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Decision {
|
||||
private String content;
|
||||
private String category; // STRATEGIC, OPERATIONAL, TECHNICAL 등
|
||||
private double confidence;
|
||||
private String extractedFrom; // 추출된 원문
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class RelatedMinutes {
|
||||
private String minutesId;
|
||||
private String title;
|
||||
private double relevanceScore; // 연관도 점수 (0.0 - 1.0)
|
||||
private String reason; // 연관 이유
|
||||
private LocalDateTime meetingDate;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class SentimentAnalysis {
|
||||
private String overall; // POSITIVE, NEUTRAL, NEGATIVE
|
||||
private double positiveScore;
|
||||
private double neutralScore;
|
||||
private double negativeScore;
|
||||
private List<String> positiveKeywords;
|
||||
private List<String> negativeKeywords;
|
||||
}
|
||||
}
|
||||
+37
@@ -1,6 +1,7 @@
|
||||
package com.unicorn.hgzero.meeting.infra.cache;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unicorn.hgzero.meeting.biz.dto.AiAnalysisDTO;
|
||||
import com.unicorn.hgzero.meeting.infra.dto.response.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -8,6 +9,7 @@ import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,7 @@ public class CacheService {
|
||||
private static final String TEMPLATE_DETAIL_PREFIX = "template:detail:";
|
||||
private static final String DASHBOARD_PREFIX = "dashboard:";
|
||||
private static final String SESSION_PREFIX = "session:";
|
||||
private static final String AI_ANALYSIS_PREFIX = "ai:analysis:";
|
||||
|
||||
/**
|
||||
* 회의 정보 캐시 저장
|
||||
@@ -361,4 +364,38 @@ public class CacheService {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// AI 분석 관련 캐시 메서드
|
||||
public void cacheAiAnalysis(String minutesId, AiAnalysisDTO analysis) {
|
||||
try {
|
||||
String value = objectMapper.writeValueAsString(analysis);
|
||||
redisTemplate.opsForValue().set(AI_ANALYSIS_PREFIX + minutesId, value, Duration.ofHours(1));
|
||||
log.debug("AI 분석 결과 캐시 저장 - minutesId: {}", minutesId);
|
||||
} catch (Exception e) {
|
||||
log.error("AI 분석 결과 캐시 저장 실패 - minutesId: {}", minutesId, e);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<AiAnalysisDTO> getAiAnalysis(String minutesId) {
|
||||
try {
|
||||
String value = redisTemplate.opsForValue().get(AI_ANALYSIS_PREFIX + minutesId);
|
||||
if (value != null) {
|
||||
AiAnalysisDTO analysis = objectMapper.readValue(value, AiAnalysisDTO.class);
|
||||
log.debug("AI 분석 결과 캐시 조회 성공 - minutesId: {}", minutesId);
|
||||
return Optional.of(analysis);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("AI 분석 결과 캐시 조회 실패 - minutesId: {}", minutesId, e);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public void evictAiAnalysisCache(String minutesId) {
|
||||
try {
|
||||
redisTemplate.delete(AI_ANALYSIS_PREFIX + minutesId);
|
||||
log.debug("AI 분석 캐시 삭제 - minutesId: {}", minutesId);
|
||||
} catch (Exception e) {
|
||||
log.error("AI 분석 캐시 삭제 실패 - minutesId: {}", minutesId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.unicorn.hgzero.meeting.infra.config;
|
||||
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* RestTemplate 설정
|
||||
* HTTP 클라이언트 관련 빈 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class RestTemplateConfig {
|
||||
|
||||
/**
|
||||
* 기본 RestTemplate 빈
|
||||
* AI 서비스 호출용
|
||||
*/
|
||||
@Bean
|
||||
public RestTemplate restTemplate(RestTemplateBuilder builder) {
|
||||
return builder
|
||||
.setConnectTimeout(Duration.ofSeconds(5))
|
||||
.setReadTimeout(Duration.ofSeconds(10))
|
||||
.requestFactory(this::clientHttpRequestFactory)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청 팩토리 설정
|
||||
*/
|
||||
private ClientHttpRequestFactory clientHttpRequestFactory() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(5000); // 5초
|
||||
factory.setReadTimeout(10000); // 10초
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
+868
-43
@@ -2,15 +2,23 @@ package com.unicorn.hgzero.meeting.infra.controller;
|
||||
|
||||
import com.unicorn.hgzero.common.dto.ApiResponse;
|
||||
import com.unicorn.hgzero.common.exception.BusinessException;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.Minutes;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.Todo;
|
||||
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
|
||||
import com.unicorn.hgzero.meeting.biz.service.MeetingService;
|
||||
import com.unicorn.hgzero.meeting.biz.service.MinutesService;
|
||||
import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService;
|
||||
import com.unicorn.hgzero.meeting.biz.service.TodoService;
|
||||
import com.unicorn.hgzero.meeting.infra.dto.request.UpdateMinutesRequest;
|
||||
import com.unicorn.hgzero.meeting.infra.dto.response.MinutesDetailResponse;
|
||||
import com.unicorn.hgzero.meeting.infra.dto.response.MinutesListResponse;
|
||||
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
|
||||
import com.unicorn.hgzero.meeting.infra.event.publisher.EventPublisher;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.AiServiceGateway;
|
||||
import com.unicorn.hgzero.meeting.biz.dto.AiAnalysisDTO;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesAnalysisRequestEvent;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@@ -25,8 +33,17 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -44,6 +61,9 @@ public class MinutesController {
|
||||
private final MinutesSectionService minutesSectionService;
|
||||
private final CacheService cacheService;
|
||||
private final EventPublisher eventPublisher;
|
||||
private final MeetingService meetingService;
|
||||
private final TodoService todoService;
|
||||
private final AiServiceGateway aiServiceGateway;
|
||||
|
||||
/**
|
||||
* 회의록 목록 조회
|
||||
@@ -125,10 +145,20 @@ public class MinutesController {
|
||||
log.info("회의록 상세 조회 요청 - userId: {}, minutesId: {}", userId, minutesId);
|
||||
|
||||
try {
|
||||
// 캐시에서 먼저 조회 시도
|
||||
MinutesDetailResponse cachedResponse = cacheService.getCachedMinutesDetail(minutesId);
|
||||
if (cachedResponse != null) {
|
||||
log.debug("회의록 상세 캐시 히트 - minutesId: {}", minutesId);
|
||||
return ResponseEntity.ok(ApiResponse.success(cachedResponse));
|
||||
}
|
||||
|
||||
// 실제 데이터 조회
|
||||
MinutesDTO minutesDTO = minutesService.getMinutesById(minutesId);
|
||||
MinutesDetailResponse response = convertToMinutesDetailResponse(minutesDTO);
|
||||
|
||||
// AI 분석 결과 포함 (비동기 처리)
|
||||
enhanceWithAiAnalysis(response, minutesDTO, userId, userName);
|
||||
|
||||
// 캐시 저장
|
||||
cacheService.cacheMinutesDetail(minutesId, response);
|
||||
|
||||
@@ -919,49 +949,38 @@ public class MinutesController {
|
||||
|
||||
|
||||
private MinutesDetailResponse convertToMinutesDetailResponse(MinutesDTO minutesDTO) {
|
||||
// 기본 회의록 정보는 실제 데이터 사용
|
||||
MinutesDetailResponse.MeetingInfo meetingInfo = MinutesDetailResponse.MeetingInfo.builder()
|
||||
.meetingId(minutesDTO.getMeetingId())
|
||||
.title(minutesDTO.getMeetingTitle())
|
||||
.location("회의실 정보 없음") // 추후 실제 데이터로 변경 필요
|
||||
.participants(List.of()) // 추후 실제 참석자 정보로 변경 필요
|
||||
.build();
|
||||
|
||||
MinutesDetailResponse.Statistics stats = MinutesDetailResponse.Statistics.builder()
|
||||
.participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 0)
|
||||
.durationMinutes(90) // 기본값 - 추후 실제 데이터로 변경 필요
|
||||
.agendaCount(0) // 기본값 - 추후 실제 데이터로 변경 필요
|
||||
.todoCount(minutesDTO.getTodoCount() != null ? minutesDTO.getTodoCount() : 0)
|
||||
.build();
|
||||
|
||||
MinutesDetailResponse.DashboardInfo dashboardInfo = MinutesDetailResponse.DashboardInfo.builder()
|
||||
.keyPoints(List.of()) // 추후 실제 데이터로 변경 필요
|
||||
.keywords(List.of()) // 추후 실제 데이터로 변경 필요
|
||||
.stats(stats)
|
||||
.decisions(List.of()) // 추후 실제 데이터로 변경 필요
|
||||
.todoProgress(MinutesDetailResponse.TodoProgress.builder()
|
||||
.totalCount(minutesDTO.getTodoCount() != null ? minutesDTO.getTodoCount() : 0)
|
||||
.completedCount(minutesDTO.getCompletedTodoCount() != null ? minutesDTO.getCompletedTodoCount() : 0)
|
||||
.progressPercentage(calculateProgressPercentage(minutesDTO.getTodoCount(), minutesDTO.getCompletedTodoCount()))
|
||||
.todos(List.of()) // 추후 실제 데이터로 변경 필요
|
||||
.build())
|
||||
.relatedMinutes(List.of()) // 추후 실제 데이터로 변경 필요
|
||||
.build();
|
||||
|
||||
return MinutesDetailResponse.builder()
|
||||
.minutesId(minutesDTO.getMinutesId())
|
||||
.title(minutesDTO.getTitle())
|
||||
.memo(minutesDTO.getMemo() != null ? minutesDTO.getMemo() : "")
|
||||
.status(minutesDTO.getStatus())
|
||||
.version(minutesDTO.getVersion())
|
||||
.createdAt(minutesDTO.getCreatedAt())
|
||||
.lastModifiedAt(minutesDTO.getLastModifiedAt())
|
||||
.createdBy(minutesDTO.getCreatedBy())
|
||||
.lastModifiedBy(minutesDTO.getLastModifiedBy())
|
||||
.meeting(meetingInfo)
|
||||
.dashboard(dashboardInfo)
|
||||
.agendas(List.of()) // 추후 실제 안건 데이터로 변경 필요
|
||||
.build();
|
||||
try {
|
||||
// 실제 회의 정보 조회
|
||||
MinutesDetailResponse.MeetingInfo meetingInfo = buildMeetingInfo(minutesDTO);
|
||||
|
||||
// 실제 안건 정보 조회
|
||||
List<MinutesDetailResponse.AgendaInfo> agendas = buildAgendaInfoList(minutesDTO.getMinutesId());
|
||||
|
||||
// 실제 Todo 정보 조회
|
||||
MinutesDetailResponse.TodoProgress todoProgress = buildTodoProgress(minutesDTO.getMinutesId());
|
||||
|
||||
// 실제 대시보드 정보 구성
|
||||
MinutesDetailResponse.DashboardInfo dashboardInfo = buildDashboardInfo(minutesDTO, agendas, todoProgress);
|
||||
|
||||
return MinutesDetailResponse.builder()
|
||||
.minutesId(minutesDTO.getMinutesId())
|
||||
.title(minutesDTO.getTitle())
|
||||
.memo(minutesDTO.getMemo() != null ? minutesDTO.getMemo() : "")
|
||||
.status(minutesDTO.getStatus())
|
||||
.version(minutesDTO.getVersion())
|
||||
.createdAt(minutesDTO.getCreatedAt())
|
||||
.lastModifiedAt(minutesDTO.getLastModifiedAt())
|
||||
.createdBy(minutesDTO.getCreatedBy())
|
||||
.lastModifiedBy(minutesDTO.getLastModifiedBy())
|
||||
.meeting(meetingInfo)
|
||||
.dashboard(dashboardInfo)
|
||||
.agendas(agendas)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("실제 데이터 조회 실패, 기본값 사용 - minutesId: {}", minutesDTO.getMinutesId(), e);
|
||||
return buildFallbackResponse(minutesDTO);
|
||||
}
|
||||
}
|
||||
|
||||
private int calculateProgressPercentage(Integer totalCount, Integer completedCount) {
|
||||
@@ -974,4 +993,810 @@ public class MinutesController {
|
||||
return (completedCount * 100) / totalCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의 정보 구성
|
||||
*/
|
||||
private MinutesDetailResponse.MeetingInfo buildMeetingInfo(MinutesDTO minutesDTO) {
|
||||
try {
|
||||
// 실제 회의 정보 조회
|
||||
var meeting = meetingService.getMeeting(minutesDTO.getMeetingId());
|
||||
|
||||
return MinutesDetailResponse.MeetingInfo.builder()
|
||||
.meetingId(minutesDTO.getMeetingId())
|
||||
.title(minutesDTO.getMeetingTitle())
|
||||
.scheduledAt(meeting.getScheduledAt())
|
||||
.startedAt(meeting.getStartedAt())
|
||||
.endedAt(meeting.getEndedAt())
|
||||
.organizerId(meeting.getOrganizerId())
|
||||
.organizerName("주최자") // TODO: 실제 주최자 이름 조회 필요
|
||||
.location(meeting.getLocation() != null ? meeting.getLocation() : "온라인 회의")
|
||||
.durationMinutes(calculateActualDuration(meeting))
|
||||
.participants(buildParticipantList(minutesDTO.getMeetingId()))
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
log.warn("회의 정보 조회 실패 - meetingId: {}", minutesDTO.getMeetingId(), e);
|
||||
return buildDefaultMeetingInfo(minutesDTO);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참석자 목록 구성
|
||||
*/
|
||||
private List<MinutesDetailResponse.Participant> buildParticipantList(String meetingId) {
|
||||
try {
|
||||
// 실제 참석자 조회 (현재는 기본값 반환)
|
||||
// TODO: MeetingService.getParticipants() 메소드 구현 필요
|
||||
|
||||
// 임시로 기본 참석자 목록 반환
|
||||
return List.of(
|
||||
MinutesDetailResponse.Participant.builder()
|
||||
.userId("user1")
|
||||
.name("회의 생성자")
|
||||
.role("생성자")
|
||||
.avatarColor("avatar-green")
|
||||
.build(),
|
||||
MinutesDetailResponse.Participant.builder()
|
||||
.userId("user2")
|
||||
.name("참여자")
|
||||
.role("참여자")
|
||||
.avatarColor("avatar-blue")
|
||||
.build()
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.warn("참석자 정보 조회 실패 - meetingId: {}", meetingId, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 안건 정보 목록 구성
|
||||
*/
|
||||
private List<MinutesDetailResponse.AgendaInfo> buildAgendaInfoList(String minutesId) {
|
||||
try {
|
||||
// 실제 안건 조회
|
||||
var sections = minutesSectionService.getSectionsByMinutes(minutesId);
|
||||
|
||||
return sections.stream()
|
||||
.map(this::convertToAgendaInfo)
|
||||
.collect(Collectors.toList());
|
||||
} catch (Exception e) {
|
||||
log.warn("안건 정보 조회 실패 - minutesId: {}", minutesId, e);
|
||||
return createSampleAgendas();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo 진행상황 구성
|
||||
*/
|
||||
private MinutesDetailResponse.TodoProgress buildTodoProgress(String minutesId) {
|
||||
try {
|
||||
// 실제 Todo 목록 조회
|
||||
var todos = todoService.getTodosByMinutes(minutesId);
|
||||
|
||||
int totalCount = todos.size();
|
||||
int completedCount = (int) todos.stream()
|
||||
.filter(todo -> "COMPLETED".equals(todo.getStatus()))
|
||||
.count();
|
||||
|
||||
List<MinutesDetailResponse.SimpleTodo> simpleTodos = todos.stream()
|
||||
.map(this::convertToSimpleTodo)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return MinutesDetailResponse.TodoProgress.builder()
|
||||
.totalCount(totalCount)
|
||||
.completedCount(completedCount)
|
||||
.progressPercentage(calculateProgressPercentage(totalCount, completedCount))
|
||||
.todos(simpleTodos)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
log.warn("Todo 정보 조회 실패 - minutesId: {}", minutesId, e);
|
||||
return createSampleTodoProgress();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 정보 구성
|
||||
*/
|
||||
private MinutesDetailResponse.DashboardInfo buildDashboardInfo(
|
||||
MinutesDTO minutesDTO,
|
||||
List<MinutesDetailResponse.AgendaInfo> agendas,
|
||||
MinutesDetailResponse.TodoProgress todoProgress) {
|
||||
|
||||
// 핵심내용 추출 (안건별 AI 요약에서)
|
||||
List<MinutesDetailResponse.KeyPoint> keyPoints = extractKeyPoints(agendas);
|
||||
|
||||
// 키워드 추출
|
||||
List<String> keywords = extractKeywords(agendas);
|
||||
|
||||
// 실제 회의 시간 계산
|
||||
int actualDurationMinutes = calculateActualMeetingDuration(minutesDTO.getMeetingId());
|
||||
|
||||
// 통계 정보
|
||||
MinutesDetailResponse.Statistics stats = MinutesDetailResponse.Statistics.builder()
|
||||
.participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 0)
|
||||
.durationMinutes(actualDurationMinutes)
|
||||
.agendaCount(agendas.size())
|
||||
.todoCount(todoProgress.getTotalCount())
|
||||
.build();
|
||||
|
||||
// 결정사항 추출
|
||||
List<MinutesDetailResponse.Decision> decisions = extractDecisions(agendas);
|
||||
|
||||
// AI 기반 관련회의록 조회 (캐시 우선)
|
||||
List<MinutesDetailResponse.RelatedMinutes> relatedMinutes = getRelatedMinutesFromAI(minutesDTO.getMinutesId());
|
||||
|
||||
return MinutesDetailResponse.DashboardInfo.builder()
|
||||
.keyPoints(keyPoints)
|
||||
.keywords(keywords)
|
||||
.stats(stats)
|
||||
.decisions(decisions)
|
||||
.todoProgress(todoProgress)
|
||||
.relatedMinutes(relatedMinutes)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴백 응답 구성
|
||||
*/
|
||||
private MinutesDetailResponse buildFallbackResponse(MinutesDTO minutesDTO) {
|
||||
MinutesDetailResponse.MeetingInfo meetingInfo = buildDefaultMeetingInfo(minutesDTO);
|
||||
MinutesDetailResponse.TodoProgress todoProgress = createSampleTodoProgress();
|
||||
List<MinutesDetailResponse.AgendaInfo> agendas = createSampleAgendas();
|
||||
MinutesDetailResponse.DashboardInfo dashboardInfo = buildDashboardInfo(minutesDTO, agendas, todoProgress);
|
||||
|
||||
return MinutesDetailResponse.builder()
|
||||
.minutesId(minutesDTO.getMinutesId())
|
||||
.title(minutesDTO.getTitle())
|
||||
.memo(minutesDTO.getMemo() != null ? minutesDTO.getMemo() : "")
|
||||
.status(minutesDTO.getStatus())
|
||||
.version(minutesDTO.getVersion())
|
||||
.createdAt(minutesDTO.getCreatedAt())
|
||||
.lastModifiedAt(minutesDTO.getLastModifiedAt())
|
||||
.createdBy(minutesDTO.getCreatedBy())
|
||||
.lastModifiedBy(minutesDTO.getLastModifiedBy())
|
||||
.meeting(meetingInfo)
|
||||
.dashboard(dashboardInfo)
|
||||
.agendas(agendas)
|
||||
.build();
|
||||
}
|
||||
|
||||
// === 헬퍼 메소드들 ===
|
||||
|
||||
private MinutesDetailResponse.MeetingInfo buildDefaultMeetingInfo(MinutesDTO minutesDTO) {
|
||||
return MinutesDetailResponse.MeetingInfo.builder()
|
||||
.meetingId(minutesDTO.getMeetingId())
|
||||
.title(minutesDTO.getMeetingTitle())
|
||||
.location("회의실 정보 없음")
|
||||
.durationMinutes(90)
|
||||
.participants(buildParticipantList(minutesDTO.getMeetingId()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private int calculateActualDuration(Object meeting) {
|
||||
// TODO: 실제 회의 시간 계산 로직 구현
|
||||
// Meeting 객체에서 startedAt, endedAt을 사용하여 계산
|
||||
return 90;
|
||||
}
|
||||
|
||||
private MinutesDetailResponse.AgendaInfo convertToAgendaInfo(Object section) {
|
||||
if (!(section instanceof MinutesSection)) {
|
||||
log.warn("MinutesSection이 아닌 객체가 전달됨: {}", section.getClass().getSimpleName());
|
||||
return createSampleAgenda("변환 실패 안건", 1);
|
||||
}
|
||||
|
||||
MinutesSection minutesSection = (MinutesSection) section;
|
||||
|
||||
// AI 요약 정보 구성 (현재는 기본값 사용)
|
||||
MinutesDetailResponse.AiSummary aiSummary = MinutesDetailResponse.AiSummary.builder()
|
||||
.content(minutesSection.getContent() != null ? minutesSection.getContent() : "AI 요약 정보 없음")
|
||||
.generatedAt(LocalDateTime.now().minusMinutes(30))
|
||||
.modifiedAt(LocalDateTime.now().minusMinutes(10))
|
||||
.build();
|
||||
|
||||
// 안건 상세 내용 구성
|
||||
MinutesDetailResponse.AgendaDetails details = MinutesDetailResponse.AgendaDetails.builder()
|
||||
.discussions(parseDiscussions(minutesSection.getContent()))
|
||||
.decisions(parseDecisions(minutesSection.getContent()))
|
||||
.build();
|
||||
|
||||
return MinutesDetailResponse.AgendaInfo.builder()
|
||||
.agendaId(minutesSection.getSectionId())
|
||||
.title(minutesSection.getTitle() != null ? minutesSection.getTitle() : "제목 없음")
|
||||
.orderIndex(minutesSection.getOrder() != null ? minutesSection.getOrder() : 1)
|
||||
.isVerified(minutesSection.isVerified())
|
||||
.verifiedBy(minutesSection.isVerified() ? "시스템" : null)
|
||||
.verifiedAt(minutesSection.isVerified() ? LocalDateTime.now().minusHours(1) : null)
|
||||
.aiSummary(aiSummary)
|
||||
.details(details)
|
||||
.relatedMinutes(new ArrayList<>()) // 관련 회의록은 별도 로직 필요
|
||||
.build();
|
||||
}
|
||||
|
||||
private MinutesDetailResponse.SimpleTodo convertToSimpleTodo(Object todo) {
|
||||
if (!(todo instanceof Todo)) {
|
||||
log.warn("Todo가 아닌 객체가 전달됨: {}", todo.getClass().getSimpleName());
|
||||
return MinutesDetailResponse.SimpleTodo.builder()
|
||||
.todoId("unknown-todo")
|
||||
.title("변환 실패 Todo")
|
||||
.assigneeName("알 수 없음")
|
||||
.status("PENDING")
|
||||
.priority("LOW")
|
||||
.dueDate(LocalDateTime.now().plusDays(7))
|
||||
.dueDayStatus("D-7")
|
||||
.build();
|
||||
}
|
||||
|
||||
Todo todoEntity = (Todo) todo;
|
||||
|
||||
// 담당자 이름 조회 (현재는 기본값 사용, 실제로는 User 서비스에서 조회 필요)
|
||||
String assigneeName = getAssigneeName(todoEntity.getAssigneeId());
|
||||
|
||||
// 마감일 상태 계산
|
||||
String dueDayStatus = calculateDueDayStatus(todoEntity.getDueDate(), todoEntity.getStatus());
|
||||
|
||||
return MinutesDetailResponse.SimpleTodo.builder()
|
||||
.todoId(todoEntity.getTodoId())
|
||||
.title(todoEntity.getTitle() != null ? todoEntity.getTitle() : "제목 없음")
|
||||
.assigneeName(assigneeName)
|
||||
.status(todoEntity.getStatus() != null ? todoEntity.getStatus() : "PENDING")
|
||||
.priority(todoEntity.getPriority() != null ? todoEntity.getPriority() : "MEDIUM")
|
||||
.dueDate(todoEntity.getDueDate() != null ? todoEntity.getDueDate().atStartOfDay() : null)
|
||||
.dueDayStatus(dueDayStatus)
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<MinutesDetailResponse.KeyPoint> extractKeyPoints(List<MinutesDetailResponse.AgendaInfo> agendas) {
|
||||
// 안건별 AI 요약에서 핵심내용 추출
|
||||
List<MinutesDetailResponse.KeyPoint> keyPoints = new ArrayList<>();
|
||||
for (int i = 0; i < agendas.size() && i < 4; i++) {
|
||||
MinutesDetailResponse.AgendaInfo agenda = agendas.get(i);
|
||||
if (agenda.getAiSummary() != null) {
|
||||
keyPoints.add(MinutesDetailResponse.KeyPoint.builder()
|
||||
.index(i + 1)
|
||||
.content(agenda.getAiSummary().getContent())
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
// 샘플 데이터로 보완
|
||||
if (keyPoints.isEmpty()) {
|
||||
keyPoints = createSampleKeyPoints();
|
||||
}
|
||||
|
||||
return keyPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록 섹션 내용에서 논의사항 추출
|
||||
*/
|
||||
private List<String> parseDiscussions(String content) {
|
||||
if (content == null || content.trim().isEmpty()) {
|
||||
return List.of("논의 내용 없음");
|
||||
}
|
||||
|
||||
// 간단한 패턴으로 논의사항 추출 (실제로는 AI 파싱 필요)
|
||||
Pattern pattern = Pattern.compile("논의[::]\\s*(.+?)(?=결정|\\n|$)", Pattern.CASE_INSENSITIVE);
|
||||
Matcher matcher = pattern.matcher(content);
|
||||
List<String> discussions = new ArrayList<>();
|
||||
|
||||
while (matcher.find()) {
|
||||
discussions.add(matcher.group(1).trim());
|
||||
}
|
||||
|
||||
if (discussions.isEmpty()) {
|
||||
// 전체 내용을 논의사항으로 처리
|
||||
discussions.add(content.length() > 100 ? content.substring(0, 100) + "..." : content);
|
||||
}
|
||||
|
||||
return discussions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록 섹션 내용에서 결정사항 추출
|
||||
*/
|
||||
private List<String> parseDecisions(String content) {
|
||||
if (content == null || content.trim().isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 간단한 패턴으로 결정사항 추출 (실제로는 AI 파싱 필요)
|
||||
Pattern pattern = Pattern.compile("결정[::]\\s*(.+?)(?=논의|\\n|$)", Pattern.CASE_INSENSITIVE);
|
||||
Matcher matcher = pattern.matcher(content);
|
||||
List<String> decisions = new ArrayList<>();
|
||||
|
||||
while (matcher.find()) {
|
||||
decisions.add(matcher.group(1).trim());
|
||||
}
|
||||
|
||||
return decisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 담당자 이름 조회 (실제로는 User 서비스에서 조회 필요)
|
||||
*/
|
||||
private String getAssigneeName(String assigneeId) {
|
||||
if (assigneeId == null) {
|
||||
return "미지정";
|
||||
}
|
||||
|
||||
// TODO: 실제 User 서비스에서 사용자 정보 조회
|
||||
// 현재는 간단한 매핑 사용
|
||||
switch (assigneeId) {
|
||||
case "user1":
|
||||
return "김민준";
|
||||
case "user2":
|
||||
return "박서연";
|
||||
case "user3":
|
||||
return "이준호";
|
||||
default:
|
||||
return "사용자" + assigneeId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마감일 상태 계산
|
||||
*/
|
||||
private String calculateDueDayStatus(LocalDate dueDate, String status) {
|
||||
if (dueDate == null) {
|
||||
return "마감일 없음";
|
||||
}
|
||||
|
||||
if ("COMPLETED".equals(status)) {
|
||||
return "완료";
|
||||
}
|
||||
|
||||
LocalDate today = LocalDate.now();
|
||||
long daysDiff = ChronoUnit.DAYS.between(today, dueDate);
|
||||
|
||||
if (daysDiff < 0) {
|
||||
return "D+" + Math.abs(daysDiff); // 마감일 지남
|
||||
} else if (daysDiff == 0) {
|
||||
return "D-Day";
|
||||
} else {
|
||||
return "D-" + daysDiff;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> extractKeywords(List<MinutesDetailResponse.AgendaInfo> agendas) {
|
||||
// TODO: AI를 통한 키워드 추출 로직 구현
|
||||
return List.of("#AI회의록", "#음성인식", "#협업도구", "#스타트업", "#베타출시");
|
||||
}
|
||||
|
||||
private List<MinutesDetailResponse.Decision> extractDecisions(List<MinutesDetailResponse.AgendaInfo> agendas) {
|
||||
List<MinutesDetailResponse.Decision> decisions = new ArrayList<>();
|
||||
|
||||
for (MinutesDetailResponse.AgendaInfo agenda : agendas) {
|
||||
if (agenda.getDetails() != null && agenda.getDetails().getDecisions() != null) {
|
||||
for (String decision : agenda.getDetails().getDecisions()) {
|
||||
decisions.add(MinutesDetailResponse.Decision.builder()
|
||||
.content(decision)
|
||||
.decidedBy("김민준")
|
||||
.decidedAt(LocalDateTime.now().minusHours(2))
|
||||
.background("안건 논의 결과")
|
||||
.build());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 샘플 데이터로 보완
|
||||
if (decisions.isEmpty()) {
|
||||
decisions = createSampleDecisions();
|
||||
}
|
||||
|
||||
return decisions;
|
||||
}
|
||||
|
||||
// === 샘플 데이터 생성 메소드들 ===
|
||||
|
||||
private List<MinutesDetailResponse.KeyPoint> createSampleKeyPoints() {
|
||||
return List.of(
|
||||
MinutesDetailResponse.KeyPoint.builder()
|
||||
.index(1)
|
||||
.content("AI 기반 회의록 자동화 서비스 출시 결정. 타겟은 중소기업 및 스타트업.")
|
||||
.build(),
|
||||
MinutesDetailResponse.KeyPoint.builder()
|
||||
.index(2)
|
||||
.content("주요 기능: 음성인식, AI 요약, Todo 자동 추출, 실시간 검증 및 협업.")
|
||||
.build(),
|
||||
MinutesDetailResponse.KeyPoint.builder()
|
||||
.index(3)
|
||||
.content("개발 기간 3개월 (Phase 1-3), 베타 출시일 2025년 12월 1일.")
|
||||
.build(),
|
||||
MinutesDetailResponse.KeyPoint.builder()
|
||||
.index(4)
|
||||
.content("프리 런칭 캠페인 11월 진행, 초기 100팀 무료 제공 후 유료 전환.")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
private List<MinutesDetailResponse.Decision> createSampleDecisions() {
|
||||
return List.of(
|
||||
MinutesDetailResponse.Decision.builder()
|
||||
.content("베타 버전 출시일: 2025년 12월 1일")
|
||||
.decidedBy("김민준")
|
||||
.decidedAt(LocalDateTime.now().minusHours(2))
|
||||
.background("개발 일정 및 시장 진입 시기를 고려하여 12월 초 출시가 최적. Q4 마무리 전 베타 피드백 확보 가능.")
|
||||
.build(),
|
||||
MinutesDetailResponse.Decision.builder()
|
||||
.content("타겟 고객: 중소기업 및 스타트업")
|
||||
.decidedBy("박서연")
|
||||
.decidedAt(LocalDateTime.now().minusHours(3))
|
||||
.background("사용자 인터뷰 결과, 중소기업과 스타트업이 회의록 작성에 가장 많은 시간을 소비하며 자동화 니즈가 높음.")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
private MinutesDetailResponse.TodoProgress createSampleTodoProgress() {
|
||||
List<MinutesDetailResponse.SimpleTodo> todos = List.of(
|
||||
MinutesDetailResponse.SimpleTodo.builder()
|
||||
.todoId("todo-1")
|
||||
.title("데이터베이스 스키마 설계")
|
||||
.assigneeName("이준호")
|
||||
.status("IN_PROGRESS")
|
||||
.priority("HIGH")
|
||||
.dueDate(LocalDateTime.now().minusDays(8))
|
||||
.dueDayStatus("D+8")
|
||||
.build(),
|
||||
MinutesDetailResponse.SimpleTodo.builder()
|
||||
.todoId("todo-2")
|
||||
.title("API 명세서 작성")
|
||||
.assigneeName("이준호")
|
||||
.status("IN_PROGRESS")
|
||||
.priority("MEDIUM")
|
||||
.dueDate(LocalDateTime.now().minusDays(5))
|
||||
.dueDayStatus("D+5")
|
||||
.build(),
|
||||
MinutesDetailResponse.SimpleTodo.builder()
|
||||
.todoId("todo-3")
|
||||
.title("예산 편성안 검토")
|
||||
.assigneeName("김민준")
|
||||
.status("COMPLETED")
|
||||
.priority("HIGH")
|
||||
.dueDate(LocalDateTime.now().minusDays(6))
|
||||
.dueDayStatus("완료")
|
||||
.build(),
|
||||
MinutesDetailResponse.SimpleTodo.builder()
|
||||
.todoId("todo-4")
|
||||
.title("UI 프로토타입 디자인")
|
||||
.assigneeName("최유진")
|
||||
.status("IN_PROGRESS")
|
||||
.priority("MEDIUM")
|
||||
.dueDate(LocalDateTime.now())
|
||||
.dueDayStatus("D-Day")
|
||||
.build(),
|
||||
MinutesDetailResponse.SimpleTodo.builder()
|
||||
.todoId("todo-5")
|
||||
.title("사용자 피드백 분석")
|
||||
.assigneeName("김민준")
|
||||
.status("OVERDUE")
|
||||
.priority("LOW")
|
||||
.dueDate(LocalDateTime.now().minusDays(9))
|
||||
.dueDayStatus("D+9")
|
||||
.build()
|
||||
);
|
||||
|
||||
int totalCount = todos.size();
|
||||
int completedCount = (int) todos.stream()
|
||||
.filter(todo -> "COMPLETED".equals(todo.getStatus()))
|
||||
.count();
|
||||
|
||||
return MinutesDetailResponse.TodoProgress.builder()
|
||||
.totalCount(totalCount)
|
||||
.completedCount(completedCount)
|
||||
.progressPercentage(calculateProgressPercentage(totalCount, completedCount))
|
||||
.todos(todos)
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<MinutesDetailResponse.RelatedMinutes> createSampleRelatedMinutes() {
|
||||
return List.of(
|
||||
MinutesDetailResponse.RelatedMinutes.builder()
|
||||
.minutesId("minutes-002")
|
||||
.title("AI 기능 개선 회의")
|
||||
.meetingDate(LocalDateTime.now().minusDays(2))
|
||||
.author("이준호")
|
||||
.relevancePercentage(92)
|
||||
.relevanceLevel("HIGH")
|
||||
.summary("AI 요약 정확도 개선 방안 논의. BERT 모델 도입 및 학습 데이터 확보 계획 수립.")
|
||||
.build(),
|
||||
MinutesDetailResponse.RelatedMinutes.builder()
|
||||
.minutesId("minutes-003")
|
||||
.title("개발 리소스 계획 회의")
|
||||
.meetingDate(LocalDateTime.now().minusDays(3))
|
||||
.author("김민준")
|
||||
.relevancePercentage(88)
|
||||
.relevanceLevel("MEDIUM")
|
||||
.summary("Q4 개발 리소스 현황 및 배분 계획. 신규 프로젝트 우선순위 협의.")
|
||||
.build(),
|
||||
MinutesDetailResponse.RelatedMinutes.builder()
|
||||
.minutesId("minutes-004")
|
||||
.title("경쟁사 분석 회의")
|
||||
.meetingDate(LocalDateTime.now().minusDays(5))
|
||||
.author("박서연")
|
||||
.relevancePercentage(78)
|
||||
.relevanceLevel("MEDIUM")
|
||||
.summary("경쟁사 A, B, C 분석 결과. 우리의 차별점은 실시간 협업 및 검증 기능.")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
private List<MinutesDetailResponse.AgendaInfo> createSampleAgendas() {
|
||||
return List.of(
|
||||
createSampleAgenda("신제품 기획 방향", 1),
|
||||
createSampleAgenda("개발 일정 및 리소스", 2),
|
||||
createSampleAgenda("마케팅 전략", 3)
|
||||
);
|
||||
}
|
||||
|
||||
private MinutesDetailResponse.AgendaInfo createSampleAgenda(String title, int order) {
|
||||
return MinutesDetailResponse.AgendaInfo.builder()
|
||||
.agendaId("agenda-" + order)
|
||||
.title(order + ". " + title)
|
||||
.orderIndex(order)
|
||||
.isVerified(true)
|
||||
.verifiedBy("검증자")
|
||||
.verifiedAt(LocalDateTime.now().minusHours(1))
|
||||
.aiSummary(MinutesDetailResponse.AiSummary.builder()
|
||||
.content(title + "에 대한 AI 요약 내용입니다.")
|
||||
.generatedAt(LocalDateTime.now().minusHours(2))
|
||||
.modifiedAt(LocalDateTime.now().minusHours(1))
|
||||
.build())
|
||||
.details(MinutesDetailResponse.AgendaDetails.builder()
|
||||
.discussions(List.of("논의 사항 1", "논의 사항 2"))
|
||||
.decisions(List.of("결정 사항 1", "결정 사항 2"))
|
||||
.build())
|
||||
.relatedMinutes(createSampleRelatedMinutes().subList(0, 1))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석 결과로 응답 데이터 향상
|
||||
*/
|
||||
private void enhanceWithAiAnalysis(MinutesDetailResponse response, MinutesDTO minutesDTO,
|
||||
String userId, String userName) {
|
||||
try {
|
||||
// 1. 캐시된 AI 분석 결과 조회 시도
|
||||
Optional<AiAnalysisDTO> aiAnalysis = aiServiceGateway.getAiAnalysis(
|
||||
minutesDTO.getMinutesId(),
|
||||
extractContentForAiAnalysis(minutesDTO)
|
||||
);
|
||||
|
||||
if (aiAnalysis.isPresent()) {
|
||||
// AI 분석 결과가 있으면 대시보드 정보 업데이트
|
||||
updateDashboardWithAiAnalysis(response, aiAnalysis.get());
|
||||
log.debug("AI 분석 결과로 대시보드 정보 업데이트 완료 - minutesId: {}",
|
||||
minutesDTO.getMinutesId());
|
||||
} else {
|
||||
// AI 분석 결과가 없으면 비동기 분석 요청 이벤트 발행
|
||||
publishAiAnalysisRequest(minutesDTO, userId, userName);
|
||||
log.debug("AI 분석 요청 이벤트 발행 완료 - minutesId: {}",
|
||||
minutesDTO.getMinutesId());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("AI 분석 처리 중 오류 - minutesId: {}", minutesDTO.getMinutesId(), e);
|
||||
// AI 분석 실패는 응답에 영향주지 않음
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석용 컨텐츠 추출
|
||||
*/
|
||||
private String extractContentForAiAnalysis(MinutesDTO minutesDTO) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
|
||||
// 회의록 제목과 메모
|
||||
content.append("제목: ").append(minutesDTO.getTitle()).append("\n");
|
||||
if (minutesDTO.getMemo() != null && !minutesDTO.getMemo().trim().isEmpty()) {
|
||||
content.append("메모: ").append(minutesDTO.getMemo()).append("\n");
|
||||
}
|
||||
|
||||
// 안건별 내용 추가
|
||||
try {
|
||||
var sections = minutesSectionService.getSectionsByMinutes(minutesDTO.getMinutesId());
|
||||
for (var section : sections) {
|
||||
if (section instanceof MinutesSection) {
|
||||
MinutesSection minutesSection = (MinutesSection) section;
|
||||
content.append("\n안건: ").append(minutesSection.getTitle()).append("\n");
|
||||
if (minutesSection.getContent() != null) {
|
||||
content.append(minutesSection.getContent()).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("안건 내용 추출 실패 - minutesId: {}", minutesDTO.getMinutesId(), e);
|
||||
}
|
||||
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석 결과로 대시보드 정보 업데이트
|
||||
*/
|
||||
private MinutesDetailResponse updateDashboardWithAiAnalysis(MinutesDetailResponse response, AiAnalysisDTO aiAnalysis) {
|
||||
if (response.getDashboard() == null || aiAnalysis.getResult() == null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
AiAnalysisDTO.AnalysisResult result = aiAnalysis.getResult();
|
||||
MinutesDetailResponse.DashboardInfo dashboard = response.getDashboard();
|
||||
|
||||
// 핵심내용 업데이트
|
||||
if (result.getKeyPoints() != null && !result.getKeyPoints().isEmpty()) {
|
||||
List<MinutesDetailResponse.KeyPoint> keyPoints = result.getKeyPoints().stream()
|
||||
.map(kp -> MinutesDetailResponse.KeyPoint.builder()
|
||||
.index(kp.getIndex())
|
||||
.content(kp.getContent())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// DashboardInfo를 새로 빌드 (불변 객체이므로)
|
||||
MinutesDetailResponse.DashboardInfo updatedDashboard = MinutesDetailResponse.DashboardInfo.builder()
|
||||
.keyPoints(keyPoints)
|
||||
.keywords(result.getKeywords() != null ? result.getKeywords() : dashboard.getKeywords())
|
||||
.stats(dashboard.getStats())
|
||||
.decisions(convertAiDecisions(result.getDecisions()))
|
||||
.todoProgress(dashboard.getTodoProgress())
|
||||
.relatedMinutes(convertAiRelatedMinutes(result.getRelatedMinutes()))
|
||||
.build();
|
||||
|
||||
// Response 객체를 새로 빌드 (toBuilder 없이 직접 빌드)
|
||||
MinutesDetailResponse updatedResponse = MinutesDetailResponse.builder()
|
||||
.minutesId(response.getMinutesId())
|
||||
.title(response.getTitle())
|
||||
.memo(response.getMemo())
|
||||
.status(response.getStatus())
|
||||
.version(response.getVersion())
|
||||
.createdAt(response.getCreatedAt())
|
||||
.lastModifiedAt(response.getLastModifiedAt())
|
||||
.createdBy(response.getCreatedBy())
|
||||
.lastModifiedBy(response.getLastModifiedBy())
|
||||
.meeting(response.getMeeting())
|
||||
.dashboard(updatedDashboard) // AI 분석 결과로 업데이트된 대시보드
|
||||
.agendas(response.getAgendas())
|
||||
.build();
|
||||
|
||||
// AI 분석 결과가 적용된 응답 반환
|
||||
return updatedResponse;
|
||||
}
|
||||
|
||||
// AI 분석 결과가 없으면 원본 응답 반환
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 결정사항을 Response 형식으로 변환
|
||||
*/
|
||||
private List<MinutesDetailResponse.Decision> convertAiDecisions(List<AiAnalysisDTO.Decision> aiDecisions) {
|
||||
if (aiDecisions == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return aiDecisions.stream()
|
||||
.map(decision -> MinutesDetailResponse.Decision.builder()
|
||||
.content(decision.getContent())
|
||||
.decidedBy("AI 분석")
|
||||
.decidedAt(LocalDateTime.now())
|
||||
.background("AI가 회의록에서 추출한 결정사항")
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 관련회의록을 Response 형식으로 변환
|
||||
*/
|
||||
private List<MinutesDetailResponse.RelatedMinutes> convertAiRelatedMinutes(List<AiAnalysisDTO.RelatedMinutes> aiRelated) {
|
||||
if (aiRelated == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return aiRelated.stream()
|
||||
.map(related -> MinutesDetailResponse.RelatedMinutes.builder()
|
||||
.minutesId(related.getMinutesId())
|
||||
.title(related.getTitle())
|
||||
.meetingDate(related.getMeetingDate())
|
||||
.author("시스템")
|
||||
.relevancePercentage((int)(related.getRelevanceScore() * 100))
|
||||
.relevanceLevel(related.getRelevanceScore() > 0.8 ? "HIGH" :
|
||||
related.getRelevanceScore() > 0.5 ? "MEDIUM" : "LOW")
|
||||
.summary(related.getReason())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Response 객체 필드 복사 (불변 객체 업데이트용)
|
||||
*/
|
||||
|
||||
/**
|
||||
* AI 분석 요청 이벤트 발행
|
||||
*/
|
||||
private void publishAiAnalysisRequest(MinutesDTO minutesDTO, String requesterId, String requesterName) {
|
||||
try {
|
||||
// 회의 메타정보 구성
|
||||
MinutesAnalysisRequestEvent.MeetingMeta meetingMeta = MinutesAnalysisRequestEvent.MeetingMeta.builder()
|
||||
.title(minutesDTO.getMeetingTitle())
|
||||
.meetingDate(minutesDTO.getCreatedAt())
|
||||
.participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 1)
|
||||
.durationMinutes(90) // 기본값
|
||||
.organizerId(minutesDTO.getCreatedBy())
|
||||
.participantIds(new String[]{requesterId}) // 기본값
|
||||
.build();
|
||||
|
||||
// AI 분석 요청 이벤트 생성
|
||||
MinutesAnalysisRequestEvent requestEvent = MinutesAnalysisRequestEvent.create(
|
||||
minutesDTO.getMinutesId(),
|
||||
minutesDTO.getMeetingId(),
|
||||
requesterId,
|
||||
requesterName,
|
||||
extractContentForAiAnalysis(minutesDTO),
|
||||
meetingMeta
|
||||
);
|
||||
|
||||
// 이벤트 발행
|
||||
eventPublisher.publishMinutesAnalysisRequest(requestEvent);
|
||||
|
||||
log.info("AI 분석 요청 이벤트 발행 완료 - minutesId: {}, eventId: {}",
|
||||
minutesDTO.getMinutesId(), requestEvent.getEventId());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 분석 요청 이벤트 발행 실패 - minutesId: {}", minutesDTO.getMinutesId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 회의 시간 계산
|
||||
*/
|
||||
private int calculateActualMeetingDuration(String meetingId) {
|
||||
try {
|
||||
var meeting = meetingService.getMeeting(meetingId);
|
||||
if (meeting.getStartedAt() != null && meeting.getEndedAt() != null) {
|
||||
long minutes = Duration.between(meeting.getStartedAt(), meeting.getEndedAt()).toMinutes();
|
||||
return (int) Math.max(minutes, 0);
|
||||
}
|
||||
// 시작/종료 시간이 없으면 기본값 반환
|
||||
return 90; // 기본 90분
|
||||
} catch (Exception e) {
|
||||
log.warn("회의 시간 계산 실패 - meetingId: {}", meetingId, e);
|
||||
return 90;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 기반 관련회의록 조회
|
||||
*/
|
||||
private List<MinutesDetailResponse.RelatedMinutes> getRelatedMinutesFromAI(String minutesId) {
|
||||
try {
|
||||
// 캐시된 AI 분석 결과에서 관련회의록 조회
|
||||
Optional<AiAnalysisDTO> aiAnalysis = cacheService.getAiAnalysis(minutesId);
|
||||
if (aiAnalysis.isPresent() && aiAnalysis.get().getResult() != null) {
|
||||
return convertAiRelatedMinutes(aiAnalysis.get().getResult().getRelatedMinutes());
|
||||
}
|
||||
|
||||
// AI 분석 결과가 없으면 빈 목록 반환
|
||||
return new ArrayList<>();
|
||||
} catch (Exception e) {
|
||||
log.warn("AI 관련회의록 조회 실패 - minutesId: {}", minutesId, e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 회의 시간 계산 (Meeting 객체 사용)
|
||||
*/
|
||||
private int calculateActualDuration(Meeting meeting) {
|
||||
try {
|
||||
if (meeting.getStartedAt() != null && meeting.getEndedAt() != null) {
|
||||
long minutes = Duration.between(meeting.getStartedAt(), meeting.getEndedAt()).toMinutes();
|
||||
return (int) Math.max(minutes, 0);
|
||||
}
|
||||
// 시작/종료 시간이 없으면 예정 시간으로 추정
|
||||
return 90; // 기본 90분
|
||||
} catch (Exception e) {
|
||||
log.warn("회의 시간 계산 실패", e);
|
||||
return 90;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ import java.util.List;
|
||||
* 회의록 상세 조회 응답 DTO (프로토타입 기반 - 대시보드/회의록 탭 구조)
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@Builder(toBuilder = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MinutesDetailResponse {
|
||||
|
||||
+2
@@ -13,12 +13,14 @@ public class EventHubConstants {
|
||||
public static final String EVENT_TYPE_TODO_COMPLETED = "TODO_COMPLETED";
|
||||
public static final String EVENT_TYPE_MINUTES_FINALIZED = "MINUTES_FINALIZED";
|
||||
public static final String EVENT_TYPE_NOTIFICATION_REQUEST = "NOTIFICATION_REQUEST";
|
||||
public static final String EVENT_TYPE_MINUTES_ANALYSIS_REQUEST = "MINUTES_ANALYSIS_REQUEST";
|
||||
|
||||
// 토픽 이름 상수
|
||||
public static final String TOPIC_MEETING = "meeting";
|
||||
public static final String TOPIC_TODO = "todo";
|
||||
public static final String TOPIC_MINUTES = "minutes";
|
||||
public static final String TOPIC_NOTIFICATION = "notification";
|
||||
public static final String TOPIC_AI_ANALYSIS = "ai-analysis";
|
||||
|
||||
// 속성 키 상수
|
||||
public static final String PROPERTY_TYPE = "type";
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
package com.unicorn.hgzero.meeting.infra.event.consumer;
|
||||
|
||||
import com.azure.messaging.eventhubs.*;
|
||||
import com.azure.messaging.eventhubs.checkpointstore.blob.BlobCheckpointStore;
|
||||
import com.azure.messaging.eventhubs.models.EventContext;
|
||||
import com.azure.storage.blob.BlobContainerAsyncClient;
|
||||
import com.azure.storage.blob.BlobContainerClientBuilder;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unicorn.hgzero.meeting.biz.dto.AiAnalysisDTO;
|
||||
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesAnalysisCompletedEvent;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 회의록 AI 분석 완료 이벤트 소비자
|
||||
* Azure EventHub 사용
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@ConditionalOnProperty(name = "eventhub.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class MinutesAnalysisEventConsumer {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final CacheService cacheService;
|
||||
private EventProcessorClient processorClient;
|
||||
|
||||
@Value("${eventhub.connection-string}")
|
||||
private String connectionString;
|
||||
|
||||
@Value("${eventhub.name}")
|
||||
private String eventHubName;
|
||||
|
||||
@Value("${eventhub.consumer-group:$Default}")
|
||||
private String consumerGroup;
|
||||
|
||||
@Value("${azure.storage.connection-string:}")
|
||||
private String storageConnectionString;
|
||||
|
||||
@Value("${azure.storage.container-name:checkpoint}")
|
||||
private String checkpointContainerName;
|
||||
|
||||
@PostConstruct
|
||||
public void initialize() {
|
||||
try {
|
||||
log.info("AI 분석 이벤트 소비자 초기화 시작 - eventHub: {}, consumerGroup: {}",
|
||||
eventHubName, consumerGroup);
|
||||
|
||||
// Checkpoint Store 설정 (선택사항)
|
||||
BlobCheckpointStore checkpointStore = null;
|
||||
if (!storageConnectionString.isEmpty()) {
|
||||
BlobContainerAsyncClient containerClient = new BlobContainerClientBuilder()
|
||||
.connectionString(storageConnectionString)
|
||||
.containerName(checkpointContainerName)
|
||||
.buildAsyncClient();
|
||||
checkpointStore = new BlobCheckpointStore(containerClient);
|
||||
}
|
||||
|
||||
// EventProcessor 클라이언트 생성
|
||||
EventProcessorClientBuilder builder = new EventProcessorClientBuilder()
|
||||
.connectionString(connectionString, eventHubName)
|
||||
.consumerGroup(consumerGroup)
|
||||
.processEvent(this::processAnalysisCompletedEvent)
|
||||
.processError(this::processError);
|
||||
|
||||
if (checkpointStore != null) {
|
||||
builder.checkpointStore(checkpointStore);
|
||||
}
|
||||
|
||||
processorClient = builder.buildEventProcessorClient();
|
||||
processorClient.start();
|
||||
|
||||
log.info("AI 분석 이벤트 소비자 시작 완료");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 분석 이벤트 소비자 초기화 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석 완료 이벤트 처리
|
||||
*/
|
||||
private void processAnalysisCompletedEvent(EventContext context) {
|
||||
EventData eventData = context.getEventData();
|
||||
|
||||
try {
|
||||
String messageBody = eventData.getBodyAsString();
|
||||
log.debug("AI 분석 완료 이벤트 수신 - sequenceNumber: {}", eventData.getSequenceNumber());
|
||||
|
||||
// 메시지를 이벤트 객체로 변환
|
||||
MinutesAnalysisCompletedEvent event = objectMapper.readValue(messageBody, MinutesAnalysisCompletedEvent.class);
|
||||
|
||||
// 이벤트 타입 확인
|
||||
if (!"MINUTES_ANALYSIS_COMPLETED".equals(event.getEventType())) {
|
||||
log.debug("처리 대상이 아닌 이벤트 타입 - eventType: {}", event.getEventType());
|
||||
context.updateCheckpoint();
|
||||
return;
|
||||
}
|
||||
|
||||
// 분석 완료 이벤트 처리
|
||||
handleAnalysisCompleted(event);
|
||||
|
||||
// 체크포인트 업데이트
|
||||
context.updateCheckpoint();
|
||||
log.debug("AI 분석 완료 이벤트 처리 완료 - minutesId: {}, sequenceNumber: {}",
|
||||
event.getMinutesId(), eventData.getSequenceNumber());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 분석 완료 이벤트 처리 실패 - sequenceNumber: {}", eventData.getSequenceNumber(), e);
|
||||
// EventHub에서는 처리 실패해도 체크포인트를 업데이트하지 않음으로써 재처리 가능
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석 완료 이벤트 처리 로직
|
||||
*/
|
||||
private void handleAnalysisCompleted(MinutesAnalysisCompletedEvent event) {
|
||||
String minutesId = event.getMinutesId();
|
||||
|
||||
try {
|
||||
if ("COMPLETED".equals(event.getStatus()) && event.getResult() != null) {
|
||||
// 분석 성공 시 캐시에 결과 저장
|
||||
AiAnalysisDTO analysisDTO = convertToAnalysisDTO(event);
|
||||
cacheService.cacheAiAnalysis(minutesId, analysisDTO);
|
||||
|
||||
log.info("AI 분석 결과 캐시 저장 완료 - minutesId: {}, analysisId: {}",
|
||||
minutesId, event.getAnalysisId());
|
||||
|
||||
} else if ("FAILED".equals(event.getStatus()) && event.getFailure() != null) {
|
||||
// 분석 실패 시 실패 정보 로깅
|
||||
MinutesAnalysisCompletedEvent.FailureInfo failure = event.getFailure();
|
||||
log.warn("AI 분석 실패 - minutesId: {}, errorCode: {}, errorMessage: {}, retryable: {}",
|
||||
minutesId, failure.getErrorCode(), failure.getErrorMessage(), failure.isRetryable());
|
||||
|
||||
// 재시도 가능한 실패인 경우 나중에 재처리 로직 추가 가능
|
||||
if (failure.isRetryable()) {
|
||||
log.info("재시도 가능한 분석 실패 - minutesId: {}", minutesId);
|
||||
// TODO: 재시도 로직 구현
|
||||
}
|
||||
}
|
||||
|
||||
// 회의록 상세 조회 캐시 무효화 (새로운 AI 분석 결과 반영을 위해)
|
||||
cacheService.evictCacheMinutesDetail(minutesId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 분석 완료 이벤트 처리 중 오류 - minutesId: {}", minutesId, e);
|
||||
throw e; // 상위로 예외 전파하여 메시지 재처리
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트를 DTO로 변환
|
||||
*/
|
||||
private AiAnalysisDTO convertToAnalysisDTO(MinutesAnalysisCompletedEvent event) {
|
||||
MinutesAnalysisCompletedEvent.AnalysisResult result = event.getResult();
|
||||
|
||||
// 핵심내용 변환
|
||||
List<AiAnalysisDTO.KeyPoint> keyPoints = new ArrayList<>();
|
||||
String[] keyPointsArray = result.getKeyPoints();
|
||||
for (int i = 0; i < keyPointsArray.length; i++) {
|
||||
keyPoints.add(AiAnalysisDTO.KeyPoint.builder()
|
||||
.index(i + 1)
|
||||
.content(keyPointsArray[i])
|
||||
.confidence(0.85) // 기본 신뢰도
|
||||
.category("DISCUSSION")
|
||||
.build());
|
||||
}
|
||||
|
||||
// 결정사항 변환
|
||||
List<AiAnalysisDTO.Decision> decisions = Arrays.stream(result.getDecisions())
|
||||
.map(content -> AiAnalysisDTO.Decision.builder()
|
||||
.content(content)
|
||||
.category("STRATEGIC")
|
||||
.confidence(0.80)
|
||||
.extractedFrom(content)
|
||||
.build())
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
// 관련회의록 변환 (현재는 ID만 있으므로 기본값 사용)
|
||||
List<AiAnalysisDTO.RelatedMinutes> relatedMinutes = Arrays.stream(result.getRelatedMinutesIds())
|
||||
.map(minutesId -> AiAnalysisDTO.RelatedMinutes.builder()
|
||||
.minutesId(minutesId)
|
||||
.title("관련 회의록") // 실제로는 별도 조회 필요
|
||||
.relevanceScore(0.75)
|
||||
.reason("키워드 및 주제 유사성")
|
||||
.meetingDate(LocalDateTime.now().minusDays(7)) // 기본값
|
||||
.build())
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
// 분석 결과 구성
|
||||
AiAnalysisDTO.AnalysisResult analysisResult = AiAnalysisDTO.AnalysisResult.builder()
|
||||
.keyPoints(keyPoints)
|
||||
.keywords(Arrays.asList(result.getKeywords()))
|
||||
.summary(result.getSummary())
|
||||
.decisions(decisions)
|
||||
.relatedMinutes(relatedMinutes)
|
||||
.qualityScore(result.getQualityScore())
|
||||
.build();
|
||||
|
||||
return AiAnalysisDTO.builder()
|
||||
.minutesId(event.getMinutesId())
|
||||
.analysisId(event.getAnalysisId())
|
||||
.status("COMPLETED")
|
||||
.requestedAt(LocalDateTime.now().minusMinutes(10)) // 추정값
|
||||
.completedAt(event.getCompletedAt())
|
||||
.result(analysisResult)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리
|
||||
*/
|
||||
private void processError(com.azure.messaging.eventhubs.models.ErrorContext context) {
|
||||
log.error("AI 분석 이벤트 소비 중 오류 발생 - partitionContext: {}, throwable: {}",
|
||||
context.getPartitionContext().getPartitionId(),
|
||||
context.getThrowable().getMessage(),
|
||||
context.getThrowable());
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void cleanup() {
|
||||
if (processorClient != null) {
|
||||
try {
|
||||
processorClient.stop();
|
||||
log.info("AI 분석 이벤트 소비자 종료 완료");
|
||||
} catch (Exception e) {
|
||||
log.error("AI 분석 이벤트 소비자 종료 중 오류", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.unicorn.hgzero.meeting.infra.event.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 회의록 AI 분석 완료 이벤트
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MinutesAnalysisCompletedEvent {
|
||||
|
||||
private String eventType; // MINUTES_ANALYSIS_COMPLETED
|
||||
private String eventId; // 이벤트 고유 ID
|
||||
private String minutesId; // 회의록 ID
|
||||
private String analysisId; // 분석 결과 ID
|
||||
private String status; // COMPLETED, FAILED
|
||||
private String requesterId; // 요청자 ID
|
||||
private LocalDateTime completedAt; // 완료 시간
|
||||
private LocalDateTime timestamp; // 이벤트 발생 시간
|
||||
|
||||
// 분석 결과 (성공 시)
|
||||
private AnalysisResult result;
|
||||
|
||||
// 실패 정보 (실패 시)
|
||||
private FailureInfo failure;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class AnalysisResult {
|
||||
private String[] keyPoints; // 핵심내용
|
||||
private String[] keywords; // 키워드
|
||||
private String summary; // 요약
|
||||
private String[] decisions; // 결정사항
|
||||
private String[] relatedMinutesIds; // 관련회의록 ID
|
||||
private int qualityScore; // 분석 품질 점수 (0-100)
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class FailureInfo {
|
||||
private String errorCode;
|
||||
private String errorMessage;
|
||||
private String cause;
|
||||
private boolean retryable;
|
||||
}
|
||||
|
||||
public static MinutesAnalysisCompletedEvent createSuccess(String minutesId, String analysisId,
|
||||
String requesterId, AnalysisResult result) {
|
||||
return MinutesAnalysisCompletedEvent.builder()
|
||||
.eventType("MINUTES_ANALYSIS_COMPLETED")
|
||||
.eventId("analysis-completed-" + minutesId + "-" + System.currentTimeMillis())
|
||||
.minutesId(minutesId)
|
||||
.analysisId(analysisId)
|
||||
.status("COMPLETED")
|
||||
.requesterId(requesterId)
|
||||
.completedAt(LocalDateTime.now())
|
||||
.timestamp(LocalDateTime.now())
|
||||
.result(result)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static MinutesAnalysisCompletedEvent createFailure(String minutesId, String analysisId,
|
||||
String requesterId, FailureInfo failure) {
|
||||
return MinutesAnalysisCompletedEvent.builder()
|
||||
.eventType("MINUTES_ANALYSIS_COMPLETED")
|
||||
.eventId("analysis-failed-" + minutesId + "-" + System.currentTimeMillis())
|
||||
.minutesId(minutesId)
|
||||
.analysisId(analysisId)
|
||||
.status("FAILED")
|
||||
.requesterId(requesterId)
|
||||
.completedAt(LocalDateTime.now())
|
||||
.timestamp(LocalDateTime.now())
|
||||
.failure(failure)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.unicorn.hgzero.meeting.infra.event.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 회의록 AI 분석 요청 이벤트
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MinutesAnalysisRequestEvent {
|
||||
|
||||
private String eventType; // MINUTES_ANALYSIS_REQUEST
|
||||
private String eventId; // 이벤트 고유 ID
|
||||
private String minutesId; // 회의록 ID
|
||||
private String meetingId; // 회의 ID
|
||||
private String requesterId; // 요청자 ID
|
||||
private String requesterName; // 요청자 이름
|
||||
private String content; // 분석할 회의록 내용
|
||||
private String[] features; // 분석 기능 목록 (KEY_POINTS, KEYWORDS, DECISIONS, etc.)
|
||||
private String priority; // URGENT, HIGH, NORMAL, LOW
|
||||
private LocalDateTime requestedAt; // 요청 시간
|
||||
private LocalDateTime timestamp; // 이벤트 발생 시간
|
||||
|
||||
// 회의 메타정보
|
||||
private MeetingMeta meetingMeta;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class MeetingMeta {
|
||||
private String title;
|
||||
private LocalDateTime meetingDate;
|
||||
private int participantCount;
|
||||
private int durationMinutes;
|
||||
private String organizerId;
|
||||
private String[] participantIds;
|
||||
}
|
||||
|
||||
public static MinutesAnalysisRequestEvent create(String minutesId, String meetingId,
|
||||
String requesterId, String requesterName,
|
||||
String content, MeetingMeta meetingMeta) {
|
||||
return MinutesAnalysisRequestEvent.builder()
|
||||
.eventType("MINUTES_ANALYSIS_REQUEST")
|
||||
.eventId("analysis-" + minutesId + "-" + System.currentTimeMillis())
|
||||
.minutesId(minutesId)
|
||||
.meetingId(meetingId)
|
||||
.requesterId(requesterId)
|
||||
.requesterName(requesterName)
|
||||
.content(content)
|
||||
.features(new String[]{"KEY_POINTS", "KEYWORDS", "DECISIONS", "SUMMARY", "RELATED_MINUTES"})
|
||||
.priority("NORMAL")
|
||||
.requestedAt(LocalDateTime.now())
|
||||
.timestamp(LocalDateTime.now())
|
||||
.meetingMeta(meetingMeta)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+8
@@ -10,6 +10,7 @@ import com.unicorn.hgzero.meeting.infra.event.dto.MeetingStartedEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingEndedEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.TodoAssignedEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesAnalysisRequestEvent;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -149,6 +150,13 @@ public class EventHubPublisher implements EventPublisher {
|
||||
meetingId, participants.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publishMinutesAnalysisRequest(MinutesAnalysisRequestEvent event) {
|
||||
publishEvent(event, event.getMinutesId(),
|
||||
EventHubConstants.TOPIC_AI_ANALYSIS,
|
||||
EventHubConstants.EVENT_TYPE_MINUTES_ANALYSIS_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 발행 공통 메서드
|
||||
*
|
||||
|
||||
+8
@@ -4,6 +4,7 @@ import com.unicorn.hgzero.meeting.infra.event.dto.MeetingStartedEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingEndedEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.TodoAssignedEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesAnalysisRequestEvent;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -75,4 +76,11 @@ public interface EventPublisher {
|
||||
*/
|
||||
void publishMeetingCreated(String meetingId, String title, LocalDateTime startTime,
|
||||
String location, List<String> participants, String organizerId, String organizerName);
|
||||
|
||||
/**
|
||||
* 회의록 AI 분석 요청 이벤트 발행
|
||||
*
|
||||
* @param event AI 분석 요청 이벤트
|
||||
*/
|
||||
void publishMinutesAnalysisRequest(MinutesAnalysisRequestEvent event);
|
||||
}
|
||||
+6
@@ -4,6 +4,7 @@ import com.unicorn.hgzero.meeting.infra.event.dto.MeetingStartedEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingEndedEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.TodoAssignedEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent;
|
||||
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesAnalysisRequestEvent;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
@@ -66,4 +67,9 @@ public class NoOpEventPublisher implements EventPublisher {
|
||||
log.debug("[NoOp] Meeting created: meetingId={}, title={}, participants={}",
|
||||
meetingId, title, participants.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publishMinutesAnalysisRequest(MinutesAnalysisRequestEvent event) {
|
||||
log.debug("[NoOp] Minutes analysis request event: minutesId={}", event.getMinutesId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.unicorn.hgzero.meeting.infra.gateway;
|
||||
|
||||
import com.unicorn.hgzero.meeting.biz.dto.AiAnalysisDTO;
|
||||
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* AI 서비스 연동 Gateway
|
||||
* Redis 캐시 우선 방식으로 AI 분석 결과 조회
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiServiceGateway {
|
||||
|
||||
private final CacheService cacheService;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Value("${ai.service.base-url:http://ai:8080}")
|
||||
private String aiServiceBaseUrl;
|
||||
|
||||
@Value("${ai.service.timeout-ms:5000}")
|
||||
private int timeoutMs;
|
||||
|
||||
/**
|
||||
* 회의록 AI 분석 요청 및 결과 조회 (캐시 우선)
|
||||
*
|
||||
* @param minutesId 회의록 ID
|
||||
* @param content 분석할 회의록 내용
|
||||
* @return AI 분석 결과 DTO
|
||||
*/
|
||||
public Optional<AiAnalysisDTO> getAiAnalysis(String minutesId, String content) {
|
||||
try {
|
||||
// 1. Redis 캐시에서 먼저 조회
|
||||
Optional<AiAnalysisDTO> cachedResult = getCachedAiAnalysis(minutesId);
|
||||
if (cachedResult.isPresent()) {
|
||||
log.debug("AI 분석 결과 캐시 히트 - minutesId: {}", minutesId);
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
// 2. 캐시 미스 시 AI 서비스 직접 호출
|
||||
log.debug("AI 분석 결과 캐시 미스, AI 서비스 호출 - minutesId: {}", minutesId);
|
||||
Optional<AiAnalysisDTO> analysisResult = requestAiAnalysis(minutesId, content);
|
||||
|
||||
// 3. 분석 결과가 있으면 캐시에 저장 (TTL: 1시간)
|
||||
if (analysisResult.isPresent()) {
|
||||
cacheAiAnalysis(minutesId, analysisResult.get());
|
||||
}
|
||||
|
||||
return analysisResult;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 분석 요청 실패 - minutesId: {}", minutesId, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 캐시에서 AI 분석 결과 조회
|
||||
*/
|
||||
private Optional<AiAnalysisDTO> getCachedAiAnalysis(String minutesId) {
|
||||
try {
|
||||
return cacheService.getAiAnalysis(minutesId);
|
||||
} catch (Exception e) {
|
||||
log.warn("AI 분석 캐시 조회 실패 - minutesId: {}", minutesId, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 서비스에 직접 분석 요청
|
||||
*/
|
||||
private Optional<AiAnalysisDTO> requestAiAnalysis(String minutesId, String content) {
|
||||
try {
|
||||
// HTTP 헤더 설정
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.add("X-Request-Source", "meeting-service");
|
||||
headers.add("X-Minutes-Id", minutesId);
|
||||
|
||||
// 요청 바디 구성
|
||||
Map<String, Object> requestBody = Map.of(
|
||||
"minutesId", minutesId,
|
||||
"content", content,
|
||||
"analysisType", "COMPREHENSIVE", // 종합 분석
|
||||
"features", new String[]{
|
||||
"KEY_POINTS", // 핵심내용 추출
|
||||
"KEYWORDS", // 키워드 추출
|
||||
"DECISIONS", // 결정사항 추출
|
||||
"SUMMARY", // 요약
|
||||
"RELATED_MINUTES" // 관련회의록 추천
|
||||
}
|
||||
);
|
||||
|
||||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
|
||||
|
||||
// AI 서비스 호출
|
||||
String aiAnalysisUrl = aiServiceBaseUrl + "/api/v1/analysis/minutes";
|
||||
ResponseEntity<AiAnalysisDTO> response = restTemplate.exchange(
|
||||
aiAnalysisUrl,
|
||||
HttpMethod.POST,
|
||||
request,
|
||||
AiAnalysisDTO.class
|
||||
);
|
||||
|
||||
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
|
||||
log.info("AI 분석 완료 - minutesId: {}", minutesId);
|
||||
return Optional.of(response.getBody());
|
||||
} else {
|
||||
log.warn("AI 서비스 응답 비정상 - minutesId: {}, status: {}",
|
||||
minutesId, response.getStatusCode());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 서비스 호출 실패 - minutesId: {}, error: {}", minutesId, e.getMessage(), e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석 결과를 Redis 캐시에 저장
|
||||
*/
|
||||
private void cacheAiAnalysis(String minutesId, AiAnalysisDTO analysisResult) {
|
||||
try {
|
||||
cacheService.cacheAiAnalysis(minutesId, analysisResult);
|
||||
log.debug("AI 분석 결과 캐시 저장 완료 - minutesId: {}", minutesId);
|
||||
} catch (Exception e) {
|
||||
log.warn("AI 분석 결과 캐시 저장 실패 - minutesId: {}", minutesId, e);
|
||||
// 캐시 저장 실패는 비즈니스에 영향주지 않으므로 로그만 남김
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석 상태 확인
|
||||
*
|
||||
* @param minutesId 회의록 ID
|
||||
* @return 분석 상태 (PENDING, IN_PROGRESS, COMPLETED, FAILED)
|
||||
*/
|
||||
public String getAnalysisStatus(String minutesId) {
|
||||
try {
|
||||
String statusUrl = aiServiceBaseUrl + "/api/v1/analysis/status/" + minutesId;
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("X-Request-Source", "meeting-service");
|
||||
HttpEntity<Void> request = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<Map> response = restTemplate.exchange(
|
||||
statusUrl,
|
||||
HttpMethod.GET,
|
||||
request,
|
||||
Map.class
|
||||
);
|
||||
|
||||
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
|
||||
return (String) response.getBody().get("status");
|
||||
}
|
||||
|
||||
return "UNKNOWN";
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("AI 분석 상태 조회 실패 - minutesId: {}", minutesId, e);
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석 캐시 무효화
|
||||
*/
|
||||
public void evictAiAnalysisCache(String minutesId) {
|
||||
try {
|
||||
cacheService.evictAiAnalysisCache(minutesId);
|
||||
log.debug("AI 분석 캐시 무효화 완료 - minutesId: {}", minutesId);
|
||||
} catch (Exception e) {
|
||||
log.warn("AI 분석 캐시 무효화 실패 - minutesId: {}", minutesId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user