mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-12 22:59:10 +00:00
Feat: AI 서비스 통합 및 회의록 기능 개선
- AI 서비스와 Meeting 서비스 통합 개선 - AgendaSummaryDTO에 decisions 필드 추가 (안건별 결정사항 배열) - EndMeetingService에서 AI 서비스 타임아웃 처리 개선 - AIServiceClient에 상세한 에러 로깅 추가 - 회의록 consolidate 프롬프트 개선 - Todo 추출 로직 강화 (자연스러운 표현 인식) - 안건별 decisions 필드 추가 (대시보드 표시용) - 담당자 패턴 인식 개선 - Kubernetes 배포 설정 개선 - meeting-service.yaml에 AI_SERVICE_URL 환경변수 추가 - AI_SERVICE_TIMEOUT 설정 추가 - 데이터베이스 관리 SQL 스크립트 추가 - check-agenda-sections.sql: 안건 섹션 확인 - cleanup-test-data.sql: 테스트 데이터 정리 - insert-test-data-final.sql: 최종 테스트 데이터 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+114
-37
@@ -13,11 +13,14 @@ import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingEntity;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesEntity;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesSectionEntity;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.TodoEntity;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.AgendaSectionJpaRepository;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingAnalysisJpaRepository;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingJpaRepository;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesJpaRepository;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesSectionJpaRepository;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.TodoJpaRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
@@ -45,7 +48,9 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
private final MinutesSectionJpaRepository minutesSectionRepository;
|
||||
private final TodoJpaRepository todoRepository;
|
||||
private final MeetingAnalysisJpaRepository analysisRepository;
|
||||
private final AgendaSectionJpaRepository agendaSectionRepository;
|
||||
private final AIServiceClient aiServiceClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 회의 종료 및 AI 분석 실행
|
||||
@@ -58,44 +63,73 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
public MeetingEndDTO endMeeting(String meetingId) {
|
||||
log.info("회의 종료 시작 - meetingId: {}", meetingId);
|
||||
|
||||
// 1. 회의 정보 조회
|
||||
MeetingEntity meeting = meetingRepository.findById(meetingId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("회의를 찾을 수 없습니다: " + meetingId));
|
||||
try {
|
||||
// 1. 회의 정보 조회
|
||||
MeetingEntity meeting = meetingRepository.findById(meetingId)
|
||||
.orElseThrow(() -> {
|
||||
log.error("회의를 찾을 수 없음 - meetingId: {}", meetingId);
|
||||
return new IllegalArgumentException("회의를 찾을 수 없습니다: " + meetingId);
|
||||
});
|
||||
|
||||
// 2. 참석자별 회의록 조회 (userId가 있는 회의록들)
|
||||
List<MinutesEntity> participantMinutesList = minutesRepository.findByMeetingIdAndUserIdIsNotNull(meetingId);
|
||||
log.info("회의 정보 조회 완료 - title: {}, status: {}", meeting.getTitle(), meeting.getStatus());
|
||||
|
||||
if (participantMinutesList.isEmpty()) {
|
||||
throw new IllegalStateException("참석자 회의록이 없습니다: " + meetingId);
|
||||
// 2. 참석자별 회의록 조회 (userId가 있는 회의록들)
|
||||
List<MinutesEntity> participantMinutesList = minutesRepository.findByMeetingIdAndUserIdIsNotNull(meetingId);
|
||||
|
||||
if (participantMinutesList.isEmpty()) {
|
||||
log.error("참석자 회의록이 없음 - meetingId: {}", meetingId);
|
||||
throw new IllegalStateException("참석자 회의록이 없습니다: " + meetingId);
|
||||
}
|
||||
|
||||
log.info("참석자 회의록 조회 완료 - 참석자 수: {}", participantMinutesList.size());
|
||||
|
||||
// 3. 각 회의록의 sections 조회 및 통합
|
||||
List<MinutesSectionEntity> allMinutesSections = new ArrayList<>();
|
||||
for (MinutesEntity minutes : participantMinutesList) {
|
||||
List<MinutesSectionEntity> sections = minutesSectionRepository.findByMinutesIdOrderByOrderAsc(
|
||||
minutes.getMinutesId()
|
||||
);
|
||||
allMinutesSections.addAll(sections);
|
||||
log.debug("회의록 섹션 조회 - minutesId: {}, userId: {}, 섹션 수: {}",
|
||||
minutes.getMinutesId(), minutes.getUserId(), sections.size());
|
||||
}
|
||||
|
||||
log.info("전체 회의록 섹션 조회 완료 - 총 섹션 수: {}", allMinutesSections.size());
|
||||
|
||||
// 4. AI 통합 분석 요청 데이터 생성
|
||||
ConsolidateRequest request = createConsolidateRequest(meeting, allMinutesSections, participantMinutesList);
|
||||
log.info("AI 통합 분석 요청 데이터 생성 완료 - participantMinutes 수: {}",
|
||||
request.getParticipantMinutes().size());
|
||||
|
||||
// 5. AI Service 호출
|
||||
log.info("AI Service 호출 시작...");
|
||||
ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request);
|
||||
log.info("AI Service 호출 완료 - 안건 수: {}", aiResponse.getAgendaSummaries().size());
|
||||
|
||||
// 6. AI 분석 결과 저장
|
||||
MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse);
|
||||
|
||||
// 7. Todo 생성 및 저장
|
||||
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
|
||||
|
||||
// 8. 회의 종료 처리
|
||||
meeting.end();
|
||||
meetingRepository.save(meeting);
|
||||
log.info("회의 상태 업데이트 완료 - status: {}", meeting.getStatus());
|
||||
|
||||
// 9. 응답 DTO 생성
|
||||
MeetingEndDTO result = createMeetingEndDTO(meeting, analysis, todos, participantMinutesList.size());
|
||||
|
||||
log.info("회의 종료 처리 완료 - meetingId: {}, 안건 수: {}, Todo 수: {}",
|
||||
meetingId, analysis.getAgendaAnalyses().size(), todos.size());
|
||||
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("회의 종료 처리 중 오류 발생 - meetingId: {}, 에러: {}",
|
||||
meetingId, e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 3. 각 회의록의 sections 조회 및 통합
|
||||
List<MinutesSectionEntity> allMinutesSections = new ArrayList<>();
|
||||
for (MinutesEntity minutes : participantMinutesList) {
|
||||
List<MinutesSectionEntity> sections = minutesSectionRepository.findByMinutesIdOrderByOrderAsc(
|
||||
minutes.getMinutesId()
|
||||
);
|
||||
allMinutesSections.addAll(sections);
|
||||
}
|
||||
|
||||
// 4. AI 통합 분석 요청 데이터 생성
|
||||
ConsolidateRequest request = createConsolidateRequest(meeting, allMinutesSections, participantMinutesList);
|
||||
|
||||
// 5. AI Service 호출
|
||||
ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request);
|
||||
|
||||
// 5. AI 분석 결과 저장
|
||||
MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse);
|
||||
|
||||
// 6. Todo 생성 및 저장
|
||||
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
|
||||
|
||||
// 6. 회의 종료 처리
|
||||
meeting.end();
|
||||
meetingRepository.save(meeting);
|
||||
|
||||
// 7. 응답 DTO 생성
|
||||
return createMeetingEndDTO(meeting, analysis, todos, participantMinutesList.size());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,7 +175,7 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
.title(summary.getAgendaTitle())
|
||||
.aiSummaryShort(summary.getSummaryShort())
|
||||
.discussion(summary.getSummary() != null ? summary.getSummary() : "")
|
||||
.decisions(List.of())
|
||||
.decisions(summary.getDecisions() != null ? summary.getDecisions() : List.of())
|
||||
.pending(summary.getPending() != null ? summary.getPending() : List.of())
|
||||
.extractedTodos(summary.getTodos() != null
|
||||
? summary.getTodos().stream()
|
||||
@@ -168,18 +202,61 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
MeetingAnalysisEntity entity = MeetingAnalysisEntity.fromDomain(analysis);
|
||||
analysisRepository.save(entity);
|
||||
|
||||
// AgendaSection 저장 (안건별 회의록)
|
||||
saveAgendaSections(meeting.getMeetingId(), aiResponse);
|
||||
|
||||
log.info("AI 분석 결과 저장 완료 - analysisId: {}", analysis.getAnalysisId());
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgendaSection 저장
|
||||
*/
|
||||
private void saveAgendaSections(String meetingId, ConsolidateResponse aiResponse) {
|
||||
int agendaNumber = 1;
|
||||
|
||||
for (var summary : aiResponse.getAgendaSummaries()) {
|
||||
try {
|
||||
// pending items와 todos를 JSON 문자열로 변환
|
||||
String pendingItemsJson = summary.getPending() != null && !summary.getPending().isEmpty()
|
||||
? objectMapper.writeValueAsString(summary.getPending())
|
||||
: null;
|
||||
|
||||
String todosJson = summary.getTodos() != null && !summary.getTodos().isEmpty()
|
||||
? objectMapper.writeValueAsString(summary.getTodos())
|
||||
: null;
|
||||
|
||||
AgendaSectionEntity agendaSection = AgendaSectionEntity.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.minutesId(meetingId) // AI 통합 회의록 ID로 사용
|
||||
.meetingId(meetingId)
|
||||
.agendaNumber(agendaNumber++)
|
||||
.agendaTitle(summary.getAgendaTitle())
|
||||
.aiSummaryShort(summary.getSummaryShort())
|
||||
.summary(summary.getSummary())
|
||||
.pendingItems(pendingItemsJson)
|
||||
.todos(todosJson)
|
||||
.build();
|
||||
|
||||
agendaSectionRepository.save(agendaSection);
|
||||
log.debug("AgendaSection 저장 완료 - agendaTitle: {}", summary.getAgendaTitle());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AgendaSection 저장 실패 - agendaTitle: {}", summary.getAgendaTitle(), e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("AgendaSection 저장 완료 - meetingId: {}, count: {}", meetingId, aiResponse.getAgendaSummaries().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo 생성 및 저장
|
||||
*/
|
||||
private List<TodoEntity> createAndSaveTodos(MeetingEntity meeting, ConsolidateResponse aiResponse, MeetingAnalysis analysis) {
|
||||
List<TodoEntity> todos = aiResponse.getAgendaSummaries().stream()
|
||||
.<TodoEntity>flatMap(agenda -> {
|
||||
String agendaId = findAgendaIdByTitle(analysis, agenda.getAgendaTitle());
|
||||
// agendaId는 향후 Todo와 안건 매핑에 사용될 수 있음 (현재는 사용하지 않음)
|
||||
List<ExtractedTodoDTO> todoList = agenda.getTodos() != null ? agenda.getTodos() : List.of();
|
||||
return todoList.stream()
|
||||
.<TodoEntity>map(todo -> TodoEntity.builder()
|
||||
|
||||
+4
-4
@@ -66,8 +66,8 @@ public class MeetingMemoService implements SaveMeetingMemoUseCase {
|
||||
|
||||
// 4. 기존 메모 섹션 조회 또는 새로운 섹션 생성
|
||||
MinutesSection memoSection = minutesSectionReader.findFirstByMinutesIdAndType(
|
||||
minutes.getMinutesId(),
|
||||
"AI_MEMO"
|
||||
minutes.getMinutesId(),
|
||||
"MEMO"
|
||||
).orElseGet(() -> createNewMemoSection(minutes.getMinutesId()));
|
||||
|
||||
// 5. 메모 내용 업데이트 (기존 내용에 추가)
|
||||
@@ -81,7 +81,7 @@ public class MeetingMemoService implements SaveMeetingMemoUseCase {
|
||||
MinutesSection updatedSection = MinutesSection.builder()
|
||||
.sectionId(memoSection.getSectionId())
|
||||
.minutesId(memoSection.getMinutesId())
|
||||
.type("AI_MEMO")
|
||||
.type("MEMO")
|
||||
.title("회의 메모")
|
||||
.content(updatedContent)
|
||||
.order(memoSection.getOrder())
|
||||
@@ -105,7 +105,7 @@ public class MeetingMemoService implements SaveMeetingMemoUseCase {
|
||||
return MinutesSection.builder()
|
||||
.sectionId(generateSectionId())
|
||||
.minutesId(minutesId)
|
||||
.type("AI_MEMO")
|
||||
.type("MEMO")
|
||||
.title("회의 메모")
|
||||
.content("")
|
||||
.order(maxOrder + 1)
|
||||
|
||||
@@ -43,11 +43,10 @@ public class AIServiceClient {
|
||||
* @return 통합 요약 응답
|
||||
*/
|
||||
public ConsolidateResponse consolidateMinutes(ConsolidateRequest request) {
|
||||
log.info("AI Service 호출 - 회의록 통합 요약: {}", request.getMeetingId());
|
||||
String url = aiServiceUrl + "/api/transcripts/consolidate";
|
||||
log.info("AI Service 호출 시작 - URL: {}, meetingId: {}", url, request.getMeetingId());
|
||||
|
||||
try {
|
||||
String url = aiServiceUrl + "/api/transcripts/consolidate";
|
||||
|
||||
// HTTP 헤더 설정
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
@@ -55,6 +54,9 @@ public class AIServiceClient {
|
||||
// HTTP 요청 생성
|
||||
HttpEntity<ConsolidateRequest> httpEntity = new HttpEntity<>(request, headers);
|
||||
|
||||
log.debug("AI Service 요청 데이터 - participantMinutes 수: {}",
|
||||
request.getParticipantMinutes() != null ? request.getParticipantMinutes().size() : 0);
|
||||
|
||||
// API 호출
|
||||
ResponseEntity<ConsolidateResponse> response = restTemplate.postForEntity(
|
||||
url,
|
||||
@@ -65,15 +67,30 @@ public class AIServiceClient {
|
||||
ConsolidateResponse result = response.getBody();
|
||||
|
||||
if (result == null) {
|
||||
log.error("AI Service 응답이 비어있습니다 - HTTP Status: {}", response.getStatusCode());
|
||||
throw new RuntimeException("AI Service 응답이 비어있습니다");
|
||||
}
|
||||
|
||||
log.info("AI Service 응답 수신 완료 - 안건 수: {}", result.getAgendaSummaries().size());
|
||||
log.info("AI Service 응답 수신 완료 - 안건 수: {}, HTTP Status: {}",
|
||||
result.getAgendaSummaries() != null ? result.getAgendaSummaries().size() : 0,
|
||||
response.getStatusCode());
|
||||
|
||||
return result;
|
||||
|
||||
} catch (org.springframework.web.client.ResourceAccessException e) {
|
||||
log.error("AI Service 연결 실패 - URL: {}, 에러: {}", url, e.getMessage());
|
||||
throw new RuntimeException("AI 서비스에 연결할 수 없습니다. 서비스가 실행 중인지 확인해주세요.", e);
|
||||
} catch (org.springframework.web.client.HttpClientErrorException e) {
|
||||
log.error("AI Service 클라이언트 오류 - HTTP Status: {}, 응답: {}",
|
||||
e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw new RuntimeException("AI 서비스 요청이 거부되었습니다: " + e.getMessage(), e);
|
||||
} catch (org.springframework.web.client.HttpServerErrorException e) {
|
||||
log.error("AI Service 서버 오류 - HTTP Status: {}, 응답: {}",
|
||||
e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw new RuntimeException("AI 서비스에서 오류가 발생했습니다: " + e.getMessage(), e);
|
||||
} catch (Exception e) {
|
||||
log.error("AI Service 호출 실패: {}", e.getMessage(), e);
|
||||
log.error("AI Service 호출 중 예상치 못한 오류 - 타입: {}, 메시지: {}",
|
||||
e.getClass().getSimpleName(), e.getMessage(), e);
|
||||
throw new RuntimeException("AI 회의록 통합 처리 중 오류가 발생했습니다: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,15 @@ public class AgendaSummaryDTO {
|
||||
|
||||
/**
|
||||
* 안건별 회의록 요약
|
||||
* 사용자가 입력한 회의록 내용을 요약한 결과
|
||||
* 사용자가 입력한 회의록 내용을 요약한 결과 (논의사항 + 결정사항)
|
||||
*/
|
||||
private String summary;
|
||||
|
||||
/**
|
||||
* 안건별 결정사항 배열 (대시보드 표시용)
|
||||
*/
|
||||
private List<String> decisions;
|
||||
|
||||
/**
|
||||
* 보류 사항
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user