작업 중: Meeting AI 통합 개발 진행 상황 저장

This commit is contained in:
Minseo-Jo
2025-10-29 09:15:23 +09:00
parent 143721d106
commit 621d4c16df
53 changed files with 17042 additions and 17928 deletions
@@ -4,18 +4,19 @@ 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.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.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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -25,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -39,7 +41,8 @@ import java.util.stream.Collectors;
public class EndMeetingService implements EndMeetingUseCase {
private final MeetingJpaRepository meetingRepository;
private final AgendaSectionJpaRepository agendaRepository;
private final MinutesJpaRepository minutesRepository;
private final MinutesSectionJpaRepository minutesSectionRepository;
private final TodoJpaRepository todoRepository;
private final MeetingAnalysisJpaRepository analysisRepository;
private final AIServiceClient aiServiceClient;
@@ -59,13 +62,26 @@ public class EndMeetingService implements EndMeetingUseCase {
MeetingEntity meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new IllegalArgumentException("회의를 찾을 수 없습니다: " + meetingId));
// 2. 안건 목록 조회 (실제로는 참석자별 메모 섹션)
List<AgendaSectionEntity> agendaSections = agendaRepository.findByMeetingIdOrderByAgendaNumberAsc(meetingId);
// 2. 참석자별 회의록 조회 (userId가 있는 회의록들)
List<MinutesEntity> participantMinutesList = minutesRepository.findByMeetingIdAndUserIdIsNotNull(meetingId);
// 3. AI 통합 분석 요청 데이터 생성
ConsolidateRequest request = createConsolidateRequest(meeting, agendaSections);
if (participantMinutesList.isEmpty()) {
throw new IllegalStateException("참석자 회의록이 없습니다: " + meetingId);
}
// 4. AI Service 호출
// 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 분석 결과 저장
@@ -74,25 +90,38 @@ public class EndMeetingService implements EndMeetingUseCase {
// 6. Todo 생성 및 저장
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
// 7. 회의 종료 처리
// 6. 회의 종료 처리
meeting.end();
meetingRepository.save(meeting);
// 8. 응답 DTO 생성
return createMeetingEndDTO(meeting, analysis, todos, agendaSections.size());
// 7. 응답 DTO 생성
return createMeetingEndDTO(meeting, analysis, todos, participantMinutesList.size());
}
/**
* AI 통합 분석 요청 데이터 생성
* 참석자별 회의록의 섹션들을 참석자별로 그룹화하여 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())
private ConsolidateRequest createConsolidateRequest(
MeetingEntity meeting,
List<MinutesSectionEntity> allMinutesSections,
List<MinutesEntity> participantMinutesList) {
// 참석자별 회의록을 ParticipantMinutesDTO로 변환
List<ParticipantMinutesDTO> participantMinutes = participantMinutesList.stream()
.<ParticipantMinutesDTO>map(minutes -> {
// 해당 회의록의 섹션들만 필터링
String content = allMinutesSections.stream()
.filter(section -> section.getMinutesId().equals(minutes.getMinutesId()))
.<String>map(section -> section.getTitle() + "\n" + section.getContent())
.collect(Collectors.joining("\n\n"));
return ParticipantMinutesDTO.builder()
.userId(minutes.getUserId())
.userName(minutes.getUserId()) // 실제로는 userName이 필요하지만 일단 userId 사용
.content(content)
.build();
})
.collect(Collectors.toList());
return ConsolidateRequest.builder()
@@ -46,7 +46,7 @@ public class AIServiceClient {
log.info("AI Service 호출 - 회의록 통합 요약: {}", request.getMeetingId());
try {
String url = aiServiceUrl + "/api/v1/transcripts/consolidate";
String url = aiServiceUrl + "/api/transcripts/consolidate";
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
@@ -37,10 +37,6 @@ public class MinutesEntity extends BaseTimeEntity {
@Column(name = "title", length = 200, nullable = false)
private String title;
@OneToMany(mappedBy = "minutes", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<MinutesSectionEntity> sections = new ArrayList<>();
@Column(name = "status", length = 20, nullable = false)
@Builder.Default
private String status = "DRAFT";
@@ -64,9 +60,7 @@ public class MinutesEntity extends BaseTimeEntity {
.meetingId(this.meetingId)
.userId(this.userId)
.title(this.title)
.sections(this.sections.stream()
.map(MinutesSectionEntity::toDomain)
.collect(Collectors.toList()))
.sections(List.of()) // sections는 별도 조회 필요
.status(this.status)
.version(this.version)
.createdBy(this.createdBy)
@@ -83,11 +77,6 @@ public class MinutesEntity extends BaseTimeEntity {
.meetingId(minutes.getMeetingId())
.userId(minutes.getUserId())
.title(minutes.getTitle())
.sections(minutes.getSections() != null
? minutes.getSections().stream()
.map(MinutesSectionEntity::fromDomain)
.collect(Collectors.toList())
: new ArrayList<>())
.status(minutes.getStatus())
.version(minutes.getVersion())
.createdBy(minutes.getCreatedBy())
@@ -10,6 +10,8 @@ import lombok.NoArgsConstructor;
/**
* 회의록 섹션 Entity
* 참석자가 작성한 메모를 안건별로 저장
* AI 분석의 입력 데이터로 사용됨
*/
@Entity
@Table(name = "minutes_sections")
@@ -20,43 +22,36 @@ import lombok.NoArgsConstructor;
public class MinutesSectionEntity extends BaseTimeEntity {
@Id
@Column(name = "section_id", length = 50)
private String sectionId;
@Column(name = "id", length = 50)
private String id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "minutes_id", nullable = false)
private MinutesEntity minutes;
@Column(name = "minutes_id", insertable = false, updatable = false)
@Column(name = "minutes_id", nullable = false, length = 50)
private String minutesId;
@Column(name = "type", length = 50, nullable = false)
@Column(name = "type", length = 50)
private String type;
@Column(name = "title", length = 200, nullable = false)
@Column(name = "title", length = 200)
private String title;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "\"order\"")
@Builder.Default
private Integer order = 0;
@Column(name = "order")
private Integer order;
@Column(name = "verified", nullable = false)
@Builder.Default
private Boolean verified = false;
@Column(name = "verified")
private Boolean verified;
@Column(name = "locked", nullable = false)
@Builder.Default
private Boolean locked = false;
@Column(name = "locked")
private Boolean locked;
@Column(name = "locked_by", length = 50)
private String lockedBy;
public MinutesSection toDomain() {
return MinutesSection.builder()
.sectionId(this.sectionId)
.sectionId(this.id)
.minutesId(this.minutesId)
.type(this.type)
.title(this.title)
@@ -70,7 +65,7 @@ public class MinutesSectionEntity extends BaseTimeEntity {
public static MinutesSectionEntity fromDomain(MinutesSection section) {
return MinutesSectionEntity.builder()
.sectionId(section.getSectionId())
.id(section.getSectionId())
.minutesId(section.getMinutesId())
.type(section.getType())
.title(section.getTitle())
@@ -82,6 +77,10 @@ public class MinutesSectionEntity extends BaseTimeEntity {
.build();
}
public void verify() {
this.verified = true;
}
public void lock(String userId) {
this.locked = true;
this.lockedBy = userId;
@@ -91,8 +90,4 @@ public class MinutesSectionEntity extends BaseTimeEntity {
this.locked = false;
this.lockedBy = null;
}
public void verify() {
this.verified = true;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ spring:
use_sql_comments: true
dialect: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
ddl-auto: ${JPA_DDL_AUTO:none}
# Redis Configuration
data:
@@ -0,0 +1,52 @@
-- ========================================
-- V5: minutes_sections 테이블 재생성
-- ========================================
-- 작성일: 2025-10-28
-- 설명: minutes_sections 테이블을 Entity 구조에 맞게 재생성
-- 1. 기존 테이블이 있으면 삭제
DROP TABLE IF EXISTS minutes_sections CASCADE;
-- 2. Entity 구조에 맞는 테이블 생성
CREATE TABLE minutes_sections (
id VARCHAR(50) PRIMARY KEY,
minutes_id VARCHAR(50) NOT NULL,
type VARCHAR(50),
title VARCHAR(200),
content TEXT,
"order" INTEGER,
verified BOOLEAN DEFAULT FALSE,
locked BOOLEAN DEFAULT FALSE,
locked_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_minutes_sections_minutes
FOREIGN KEY (minutes_id) REFERENCES minutes(id)
ON DELETE CASCADE
);
-- 3. 인덱스 생성
CREATE INDEX idx_minutes_sections_minutes ON minutes_sections(minutes_id);
CREATE INDEX idx_minutes_sections_order ON minutes_sections(minutes_id, "order");
CREATE INDEX idx_minutes_sections_type ON minutes_sections(type);
CREATE INDEX idx_minutes_sections_verified ON minutes_sections(verified);
-- 4. 코멘트 추가
COMMENT ON TABLE minutes_sections IS '참석자별 회의록 안건 섹션 - AI 통합 회의록 생성 입력 데이터';
COMMENT ON COLUMN minutes_sections.id IS '섹션 고유 ID';
COMMENT ON COLUMN minutes_sections.minutes_id IS '참석자별 회의록 ID (minutes.id 참조)';
COMMENT ON COLUMN minutes_sections.type IS '섹션 타입 (AGENDA: 안건, DISCUSSION: 논의사항, DECISION: 결정사항 등)';
COMMENT ON COLUMN minutes_sections.title IS '섹션 제목';
COMMENT ON COLUMN minutes_sections.content IS '섹션 내용 (참석자가 작성한 메모)';
COMMENT ON COLUMN minutes_sections."order" IS '섹션 순서';
COMMENT ON COLUMN minutes_sections.verified IS '검증 완료 여부';
COMMENT ON COLUMN minutes_sections.locked IS '편집 잠금 여부';
COMMENT ON COLUMN minutes_sections.locked_by IS '잠금 설정한 사용자 ID';
-- 5. updated_at 자동 업데이트 트리거
DROP TRIGGER IF EXISTS update_minutes_sections_updated_at ON minutes_sections;
CREATE TRIGGER update_minutes_sections_updated_at
BEFORE UPDATE ON minutes_sections
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();