feat: Meeting Service AI 통합 개발

 구현 완료
- AI Python Service (FastAPI, Claude API, 8087 포트)
  - POST /api/v1/transcripts/consolidate
  - 참석자별 회의록 → AI 통합 분석
  - 키워드/안건별 요약/Todo 추출

- Meeting Service AI 통합
  - EndMeetingService (@Primary)
  - AIServiceClient (RestTemplate, 30초 timeout)
  - AI 분석 결과 저장 (meeting_analysis, todos)
  - 회의 상태 COMPLETED 처리

- DTO 구조 (간소화)
  - ConsolidateRequest/Response
  - MeetingEndDTO
  - Todo 제목만 포함 (담당자/마감일 제거)

📝 기술스택
- Python: FastAPI, anthropic 0.71.0, psycopg2
- Java: Spring Boot, RestTemplate
- Claude: claude-3-5-sonnet-20241022

🔧 주요 이슈 해결
- 포트 충돌: 8086(feature/stt-ai) → 8087(feat/meeting-ai)
- Bean 충돌: @Primary 추가
- YAML 문법: ai.service.url 구조 수정
- anthropic 라이브러리 업그레이드

📚 테스트 가이드 및 스크립트 작성
- claude/MEETING-AI-TEST-GUIDE.md
- test-meeting-ai.sh

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Minseo-Jo
2025-10-28 16:42:09 +09:00
parent 79036128ec
commit 143721d106
22 changed files with 1831 additions and 0 deletions
@@ -0,0 +1,237 @@
package com.unicorn.hgzero.meeting.biz.service;
import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis;
import com.unicorn.hgzero.meeting.biz.dto.MeetingEndDTO;
import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.EndMeetingUseCase;
import com.unicorn.hgzero.meeting.infra.client.AIServiceClient;
import com.unicorn.hgzero.meeting.infra.dto.ai.AgendaSummaryDTO;
import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateRequest;
import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateResponse;
import com.unicorn.hgzero.meeting.infra.dto.ai.ExtractedTodoDTO;
import com.unicorn.hgzero.meeting.infra.dto.ai.ParticipantMinutesDTO;
import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingAnalysisEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.TodoEntity;
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.TodoJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 회의 종료 비즈니스 로직 (AI 통합)
*/
@Slf4j
@Service
@Primary
@RequiredArgsConstructor
public class EndMeetingService implements EndMeetingUseCase {
private final MeetingJpaRepository meetingRepository;
private final AgendaSectionJpaRepository agendaRepository;
private final TodoJpaRepository todoRepository;
private final MeetingAnalysisJpaRepository analysisRepository;
private final AIServiceClient aiServiceClient;
/**
* 회의 종료 및 AI 분석 실행
*
* @param meetingId 회의 ID
* @return 회의 종료 결과 DTO
*/
@Override
@Transactional
public MeetingEndDTO endMeeting(String meetingId) {
log.info("회의 종료 시작 - meetingId: {}", meetingId);
// 1. 회의 정보 조회
MeetingEntity meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new IllegalArgumentException("회의를 찾을 수 없습니다: " + meetingId));
// 2. 안건 목록 조회 (실제로는 참석자별 메모 섹션)
List<AgendaSectionEntity> agendaSections = agendaRepository.findByMeetingIdOrderByAgendaNumberAsc(meetingId);
// 3. AI 통합 분석 요청 데이터 생성
ConsolidateRequest request = createConsolidateRequest(meeting, agendaSections);
// 4. AI Service 호출
ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request);
// 5. AI 분석 결과 저장
MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse);
// 6. Todo 생성 및 저장
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
// 7. 회의 종료 처리
meeting.end();
meetingRepository.save(meeting);
// 8. 응답 DTO 생성
return createMeetingEndDTO(meeting, analysis, todos, agendaSections.size());
}
/**
* AI 통합 분석 요청 데이터 생성
*/
private ConsolidateRequest createConsolidateRequest(MeetingEntity meeting, List<AgendaSectionEntity> agendaSections) {
// 참석자별 회의록 변환 (AgendaSection → ParticipantMinutes)
List<ParticipantMinutesDTO> participantMinutes = agendaSections.stream()
.<ParticipantMinutesDTO>map(section -> ParticipantMinutesDTO.builder()
.userId(section.getMeetingId()) // 실제로는 participantId 필요
.userName(section.getAgendaTitle()) // 실제로는 participantName 필요
.content(section.getDiscussions() != null ? section.getDiscussions() : "")
.build())
.collect(Collectors.toList());
return ConsolidateRequest.builder()
.meetingId(meeting.getMeetingId())
.participantMinutes(participantMinutes)
.build();
}
/**
* AI 분석 결과 저장
*/
private MeetingAnalysis saveAnalysisResult(MeetingEntity meeting, ConsolidateResponse aiResponse) {
// AgendaAnalysis 리스트 생성
List<MeetingAnalysis.AgendaAnalysis> agendaAnalyses = aiResponse.getAgendaSummaries().stream()
.<MeetingAnalysis.AgendaAnalysis>map(summary -> MeetingAnalysis.AgendaAnalysis.builder()
.agendaId(UUID.randomUUID().toString())
.title(summary.getAgendaTitle())
.aiSummaryShort(summary.getSummaryShort())
.discussion(summary.getDiscussion() != null ? summary.getDiscussion() : "")
.decisions(summary.getDecisions() != null ? summary.getDecisions() : List.of())
.pending(summary.getPending() != null ? summary.getPending() : List.of())
.extractedTodos(summary.getTodos() != null
? summary.getTodos().stream()
.<String>map(todo -> todo.getTitle())
.collect(Collectors.toList())
: List.of())
.build())
.collect(Collectors.toList());
// MeetingAnalysis 도메인 생성
MeetingAnalysis analysis = MeetingAnalysis.builder()
.analysisId(UUID.randomUUID().toString())
.meetingId(meeting.getMeetingId())
.minutesId(meeting.getMeetingId()) // 실제로는 minutesId 필요
.keywords(aiResponse.getKeywords())
.agendaAnalyses(agendaAnalyses)
.status("COMPLETED")
.completedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.build();
// Entity 저장
MeetingAnalysisEntity entity = MeetingAnalysisEntity.fromDomain(analysis);
analysisRepository.save(entity);
log.info("AI 분석 결과 저장 완료 - analysisId: {}", analysis.getAnalysisId());
return analysis;
}
/**
* 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());
List<ExtractedTodoDTO> todoList = agenda.getTodos() != null ? agenda.getTodos() : List.of();
return todoList.stream()
.<TodoEntity>map(todo -> TodoEntity.builder()
.todoId(UUID.randomUUID().toString())
.meetingId(meeting.getMeetingId())
.minutesId(meeting.getMeetingId()) // 실제로는 minutesId 필요
.title(todo.getTitle())
.assigneeId("") // AI가 담당자를 추출하지 않으므로 빈 값
.status("PENDING")
.build());
})
.collect(Collectors.toList());
if (!todos.isEmpty()) {
todoRepository.saveAll(todos);
log.info("Todo 생성 완료 - 총 {}개", todos.size());
}
return todos;
}
/**
* 안건 제목으로 안건 ID 찾기
*/
private String findAgendaIdByTitle(MeetingAnalysis analysis, String title) {
return analysis.getAgendaAnalyses().stream()
.filter(agenda -> agenda.getTitle().equals(title))
.findFirst()
.map(MeetingAnalysis.AgendaAnalysis::getAgendaId)
.orElse(null);
}
/**
* 회의 종료 결과 DTO 생성
*/
private MeetingEndDTO createMeetingEndDTO(MeetingEntity meeting, MeetingAnalysis analysis,
List<TodoEntity> todos, int participantCount) {
// 회의 소요 시간 계산
int durationMinutes = calculateDurationMinutes(meeting.getStartedAt(), meeting.getEndedAt());
// 안건별 요약 DTO 생성
List<MeetingEndDTO.AgendaSummaryDTO> agendaSummaries = analysis.getAgendaAnalyses().stream()
.<MeetingEndDTO.AgendaSummaryDTO>map(agenda -> {
// 해당 안건의 Todo 필터링 (agendaId가 없을 수 있음)
List<MeetingEndDTO.TodoSummaryDTO> agendaTodos = todos.stream()
.filter(todo -> agenda.getAgendaId().equals(todo.getMinutesId())) // 임시 매핑
.<MeetingEndDTO.TodoSummaryDTO>map(todo -> MeetingEndDTO.TodoSummaryDTO.builder()
.title(todo.getTitle())
.build())
.collect(Collectors.toList());
return MeetingEndDTO.AgendaSummaryDTO.builder()
.title(agenda.getTitle())
.aiSummaryShort(agenda.getAiSummaryShort())
.details(MeetingEndDTO.AgendaDetailsDTO.builder()
.discussion(agenda.getDiscussion())
.decisions(agenda.getDecisions())
.pending(agenda.getPending())
.build())
.todos(agendaTodos)
.build();
})
.collect(Collectors.toList());
return MeetingEndDTO.builder()
.title(meeting.getTitle())
.participantCount(participantCount)
.durationMinutes(durationMinutes)
.agendaCount(analysis.getAgendaAnalyses().size())
.todoCount(todos.size())
.keywords(analysis.getKeywords())
.agendaSummaries(agendaSummaries)
.build();
}
/**
* 회의 소요 시간 계산 (분 단위)
*/
private int calculateDurationMinutes(LocalDateTime startedAt, LocalDateTime endedAt) {
if (startedAt == null || endedAt == null) {
return 0;
}
return (int) Duration.between(startedAt, endedAt).toMinutes();
}
}
@@ -0,0 +1,80 @@
package com.unicorn.hgzero.meeting.infra.client;
import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateRequest;
import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
/**
* AI Service 호출 클라이언트
*/
@Slf4j
@Component
public class AIServiceClient {
private final RestTemplate restTemplate;
private final String aiServiceUrl;
public AIServiceClient(
RestTemplateBuilder restTemplateBuilder,
@Value("${ai.service.url:http://localhost:8087}") String aiServiceUrl,
@Value("${ai.service.timeout:30000}") int timeout
) {
this.restTemplate = restTemplateBuilder
.setConnectTimeout(Duration.ofMillis(timeout))
.setReadTimeout(Duration.ofMillis(timeout))
.build();
this.aiServiceUrl = aiServiceUrl;
}
/**
* 회의록 통합 요약 API 호출
*
* @param request 통합 요약 요청
* @return 통합 요약 응답
*/
public ConsolidateResponse consolidateMinutes(ConsolidateRequest request) {
log.info("AI Service 호출 - 회의록 통합 요약: {}", request.getMeetingId());
try {
String url = aiServiceUrl + "/api/v1/transcripts/consolidate";
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
// HTTP 요청 생성
HttpEntity<ConsolidateRequest> httpEntity = new HttpEntity<>(request, headers);
// API 호출
ResponseEntity<ConsolidateResponse> response = restTemplate.postForEntity(
url,
httpEntity,
ConsolidateResponse.class
);
ConsolidateResponse result = response.getBody();
if (result == null) {
throw new RuntimeException("AI Service 응답이 비어있습니다");
}
log.info("AI Service 응답 수신 완료 - 안건 수: {}", result.getAgendaSummaries().size());
return result;
} catch (Exception e) {
log.error("AI Service 호출 실패: {}", e.getMessage(), e);
throw new RuntimeException("AI 회의록 통합 처리 중 오류가 발생했습니다: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,57 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 안건별 요약 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgendaSummaryDTO {
/**
* 안건 번호
*/
@JsonProperty("agenda_number")
private Integer agendaNumber;
/**
* 안건 제목
*/
@JsonProperty("agenda_title")
private String agendaTitle;
/**
* 짧은 요약 (1줄)
*/
@JsonProperty("summary_short")
private String summaryShort;
/**
* 논의 주제
*/
private String discussion;
/**
* 결정 사항
*/
private List<String> decisions;
/**
* 보류 사항
*/
private List<String> pending;
/**
* Todo 목록
*/
private List<ExtractedTodoDTO> todos;
}
@@ -0,0 +1,42 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* AI Service - 회의록 통합 요약 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConsolidateRequest {
/**
* 회의 ID
*/
@JsonProperty("meeting_id")
private String meetingId;
/**
* 참석자별 회의록 목록
*/
@JsonProperty("participant_minutes")
private List<ParticipantMinutesDTO> participantMinutes;
/**
* 안건 목록 (선택)
*/
private List<String> agendas;
/**
* 회의 시간(분) (선택)
*/
@JsonProperty("duration_minutes")
private Integer durationMinutes;
}
@@ -0,0 +1,53 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* AI Service - 회의록 통합 요약 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConsolidateResponse {
/**
* 회의 ID
*/
@JsonProperty("meeting_id")
private String meetingId;
/**
* 주요 키워드
*/
private List<String> keywords;
/**
* 통계 정보
* - participants_count: 참석자 수
* - agendas_count: 안건 수
* - todos_count: Todo 개수
* - duration_minutes: 회의 시간(분)
*/
private Map<String, Integer> statistics;
/**
* 안건별 요약
*/
@JsonProperty("agenda_summaries")
private List<AgendaSummaryDTO> agendaSummaries;
/**
* 생성 시각
*/
@JsonProperty("generated_at")
private LocalDateTime generatedAt;
}
@@ -0,0 +1,21 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* AI 추출 Todo DTO (제목만)
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExtractedTodoDTO {
/**
* Todo 제목
*/
private String title;
}
@@ -0,0 +1,31 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 참석자별 회의록 DTO (AI Service 요청용)
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ParticipantMinutesDTO {
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String userName;
/**
* 회의록 전체 내용 (MEMO 섹션)
*/
private String content;
}
@@ -133,3 +133,9 @@ azure:
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
container: ${AZURE_STORAGE_CONTAINER:hgzero-checkpoints}
# AI Service Configuration
ai:
service:
url: ${AI_SERVICE_URL:http://localhost:8087}
timeout: ${AI_SERVICE_TIMEOUT:30000}