mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 09:06:24 +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:
parent
4a87be88f0
commit
0caa1ec3b6
@ -30,6 +30,7 @@ class AgendaSummary(BaseModel):
|
|||||||
agenda_title: str = Field(..., description="안건 제목")
|
agenda_title: str = Field(..., description="안건 제목")
|
||||||
summary_short: str = Field(..., description="AI 생성 짧은 요약 (1줄, 20자 이내)")
|
summary_short: str = Field(..., description="AI 생성 짧은 요약 (1줄, 20자 이내)")
|
||||||
summary: str = Field(..., description="안건별 회의록 요약 (논의사항+결정사항, 사용자 수정 가능)")
|
summary: str = Field(..., description="안건별 회의록 요약 (논의사항+결정사항, 사용자 수정 가능)")
|
||||||
|
decisions: List[str] = Field(default_factory=list, description="안건별 결정사항 배열 (대시보드 표시용)")
|
||||||
pending: List[str] = Field(default_factory=list, description="보류 사항")
|
pending: List[str] = Field(default_factory=list, description="보류 사항")
|
||||||
todos: List[ExtractedTodo] = Field(default_factory=list, description="Todo 목록 (제목만)")
|
todos: List[ExtractedTodo] = Field(default_factory=list, description="Todo 목록 (제목만)")
|
||||||
|
|
||||||
|
|||||||
@ -49,13 +49,23 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
|||||||
- **agenda_number**: 안건 번호 (1, 2, 3...)
|
- **agenda_number**: 안건 번호 (1, 2, 3...)
|
||||||
- **agenda_title**: 안건 제목 (간결하게)
|
- **agenda_title**: 안건 제목 (간결하게)
|
||||||
- **summary_short**: AI가 생성한 1줄 요약 (20자 이내, 사용자 수정 불가)
|
- **summary_short**: AI가 생성한 1줄 요약 (20자 이내, 사용자 수정 불가)
|
||||||
- **summary**: 안건별 회의록 요약 (논의사항과 결정사항을 포함한 전체 요약)
|
- **summary**: 안건별 회의록 요약 (논의사항과 결정사항 모두 포함)
|
||||||
* 회의록 수정 페이지에서 사용자가 수정할 수 있는 입력 필드
|
* 회의록 수정 페이지에서 사용자가 수정할 수 있는 입력 필드
|
||||||
* 형식: "**논의 사항:**\n- 논의내용1\n- 논의내용2\n\n**결정 사항:**\n- 결정1\n- 결정2"
|
* 형식: "**논의 사항:**\n- 논의내용1\n- 논의내용2\n\n**결정 사항:**\n- 결정1\n- 결정2"
|
||||||
* 사용자가 자유롭게 편집할 수 있도록 구조화된 텍스트로 작성
|
* 사용자가 자유롭게 편집할 수 있도록 구조화된 텍스트로 작성
|
||||||
|
- **decisions**: 안건별 결정사항 배열 (대시보드 표시용, summary의 결정사항 부분을 배열로 추출)
|
||||||
|
* 형식: ["결정사항1", "결정사항2", "결정사항3"]
|
||||||
|
* 회의에서 최종 결정된 사항만 포함
|
||||||
- **pending**: 보류 사항 배열 (추가 논의 필요 사항)
|
- **pending**: 보류 사항 배열 (추가 논의 필요 사항)
|
||||||
- **todos**: Todo 배열 (제목만, 담당자/마감일/우선순위 없음)
|
- **todos**: Todo 배열 (제목과 담당자 추출)
|
||||||
- title: Todo 제목만 추출 (예: "시장 조사 보고서 작성")
|
- title: Todo 제목 (예: "시장 조사 보고서 작성")
|
||||||
|
- assignee: 담당자 이름 (있는 경우에만, 예: "김대리", "박과장")
|
||||||
|
|
||||||
|
**Todo 추출 가이드:**
|
||||||
|
- 자연스러운 표현도 인식: "김대리가 ~하기로 함", "박과장은 ~준비합니다", "이차장님께서 ~하시기로 하셨습니다"
|
||||||
|
- 실행 동사 패턴: ~하기로, ~준비, ~작성, ~제출, ~완료, ~진행, ~검토, ~분석
|
||||||
|
- 담당자 패턴: "OO님", "OO이/가", "OO은/는", "OO께서"
|
||||||
|
- 기한 표현: "다음주", "이번주", "~까지", "~일까지", "~월까지"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -77,10 +87,16 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
|||||||
"agenda_title": "안건 제목",
|
"agenda_title": "안건 제목",
|
||||||
"summary_short": "짧은 요약 (20자 이내)",
|
"summary_short": "짧은 요약 (20자 이내)",
|
||||||
"summary": "**논의 사항:**\\n- 논의내용1\\n- 논의내용2\\n\\n**결정 사항:**\\n- 결정1\\n- 결정2",
|
"summary": "**논의 사항:**\\n- 논의내용1\\n- 논의내용2\\n\\n**결정 사항:**\\n- 결정1\\n- 결정2",
|
||||||
|
"decisions": ["결정사항1", "결정사항2"],
|
||||||
"pending": ["보류사항"],
|
"pending": ["보류사항"],
|
||||||
"todos": [
|
"todos": [
|
||||||
{{
|
{{
|
||||||
"title": "Todo 제목"
|
"title": "인플루언서 리스트 작성",
|
||||||
|
"assignee": "김대리"
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"title": "캠페인 콘텐츠 기획안 초안 작성",
|
||||||
|
"assignee": "박과장"
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
@ -97,11 +113,13 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
|||||||
3. **완전성**: 모든 필드를 빠짐없이 작성
|
3. **완전성**: 모든 필드를 빠짐없이 작성
|
||||||
4. **구조화**: 안건별로 명확히 분리
|
4. **구조화**: 안건별로 명확히 분리
|
||||||
5. **결정사항 추출**:
|
5. **결정사항 추출**:
|
||||||
- 회의 전체 결정사항(decisions)은 모든 안건의 결정사항을 포함
|
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식)
|
||||||
- 안건별 summary에도 결정사항을 포함하여 사용자가 수정 가능하도록 작성
|
- 안건별 결정사항(agenda_summaries[].decisions): 각 안건의 결정사항을 배열로 추출
|
||||||
|
- 결정사항이 명확하게 언급된 경우에만 포함
|
||||||
6. **summary 작성**:
|
6. **summary 작성**:
|
||||||
- summary_short: AI가 자동 생성한 1줄 요약 (사용자 수정 불가)
|
- summary_short: AI가 자동 생성한 1줄 요약 (사용자 수정 불가)
|
||||||
- summary: 논의사항과 결정사항을 포함한 전체 요약 (사용자 수정 가능)
|
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능)
|
||||||
|
- decisions: summary의 결정사항 부분을 배열로 별도 추출 (대시보드 표시용)
|
||||||
7. **Todo 추출**: 제목만 추출 (담당자나 마감일 없어도 됨)
|
7. **Todo 추출**: 제목만 추출 (담당자나 마감일 없어도 됨)
|
||||||
8. **JSON만 출력**: 추가 설명 없이 JSON만 반환
|
8. **JSON만 출력**: 추가 설명 없이 JSON만 반환
|
||||||
|
|
||||||
|
|||||||
@ -96,6 +96,7 @@ class TranscriptService:
|
|||||||
agenda_title=agenda_data.get("agenda_title", ""),
|
agenda_title=agenda_data.get("agenda_title", ""),
|
||||||
summary_short=agenda_data.get("summary_short", ""),
|
summary_short=agenda_data.get("summary_short", ""),
|
||||||
summary=agenda_data.get("summary", ""),
|
summary=agenda_data.get("summary", ""),
|
||||||
|
decisions=agenda_data.get("decisions", []),
|
||||||
pending=agenda_data.get("pending", []),
|
pending=agenda_data.get("pending", []),
|
||||||
todos=todos
|
todos=todos
|
||||||
)
|
)
|
||||||
|
|||||||
@ -65,6 +65,10 @@ spec:
|
|||||||
key: eventhub-connection-string
|
key: eventhub-connection-string
|
||||||
- name: NOTIFICATION_SERVICE_URL
|
- name: NOTIFICATION_SERVICE_URL
|
||||||
value: "http://notification-service:8082"
|
value: "http://notification-service:8082"
|
||||||
|
- name: AI_SERVICE_URL
|
||||||
|
value: "http://ai-service:8087"
|
||||||
|
- name: AI_SERVICE_TIMEOUT
|
||||||
|
value: "60000"
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 256m
|
cpu: 256m
|
||||||
|
|||||||
@ -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.MinutesEntity;
|
||||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesSectionEntity;
|
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.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.MeetingAnalysisJpaRepository;
|
||||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingJpaRepository;
|
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.MinutesJpaRepository;
|
||||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesSectionJpaRepository;
|
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesSectionJpaRepository;
|
||||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.TodoJpaRepository;
|
import com.unicorn.hgzero.meeting.infra.gateway.repository.TodoJpaRepository;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.annotation.Primary;
|
import org.springframework.context.annotation.Primary;
|
||||||
@ -45,7 +48,9 @@ public class EndMeetingService implements EndMeetingUseCase {
|
|||||||
private final MinutesSectionJpaRepository minutesSectionRepository;
|
private final MinutesSectionJpaRepository minutesSectionRepository;
|
||||||
private final TodoJpaRepository todoRepository;
|
private final TodoJpaRepository todoRepository;
|
||||||
private final MeetingAnalysisJpaRepository analysisRepository;
|
private final MeetingAnalysisJpaRepository analysisRepository;
|
||||||
|
private final AgendaSectionJpaRepository agendaSectionRepository;
|
||||||
private final AIServiceClient aiServiceClient;
|
private final AIServiceClient aiServiceClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회의 종료 및 AI 분석 실행
|
* 회의 종료 및 AI 분석 실행
|
||||||
@ -58,17 +63,26 @@ public class EndMeetingService implements EndMeetingUseCase {
|
|||||||
public MeetingEndDTO endMeeting(String meetingId) {
|
public MeetingEndDTO endMeeting(String meetingId) {
|
||||||
log.info("회의 종료 시작 - meetingId: {}", meetingId);
|
log.info("회의 종료 시작 - meetingId: {}", meetingId);
|
||||||
|
|
||||||
|
try {
|
||||||
// 1. 회의 정보 조회
|
// 1. 회의 정보 조회
|
||||||
MeetingEntity meeting = meetingRepository.findById(meetingId)
|
MeetingEntity meeting = meetingRepository.findById(meetingId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("회의를 찾을 수 없습니다: " + meetingId));
|
.orElseThrow(() -> {
|
||||||
|
log.error("회의를 찾을 수 없음 - meetingId: {}", meetingId);
|
||||||
|
return new IllegalArgumentException("회의를 찾을 수 없습니다: " + meetingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("회의 정보 조회 완료 - title: {}, status: {}", meeting.getTitle(), meeting.getStatus());
|
||||||
|
|
||||||
// 2. 참석자별 회의록 조회 (userId가 있는 회의록들)
|
// 2. 참석자별 회의록 조회 (userId가 있는 회의록들)
|
||||||
List<MinutesEntity> participantMinutesList = minutesRepository.findByMeetingIdAndUserIdIsNotNull(meetingId);
|
List<MinutesEntity> participantMinutesList = minutesRepository.findByMeetingIdAndUserIdIsNotNull(meetingId);
|
||||||
|
|
||||||
if (participantMinutesList.isEmpty()) {
|
if (participantMinutesList.isEmpty()) {
|
||||||
|
log.error("참석자 회의록이 없음 - meetingId: {}", meetingId);
|
||||||
throw new IllegalStateException("참석자 회의록이 없습니다: " + meetingId);
|
throw new IllegalStateException("참석자 회의록이 없습니다: " + meetingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("참석자 회의록 조회 완료 - 참석자 수: {}", participantMinutesList.size());
|
||||||
|
|
||||||
// 3. 각 회의록의 sections 조회 및 통합
|
// 3. 각 회의록의 sections 조회 및 통합
|
||||||
List<MinutesSectionEntity> allMinutesSections = new ArrayList<>();
|
List<MinutesSectionEntity> allMinutesSections = new ArrayList<>();
|
||||||
for (MinutesEntity minutes : participantMinutesList) {
|
for (MinutesEntity minutes : participantMinutesList) {
|
||||||
@ -76,26 +90,46 @@ public class EndMeetingService implements EndMeetingUseCase {
|
|||||||
minutes.getMinutesId()
|
minutes.getMinutesId()
|
||||||
);
|
);
|
||||||
allMinutesSections.addAll(sections);
|
allMinutesSections.addAll(sections);
|
||||||
|
log.debug("회의록 섹션 조회 - minutesId: {}, userId: {}, 섹션 수: {}",
|
||||||
|
minutes.getMinutesId(), minutes.getUserId(), sections.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("전체 회의록 섹션 조회 완료 - 총 섹션 수: {}", allMinutesSections.size());
|
||||||
|
|
||||||
// 4. AI 통합 분석 요청 데이터 생성
|
// 4. AI 통합 분석 요청 데이터 생성
|
||||||
ConsolidateRequest request = createConsolidateRequest(meeting, allMinutesSections, participantMinutesList);
|
ConsolidateRequest request = createConsolidateRequest(meeting, allMinutesSections, participantMinutesList);
|
||||||
|
log.info("AI 통합 분석 요청 데이터 생성 완료 - participantMinutes 수: {}",
|
||||||
|
request.getParticipantMinutes().size());
|
||||||
|
|
||||||
// 5. AI Service 호출
|
// 5. AI Service 호출
|
||||||
|
log.info("AI Service 호출 시작...");
|
||||||
ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request);
|
ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request);
|
||||||
|
log.info("AI Service 호출 완료 - 안건 수: {}", aiResponse.getAgendaSummaries().size());
|
||||||
|
|
||||||
// 5. AI 분석 결과 저장
|
// 6. AI 분석 결과 저장
|
||||||
MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse);
|
MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse);
|
||||||
|
|
||||||
// 6. Todo 생성 및 저장
|
// 7. Todo 생성 및 저장
|
||||||
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
|
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
|
||||||
|
|
||||||
// 6. 회의 종료 처리
|
// 8. 회의 종료 처리
|
||||||
meeting.end();
|
meeting.end();
|
||||||
meetingRepository.save(meeting);
|
meetingRepository.save(meeting);
|
||||||
|
log.info("회의 상태 업데이트 완료 - status: {}", meeting.getStatus());
|
||||||
|
|
||||||
// 7. 응답 DTO 생성
|
// 9. 응답 DTO 생성
|
||||||
return createMeetingEndDTO(meeting, analysis, todos, participantMinutesList.size());
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -141,7 +175,7 @@ public class EndMeetingService implements EndMeetingUseCase {
|
|||||||
.title(summary.getAgendaTitle())
|
.title(summary.getAgendaTitle())
|
||||||
.aiSummaryShort(summary.getSummaryShort())
|
.aiSummaryShort(summary.getSummaryShort())
|
||||||
.discussion(summary.getSummary() != null ? summary.getSummary() : "")
|
.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())
|
.pending(summary.getPending() != null ? summary.getPending() : List.of())
|
||||||
.extractedTodos(summary.getTodos() != null
|
.extractedTodos(summary.getTodos() != null
|
||||||
? summary.getTodos().stream()
|
? summary.getTodos().stream()
|
||||||
@ -168,18 +202,61 @@ public class EndMeetingService implements EndMeetingUseCase {
|
|||||||
MeetingAnalysisEntity entity = MeetingAnalysisEntity.fromDomain(analysis);
|
MeetingAnalysisEntity entity = MeetingAnalysisEntity.fromDomain(analysis);
|
||||||
analysisRepository.save(entity);
|
analysisRepository.save(entity);
|
||||||
|
|
||||||
|
// AgendaSection 저장 (안건별 회의록)
|
||||||
|
saveAgendaSections(meeting.getMeetingId(), aiResponse);
|
||||||
|
|
||||||
log.info("AI 분석 결과 저장 완료 - analysisId: {}", analysis.getAnalysisId());
|
log.info("AI 분석 결과 저장 완료 - analysisId: {}", analysis.getAnalysisId());
|
||||||
|
|
||||||
return analysis;
|
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 생성 및 저장
|
* Todo 생성 및 저장
|
||||||
*/
|
*/
|
||||||
private List<TodoEntity> createAndSaveTodos(MeetingEntity meeting, ConsolidateResponse aiResponse, MeetingAnalysis analysis) {
|
private List<TodoEntity> createAndSaveTodos(MeetingEntity meeting, ConsolidateResponse aiResponse, MeetingAnalysis analysis) {
|
||||||
List<TodoEntity> todos = aiResponse.getAgendaSummaries().stream()
|
List<TodoEntity> todos = aiResponse.getAgendaSummaries().stream()
|
||||||
.<TodoEntity>flatMap(agenda -> {
|
.<TodoEntity>flatMap(agenda -> {
|
||||||
String agendaId = findAgendaIdByTitle(analysis, agenda.getAgendaTitle());
|
// agendaId는 향후 Todo와 안건 매핑에 사용될 수 있음 (현재는 사용하지 않음)
|
||||||
List<ExtractedTodoDTO> todoList = agenda.getTodos() != null ? agenda.getTodos() : List.of();
|
List<ExtractedTodoDTO> todoList = agenda.getTodos() != null ? agenda.getTodos() : List.of();
|
||||||
return todoList.stream()
|
return todoList.stream()
|
||||||
.<TodoEntity>map(todo -> TodoEntity.builder()
|
.<TodoEntity>map(todo -> TodoEntity.builder()
|
||||||
|
|||||||
@ -67,7 +67,7 @@ public class MeetingMemoService implements SaveMeetingMemoUseCase {
|
|||||||
// 4. 기존 메모 섹션 조회 또는 새로운 섹션 생성
|
// 4. 기존 메모 섹션 조회 또는 새로운 섹션 생성
|
||||||
MinutesSection memoSection = minutesSectionReader.findFirstByMinutesIdAndType(
|
MinutesSection memoSection = minutesSectionReader.findFirstByMinutesIdAndType(
|
||||||
minutes.getMinutesId(),
|
minutes.getMinutesId(),
|
||||||
"AI_MEMO"
|
"MEMO"
|
||||||
).orElseGet(() -> createNewMemoSection(minutes.getMinutesId()));
|
).orElseGet(() -> createNewMemoSection(minutes.getMinutesId()));
|
||||||
|
|
||||||
// 5. 메모 내용 업데이트 (기존 내용에 추가)
|
// 5. 메모 내용 업데이트 (기존 내용에 추가)
|
||||||
@ -81,7 +81,7 @@ public class MeetingMemoService implements SaveMeetingMemoUseCase {
|
|||||||
MinutesSection updatedSection = MinutesSection.builder()
|
MinutesSection updatedSection = MinutesSection.builder()
|
||||||
.sectionId(memoSection.getSectionId())
|
.sectionId(memoSection.getSectionId())
|
||||||
.minutesId(memoSection.getMinutesId())
|
.minutesId(memoSection.getMinutesId())
|
||||||
.type("AI_MEMO")
|
.type("MEMO")
|
||||||
.title("회의 메모")
|
.title("회의 메모")
|
||||||
.content(updatedContent)
|
.content(updatedContent)
|
||||||
.order(memoSection.getOrder())
|
.order(memoSection.getOrder())
|
||||||
@ -105,7 +105,7 @@ public class MeetingMemoService implements SaveMeetingMemoUseCase {
|
|||||||
return MinutesSection.builder()
|
return MinutesSection.builder()
|
||||||
.sectionId(generateSectionId())
|
.sectionId(generateSectionId())
|
||||||
.minutesId(minutesId)
|
.minutesId(minutesId)
|
||||||
.type("AI_MEMO")
|
.type("MEMO")
|
||||||
.title("회의 메모")
|
.title("회의 메모")
|
||||||
.content("")
|
.content("")
|
||||||
.order(maxOrder + 1)
|
.order(maxOrder + 1)
|
||||||
|
|||||||
@ -43,11 +43,10 @@ public class AIServiceClient {
|
|||||||
* @return 통합 요약 응답
|
* @return 통합 요약 응답
|
||||||
*/
|
*/
|
||||||
public ConsolidateResponse consolidateMinutes(ConsolidateRequest request) {
|
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 {
|
try {
|
||||||
String url = aiServiceUrl + "/api/transcripts/consolidate";
|
|
||||||
|
|
||||||
// HTTP 헤더 설정
|
// HTTP 헤더 설정
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
@ -55,6 +54,9 @@ public class AIServiceClient {
|
|||||||
// HTTP 요청 생성
|
// HTTP 요청 생성
|
||||||
HttpEntity<ConsolidateRequest> httpEntity = new HttpEntity<>(request, headers);
|
HttpEntity<ConsolidateRequest> httpEntity = new HttpEntity<>(request, headers);
|
||||||
|
|
||||||
|
log.debug("AI Service 요청 데이터 - participantMinutes 수: {}",
|
||||||
|
request.getParticipantMinutes() != null ? request.getParticipantMinutes().size() : 0);
|
||||||
|
|
||||||
// API 호출
|
// API 호출
|
||||||
ResponseEntity<ConsolidateResponse> response = restTemplate.postForEntity(
|
ResponseEntity<ConsolidateResponse> response = restTemplate.postForEntity(
|
||||||
url,
|
url,
|
||||||
@ -65,15 +67,30 @@ public class AIServiceClient {
|
|||||||
ConsolidateResponse result = response.getBody();
|
ConsolidateResponse result = response.getBody();
|
||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
|
log.error("AI Service 응답이 비어있습니다 - HTTP Status: {}", response.getStatusCode());
|
||||||
throw new RuntimeException("AI Service 응답이 비어있습니다");
|
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;
|
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) {
|
} 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);
|
throw new RuntimeException("AI 회의록 통합 처리 중 오류가 발생했습니다: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,10 +37,15 @@ public class AgendaSummaryDTO {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 안건별 회의록 요약
|
* 안건별 회의록 요약
|
||||||
* 사용자가 입력한 회의록 내용을 요약한 결과
|
* 사용자가 입력한 회의록 내용을 요약한 결과 (논의사항 + 결정사항)
|
||||||
*/
|
*/
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안건별 결정사항 배열 (대시보드 표시용)
|
||||||
|
*/
|
||||||
|
private List<String> decisions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 보류 사항
|
* 보류 사항
|
||||||
*/
|
*/
|
||||||
|
|||||||
17
tools/check-agenda-sections.sql
Normal file
17
tools/check-agenda-sections.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Check agenda_sections data for meeting-test
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
meeting_id,
|
||||||
|
agenda_number,
|
||||||
|
agenda_title,
|
||||||
|
ai_summary_short,
|
||||||
|
LENGTH(summary) as summary_length,
|
||||||
|
pending_items,
|
||||||
|
todos,
|
||||||
|
created_at
|
||||||
|
FROM agenda_sections
|
||||||
|
WHERE meeting_id = 'meeting-test'
|
||||||
|
ORDER BY agenda_number;
|
||||||
26
tools/cleanup-test-data.sql
Normal file
26
tools/cleanup-test-data.sql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Cleanup Test Data for meeting-test
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- 1. agenda_sections 삭제 (외래키 제약이 있을 수 있으므로 먼저)
|
||||||
|
DELETE FROM agenda_sections WHERE meeting_id = 'meeting-test';
|
||||||
|
|
||||||
|
-- 2. todos 삭제
|
||||||
|
DELETE FROM todos WHERE meeting_id = 'meeting-test';
|
||||||
|
|
||||||
|
-- 3. meeting_analysis 관련 삭제
|
||||||
|
DELETE FROM meeting_keywords WHERE analysis_id IN (SELECT analysis_id FROM meeting_analysis WHERE meeting_id = 'meeting-test');
|
||||||
|
DELETE FROM meeting_analysis WHERE meeting_id = 'meeting-test';
|
||||||
|
|
||||||
|
-- 4. minutes_sections 삭제
|
||||||
|
DELETE FROM minutes_sections WHERE minutes_id IN (SELECT minutes_id FROM minutes WHERE meeting_id = 'meeting-test');
|
||||||
|
|
||||||
|
-- 5. minutes 삭제
|
||||||
|
DELETE FROM minutes WHERE meeting_id = 'meeting-test';
|
||||||
|
|
||||||
|
-- 6. 확인
|
||||||
|
SELECT 'Cleanup completed' AS status;
|
||||||
|
SELECT COUNT(*) as remaining_minutes FROM minutes WHERE meeting_id = 'meeting-test';
|
||||||
|
SELECT COUNT(*) as remaining_sections FROM minutes_sections WHERE minutes_id IN (SELECT minutes_id FROM minutes WHERE meeting_id = 'meeting-test');
|
||||||
|
SELECT COUNT(*) as remaining_agenda FROM agenda_sections WHERE meeting_id = 'meeting-test';
|
||||||
|
SELECT COUNT(*) as remaining_todos FROM todos WHERE meeting_id = 'meeting-test';
|
||||||
238
tools/insert-test-data-final.sql
Normal file
238
tools/insert-test-data-final.sql
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Meeting Test Data for AI Service Testing (UPDATED with user_id)
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- 1. meeting-test 데이터가 있는지 확인
|
||||||
|
SELECT * FROM meetings WHERE meeting_id = 'meeting-test';
|
||||||
|
|
||||||
|
-- 2. 기존 meeting-test 관련 데이터 정리 (있다면)
|
||||||
|
DELETE FROM minutes_sections WHERE minutes_id IN (SELECT minutes_id FROM minutes WHERE meeting_id = 'meeting-test');
|
||||||
|
DELETE FROM minutes WHERE meeting_id = 'meeting-test';
|
||||||
|
|
||||||
|
-- 3. 참석자별 회의록(minutes) 생성 - user1
|
||||||
|
INSERT INTO minutes (
|
||||||
|
minutes_id,
|
||||||
|
meeting_id,
|
||||||
|
user_id,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
version,
|
||||||
|
created_by,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
'minutes-test-user1',
|
||||||
|
'meeting-test',
|
||||||
|
'user1',
|
||||||
|
'Q4 마케팅 전략 회의 - user1 작성',
|
||||||
|
'DRAFT',
|
||||||
|
1,
|
||||||
|
'user1',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. user1 회의록 섹션 - MEMO
|
||||||
|
INSERT INTO minutes_sections (
|
||||||
|
id,
|
||||||
|
section_id,
|
||||||
|
minutes_id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
"order",
|
||||||
|
verified,
|
||||||
|
locked,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'section-user1-1',
|
||||||
|
'section-user1-1',
|
||||||
|
'minutes-test-user1',
|
||||||
|
'MEMO',
|
||||||
|
'회의 안건',
|
||||||
|
'1. Q4 마케팅 캠페인 기획
|
||||||
|
2. 예산 배분 논의
|
||||||
|
3. 실행 일정 수립
|
||||||
|
|
||||||
|
[주요 논의 사항]
|
||||||
|
마케팅 캠페인에 대한 논의를 진행했습니다. 소셜 미디어 광고와 인플루언서 마케팅을 결합한 통합 캠페인을 제안했으며, 예산은 총 5천만원으로 책정하는 것이 적절하다는 의견이 나왔습니다. 실행 시기는 11월 초부터 12월 말까지로 설정하기로 했습니다.
|
||||||
|
|
||||||
|
김대리가 인플루언서 리스트 작성하기로 함, 다음주 금요일까지 완료 예정. 박과장은 캠페인 콘텐츠 기획안 초안을 이번주 내로 준비하기로 했습니다.
|
||||||
|
|
||||||
|
[결정 사항]
|
||||||
|
1. 소셜 미디어 + 인플루언서 통합 캠페인 실행
|
||||||
|
2. 예산: 5천만원 배정
|
||||||
|
3. 기간: 11월 초 ~ 12월 말',
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 8. 참석자별 회의록(minutes) 생성 - user2
|
||||||
|
INSERT INTO minutes (
|
||||||
|
minutes_id,
|
||||||
|
meeting_id,
|
||||||
|
user_id,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
version,
|
||||||
|
created_by,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
'minutes-test-user2',
|
||||||
|
'meeting-test',
|
||||||
|
'user2',
|
||||||
|
'Q4 마케팅 전략 회의 - user2 작성',
|
||||||
|
'DRAFT',
|
||||||
|
1,
|
||||||
|
'user2',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 9. user2 회의록 섹션 - MEMO
|
||||||
|
INSERT INTO minutes_sections (
|
||||||
|
id,
|
||||||
|
section_id,
|
||||||
|
minutes_id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
"order",
|
||||||
|
verified,
|
||||||
|
locked,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'section-user2-1',
|
||||||
|
'section-user2-1',
|
||||||
|
'minutes-test-user2',
|
||||||
|
'MEMO',
|
||||||
|
'회의 목표',
|
||||||
|
'Q4 마케팅 전략 수립 및 예산 확정
|
||||||
|
|
||||||
|
[논의 내용]
|
||||||
|
Q4 마케팅 전략으로 디지털 마케팅 강화 방안을 논의했습니다. 특히 인스타그램과 유튜브를 활용한 인플루언서 마케팅이 효과적일 것으로 판단됩니다. 타겟 연령층은 20-30대이며, 예산은 광고비 3천만원, 인플루언서 비용 2천만원으로 분배하는 것이 좋겠다는 의견이 있었습니다.
|
||||||
|
|
||||||
|
이대리가 경쟁사 소셜 미디어 분석 보고서를 다음 주 수요일까지 제출하기로 했고, 최주임은 광고 플랫폼 선정 및 견적 비교 자료를 월요일까지 준비합니다.
|
||||||
|
|
||||||
|
[결정 사항]
|
||||||
|
1. 인스타그램, 유튜브 중심 인플루언서 마케팅 진행
|
||||||
|
2. 타겟: 20-30대
|
||||||
|
3. 예산: 광고비 3천만원, 인플루언서 2천만원',
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 참석자별 회의록(minutes) 생성 - user3
|
||||||
|
INSERT INTO minutes (
|
||||||
|
minutes_id,
|
||||||
|
meeting_id,
|
||||||
|
user_id,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
version,
|
||||||
|
created_by,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
'minutes-test-user3',
|
||||||
|
'meeting-test',
|
||||||
|
'user3',
|
||||||
|
'Q4 마케팅 전략 회의 - user3 작성',
|
||||||
|
'DRAFT',
|
||||||
|
1,
|
||||||
|
'user3',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 14. user3 회의록 섹션 - MEMO
|
||||||
|
INSERT INTO minutes_sections (
|
||||||
|
id,
|
||||||
|
section_id,
|
||||||
|
minutes_id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
"order",
|
||||||
|
verified,
|
||||||
|
locked,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'section-user3-1',
|
||||||
|
'section-user3-1',
|
||||||
|
'minutes-test-user3',
|
||||||
|
'MEMO',
|
||||||
|
'안건',
|
||||||
|
'1. Q4 마케팅 목표 설정
|
||||||
|
2. 채널별 예산 배분
|
||||||
|
3. KPI 지표 정의
|
||||||
|
|
||||||
|
[회의 내용]
|
||||||
|
Q4 마케팅 목표는 브랜드 인지도 상승과 매출 증대로 설정. 예산 5천만원을 디지털 광고와 인플루언서 마케팅에 분배하며, 성과 측정을 위해 도달률, 참여율, 전환율을 KPI로 설정. ROI 목표는 150%
|
||||||
|
|
||||||
|
정차장님께서 예산 집행 계획서를 이번 주 목요일까지 작성하시기로 하셨습니다. KPI 대시보드 구축은 한팀장님이 담당하시고 11월 첫째 주까지 완료 예정입니다.',
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
-- 16. user3 회의록 섹션 - DECISION
|
||||||
|
INSERT INTO minutes_sections (
|
||||||
|
id,
|
||||||
|
section_id,
|
||||||
|
minutes_id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
"order",
|
||||||
|
verified,
|
||||||
|
locked,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'section-user3-3',
|
||||||
|
'section-user3-3',
|
||||||
|
'minutes-test-user3',
|
||||||
|
'DECISION',
|
||||||
|
'결정 사항',
|
||||||
|
'1. 목표: 브랜드 인지도 상승 + 매출 증대\n2. 예산: 총 5천만원 (디지털 광고 + 인플루언서)\n3. KPI: 도달률, 참여율, 전환율, ROI 150% 이상',
|
||||||
|
3,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- 18. 데이터 확인
|
||||||
|
SELECT 'Minutes 데이터:' AS info;
|
||||||
|
SELECT * FROM minutes WHERE meeting_id = 'meeting-test';
|
||||||
|
|
||||||
|
SELECT 'Minutes Sections 데이터:' AS info;
|
||||||
|
SELECT ms.section_id, ms.minutes_id, m.created_by, m.user_id, ms.type, ms.title,
|
||||||
|
LENGTH(ms.content) as content_length, ms."order"
|
||||||
|
FROM minutes_sections ms
|
||||||
|
JOIN minutes m ON ms.minutes_id = m.minutes_id
|
||||||
|
WHERE m.meeting_id = 'meeting-test'
|
||||||
|
ORDER BY m.created_by, ms."order";
|
||||||
Loading…
x
Reference in New Issue
Block a user