feat: Meeting AI 통합 - 회의 종료 API 및 AI 회의록 요약 기능 구현

주요 변경사항:
- 회의 종료 API 구현 (POST /api/meetings/{meetingId}/end)
- AI 회의록 통합 요약 기능 구현
- Claude API 연동 및 프롬프트 최적화
- 안건별 요약, 키워드 추출, 결정사항 자동 정리

AI Service (Python):
- Claude 모델 설정: claude-sonnet-4-5-20250929
- 회의록 통합 프롬프트 개선
- AgendaSummary 모델 summary 필드 매핑 수정
- decisions 필드 추가 및 응답 구조 정리
- 입력 데이터 로깅 추가

Meeting Service (Java):
- EndMeetingService AI 통합 로직 구현
- MeetingAnalysis 엔티티 decisions 필드 추가
- AgendaSection opinions 필드 제거
- AI Service 포트 8086으로 설정
- DB 마이그레이션 스크립트 추가 (V7)

테스트 결과:
 회의 종료 API 정상 동작
 AI 응답 검증 (keywords, summary, decisions)
 안건별 요약 및 보류사항 추출
 처리 시간: ~11초, 토큰: ~2,600

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Minseo-Jo
2025-10-29 14:46:41 +09:00
parent 96e09ae83d
commit e30aa5c116
19 changed files with 148 additions and 80 deletions
@@ -49,25 +49,17 @@ public class AgendaSection {
private String aiSummaryShort;
/**
* 논의 사항 (핵심 내용 3-5문장)
* 안건별 회의록 요약
* 사용자가 입력한 회의록 내용을 요약한 결과
* (기존 discussions, decisions, opinions를 통합)
*/
private String discussions;
/**
* 결정 사항 목록
*/
private List<String> decisions;
private String summary;
/**
* 보류 사항 목록
*/
private List<String> pendingItems;
/**
* 참석자별 의견
*/
private List<ParticipantOpinion> opinions;
/**
* AI 추출 Todo 목록
*/
@@ -83,25 +75,6 @@ public class AgendaSection {
*/
private LocalDateTime updatedAt;
/**
* 참석자 의견 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ParticipantOpinion {
/**
* 발언자 이름
*/
private String speaker;
/**
* 의견 내용
*/
private String opinion;
}
/**
* Todo 항목 내부 클래스
*/
@@ -37,6 +37,11 @@ public class MeetingAnalysis {
*/
private List<String> keywords;
/**
* 회의 전체 결정사항
*/
private String decisions;
/**
* 안건별 분석 결과
*/
@@ -76,6 +76,11 @@ public class Minutes {
*/
private String lastModifiedBy;
/**
* 회의 전체 결정사항
*/
private String decisions;
/**
* 확정자 ID
*/
@@ -106,6 +106,11 @@ public class MinutesDTO {
*/
private final LocalDateTime lastModifiedAt;
/**
* 회의 전체 결정사항
*/
private final String decisions;
/**
* Todo 개수
*/
@@ -140,8 +140,8 @@ public class EndMeetingService implements EndMeetingUseCase {
.agendaId(UUID.randomUUID().toString())
.title(summary.getAgendaTitle())
.aiSummaryShort(summary.getSummaryShort())
.discussion(summary.getDiscussion() != null ? summary.getDiscussion() : "")
.decisions(summary.getDecisions() != null ? summary.getDecisions() : List.of())
.discussion(summary.getSummary() != null ? summary.getSummary() : "")
.decisions(List.of())
.pending(summary.getPending() != null ? summary.getPending() : List.of())
.extractedTodos(summary.getTodos() != null
? summary.getTodos().stream()
@@ -157,6 +157,7 @@ public class EndMeetingService implements EndMeetingUseCase {
.meetingId(meeting.getMeetingId())
.minutesId(meeting.getMeetingId()) // 실제로는 minutesId 필요
.keywords(aiResponse.getKeywords())
.decisions(aiResponse.getDecisions())
.agendaAnalyses(agendaAnalyses)
.status("COMPLETED")
.completedAt(LocalDateTime.now())
@@ -181,8 +181,7 @@ public class MeetingAiController {
.agendaNumber(section.getAgendaNumber())
.agendaTitle(section.getAgendaTitle())
.aiSummaryShort(section.getAiSummaryShort())
.discussions(section.getDiscussions())
.decisions(section.getDecisions())
.summary(section.getSummary())
.pendingItems(section.getPendingItems())
.todos(todos)
.createdAt(section.getCreatedAt())
@@ -36,14 +36,10 @@ public class AgendaSummaryDTO {
private String summaryShort;
/**
* 논의 주제
* 안건별 회의록 요약
* 사용자가 입력한 회의록 내용을 요약한 결과
*/
private String discussion;
/**
* 결정 사항
*/
private List<String> decisions;
private String summary;
/**
* 보류 사항
@@ -39,6 +39,11 @@ public class ConsolidateResponse {
*/
private Map<String, Integer> statistics;
/**
* 회의 전체 결정사항
*/
private String decisions;
/**
* 안건별 요약
*/
@@ -1,5 +1,6 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -17,11 +18,13 @@ public class ParticipantMinutesDTO {
/**
* 사용자 ID
*/
@JsonProperty("user_id")
private String userId;
/**
* 사용자 이름
*/
@JsonProperty("user_name")
private String userName;
/**
@@ -49,11 +49,8 @@ public class AgendaSectionResponse {
@Schema(description = "AI 생성 짧은 요약", example = "타겟 고객을 20-30대로 설정")
private String aiSummaryShort;
@Schema(description = "논의 사항", example = "신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고...")
private String discussions;
@Schema(description = "결정 사항 목록")
private List<String> decisions;
@Schema(description = "안건별 회의록 요약", example = "신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고...")
private String summary;
@Schema(description = "보류 사항 목록")
private List<String> pendingItems;
@@ -47,18 +47,12 @@ public class AgendaSectionEntity extends BaseTimeEntity {
@Column(name = "ai_summary_short", columnDefinition = "TEXT")
private String aiSummaryShort;
@Column(name = "discussions", columnDefinition = "TEXT")
private String discussions;
@Column(name = "decisions", columnDefinition = "json")
private String decisionsJson;
@Column(name = "summary", columnDefinition = "TEXT")
private String summary;
@Column(name = "pending_items", columnDefinition = "json")
private String pendingItemsJson;
@Column(name = "opinions", columnDefinition = "json")
private String opinionsJson;
@Column(name = "todos", columnDefinition = "json")
private String todosJson;
@@ -73,10 +67,8 @@ public class AgendaSectionEntity extends BaseTimeEntity {
.agendaNumber(this.agendaNumber)
.agendaTitle(this.agendaTitle)
.aiSummaryShort(this.aiSummaryShort)
.discussions(this.discussions)
.decisions(parseJsonToList(this.decisionsJson, new TypeReference<List<String>>() {}))
.summary(this.summary)
.pendingItems(parseJsonToList(this.pendingItemsJson, new TypeReference<List<String>>() {}))
.opinions(parseJsonToList(this.opinionsJson, new TypeReference<List<AgendaSection.ParticipantOpinion>>() {}))
.todos(parseJsonToList(this.todosJson, new TypeReference<List<AgendaSection.TodoItem>>() {}))
.createdAt(this.getCreatedAt())
.updatedAt(this.getUpdatedAt())
@@ -94,10 +86,8 @@ public class AgendaSectionEntity extends BaseTimeEntity {
.agendaNumber(section.getAgendaNumber())
.agendaTitle(section.getAgendaTitle())
.aiSummaryShort(section.getAiSummaryShort())
.discussions(section.getDiscussions())
.decisionsJson(toJson(section.getDecisions()))
.summary(section.getSummary())
.pendingItemsJson(toJson(section.getPendingItems()))
.opinionsJson(toJson(section.getOpinions()))
.todosJson(toJson(section.getTodos()))
.build();
}
@@ -38,6 +38,9 @@ public class MeetingAnalysisEntity {
@Column(name = "keyword")
private List<String> keywords;
@Column(name = "decisions", columnDefinition = "TEXT")
private String decisions;
@Column(name = "agenda_analyses", columnDefinition = "TEXT")
private String agendaAnalysesJson; // JSON 문자열로 저장
@@ -55,12 +58,13 @@ public class MeetingAnalysisEntity {
*/
public MeetingAnalysis toDomain() {
List<MeetingAnalysis.AgendaAnalysis> agendaAnalyses = parseAgendaAnalyses();
return MeetingAnalysis.builder()
.analysisId(this.analysisId)
.meetingId(this.meetingId)
.minutesId(this.minutesId)
.keywords(this.keywords)
.decisions(this.decisions)
.agendaAnalyses(agendaAnalyses)
.status(this.status)
.completedAt(this.completedAt)
@@ -90,12 +94,13 @@ public class MeetingAnalysisEntity {
*/
public static MeetingAnalysisEntity fromDomain(MeetingAnalysis domain) {
String agendaAnalysesJson = convertAgendaAnalysesToJson(domain.getAgendaAnalyses());
return MeetingAnalysisEntity.builder()
.analysisId(domain.getAnalysisId())
.meetingId(domain.getMeetingId())
.minutesId(domain.getMinutesId())
.keywords(domain.getKeywords())
.decisions(domain.getDecisions())
.agendaAnalysesJson(agendaAnalysesJson)
.status(domain.getStatus())
.completedAt(domain.getCompletedAt())
@@ -48,6 +48,9 @@ public class MinutesEntity extends BaseTimeEntity {
@Column(name = "created_by", length = 50, nullable = false)
private String createdBy;
@Column(name = "decisions", columnDefinition = "TEXT")
private String decisions;
@Column(name = "finalized_by", length = 50)
private String finalizedBy;
@@ -66,6 +69,7 @@ public class MinutesEntity extends BaseTimeEntity {
.createdBy(this.createdBy)
.createdAt(this.getCreatedAt())
.lastModifiedAt(this.getUpdatedAt())
.decisions(this.decisions)
.finalizedBy(this.finalizedBy)
.finalizedAt(this.finalizedAt)
.build();
@@ -80,6 +84,7 @@ public class MinutesEntity extends BaseTimeEntity {
.status(minutes.getStatus())
.version(minutes.getVersion())
.createdBy(minutes.getCreatedBy())
.decisions(minutes.getDecisions())
.finalizedBy(minutes.getFinalizedBy())
.finalizedAt(minutes.getFinalizedAt())
.build();
+1 -1
View File
@@ -137,5 +137,5 @@ azure:
# AI Service Configuration
ai:
service:
url: ${AI_SERVICE_URL:http://localhost:8087}
url: ${AI_SERVICE_URL:http://localhost:8086}
timeout: ${AI_SERVICE_TIMEOUT:30000}
@@ -0,0 +1,58 @@
-- ========================================
-- V7: minutes 테이블에 decisions 추가 및 agenda_sections 리팩토링
-- ========================================
-- 작성일: 2025-10-29
-- 설명:
-- 1. minutes 테이블에 decisions (결정사항) 컬럼 추가
-- 2. agenda_sections 테이블에서 discussions, decisions, opinions를 summary로 통합
-- ========================================
-- 1. minutes 테이블에 decisions 컬럼 추가
-- ========================================
-- 회의 전체 결정사항을 TEXT 형식으로 저장
ALTER TABLE minutes
ADD COLUMN IF NOT EXISTS decisions TEXT;
COMMENT ON COLUMN minutes.decisions IS '회의 전체 결정사항 (회의록 수정 시 입력)';
-- ========================================
-- 2. agenda_sections 테이블 백업 및 데이터 마이그레이션
-- ========================================
-- 기존 데이터 보존을 위한 임시 백업 테이블 생성
CREATE TABLE IF NOT EXISTS agenda_sections_backup AS
SELECT * FROM agenda_sections;
-- ========================================
-- 3. agenda_sections 테이블 컬럼 변경
-- ========================================
-- discussions, decisions, opinions → summary 통합
-- summary 컬럼 추가 (기존 discussions 내용으로 초기화)
ALTER TABLE agenda_sections
ADD COLUMN IF NOT EXISTS summary TEXT;
-- 기존 데이터 마이그레이션: discussions 내용을 summary로 복사
UPDATE agenda_sections
SET summary = COALESCE(discussions, '');
-- 기존 컬럼 삭제
ALTER TABLE agenda_sections
DROP COLUMN IF EXISTS discussions,
DROP COLUMN IF EXISTS decisions,
DROP COLUMN IF EXISTS opinions;
-- 코멘트 추가
COMMENT ON COLUMN agenda_sections.summary IS '안건별 회의록 요약 (사용자가 입력한 회의록 내용을 AI가 요약한 결과)';
-- ========================================
-- 4. 인덱스 및 트리거 유지
-- ========================================
-- 기존 인덱스 및 트리거는 그대로 유지됨
-- ========================================
-- 5. 백업 테이블 정리 안내
-- ========================================
-- agenda_sections_backup 테이블은 수동으로 검증 후 삭제
COMMENT ON TABLE agenda_sections_backup IS 'V7 마이그레이션 백업 - 검증 후 수동 삭제 필요';