for merge

This commit is contained in:
djeon 2025-10-31 14:55:33 +09:00
commit aa2cbf54b4
26 changed files with 29442 additions and 1673 deletions

View File

@ -13,15 +13,14 @@ HGZero는 업무지식이 부족한 회의록 작성자도 누락 없이 정확
- **실시간 협업**: WebSocket 기반 실시간 회의록 편집 및 동기화
### 1.2 MVP 산출물
- **발표자료**: {발표자료 링크}
- **발표자료**: [AI 기반 회의록 작성 서비스](docs/(MVP)%20AI%20기반%20회의록%20작성%20서비스_v1.11.pdf)
- **설계결과**:
- [유저스토리](design/userstory.md)
- [논리 아키텍처](design/backend/logical/logical-architecture.md)
- [API 설계서](design/backend/api/API설계서.md)
- **Git Repo**:
- **메인**: https://gitea.cbiz.kubepia.net/shared-dg05-coffeeQuokka/hgzero.git
- **프론트엔드**: {프론트엔드 Repository 링크}
- **manifest**: {Manifest Repository 링크}
- **시연 동영상**: {시연 동영상 링크}
## 2. 시스템 아키텍처
@ -31,13 +30,13 @@ HGZero는 업무지식이 부족한 회의록 작성자도 누락 없이 정확
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend
│ React 18 + TypeScript
│ Frontend │
│ React 18 + TypeScript │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ NGINX Ingress
│ NGINX Ingress │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
@ -362,7 +361,7 @@ kubectl get ingress
- Notification Service: http://{INGRESS_URL}/notification/swagger-ui.html
#### 3) 로그인 테스트
- ID: user-005, user2@example.com
- ID: meeting-test
- PW: 8자리
## 5. 팀

View File

@ -15,7 +15,7 @@ class Settings(BaseSettings):
# Claude API
claude_api_key: str = "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"
claude_model: str = "claude-sonnet-4-5-20250929"
claude_max_tokens: int = 8192 # 4096 → 8192 증가 (더 많은 제안사항 생성 가능)
claude_max_tokens: int = 25000 # 회의록 통합을 위해 25000으로 증가
claude_temperature: float = 0.7
# Redis

View File

@ -19,7 +19,9 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
f"{i+1}. {agenda}" for i, agenda in enumerate(agendas)
])
prompt = f"""당신은 회의록 작성 전문가입니다. 여러 참석자가 작성한 회의록을 통합하여 정확하고 체계적인 회의록을 생성해주세요.
prompt = f"""당신은 회의록 작성 전문가이며 JSON 생성 전문가입니다. 여러 참석자가 작성한 회의록을 통합하여 정확하고 체계적인 회의록을 생성해주세요.
**매우 중요**: 응답은 반드시 유효한 JSON 형식이어야 합니다. 문자열 내의 모든 특수문자를 올바르게 이스케이프해야 합니다.
# 입력 데이터
@ -40,7 +42,11 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
3. **회의 전체 결정사항 (decisions)**:
- 회의 전체에서 최종 결정된 사항들을 TEXT 형식으로 정리
- 안건별 결정사항을 모두 포함하여 회의록 수정 페이지에서 사용자가 확인 수정할 있도록 작성
- 형식: "**안건1 결정사항:**\n- 결정1\n- 결정2\n\n**안건2 결정사항:**\n- 결정3"
- 형식: "안건1 결정사항:\n- 결정1\n- 결정2\n\n안건2 결정사항:\n- 결정3"
- **JSON 이스케이프 필수**:
* 큰따옴표(")는 반드시 제거하거나 작은따옴표(')로 대체
* 줄바꿈은 \\n으로 이스케이프
* 역슬래시(\\) \\\\ 이스케이프
4. **안건별 요약 (agenda_summaries)**:
회의 내용을 분석하여 안건별로 구조화:
@ -51,8 +57,9 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
- **summary_short**: AI가 생성한 1 요약 (20 이내, 사용자 수정 불가)
- **summary**: 안건별 회의록 요약 (논의사항과 결정사항 모두 포함)
* 회의록 수정 페이지에서 사용자가 수정할 있는 입력 필드
* 형식: "**논의 사항:**\n- 논의내용1\n- 논의내용2\n\n**결정 사항:**\n- 결정1\n- 결정2"
* 형식: "논의 사항:\n- 논의내용1\n- 논의내용2\n\n결정 사항:\n- 결정1\n- 결정2"
* 사용자가 자유롭게 편집할 있도록 구조화된 텍스트로 작성
* **JSON 이스케이프 필수**: 큰따옴표(")는 제거하거나 작은따옴표(')로 대체, 줄바꿈은 \\n으로 표현
- **decisions**: 안건별 결정사항 배열 (대시보드 표시용, summary의 결정사항 부분을 배열로 추출)
* 형식: ["결정사항1", "결정사항2", "결정사항3"]
* 회의에서 최종 결정된 사항만 포함
@ -71,22 +78,28 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
# 출력 형식
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요.
**매우 중요 - JSON 생성 규칙**:
1. JSON 외의 다른 텍스트는 절대 포함하지 마세요
2. 문자열 내부의 큰따옴표(")는 반드시 작은따옴표(')로 대체하세요
3. 문자열 내부의 줄바꿈은 반드시 \\n으로 이스케이프하세요
4. 역슬래시(\\) \\\\ 이스케이프하세요
5. 모든 문자열이 끝까지 올바르게 닫혀 있는지 확인하세요
6. 문자열 값이 유효한 JSON 문자열인지 검증하세요
```json
{{
"keywords": ["키워드1", "키워드2", "키워드3"],
"statistics": {{
"agendas_count": 숫자,
"todos_count": 숫자
"agendas_count": 2,
"todos_count": 3
}},
"decisions": "**안건1 결정사항:**\\n- 결정1\\n- 결정2\\n\\n**안건2 결정사항:**\\n- 결정3",
"decisions": "안건1 결정사항:\\n- 결정1\\n- 결정2\\n\\n안건2 결정사항:\\n- 결정3",
"agenda_summaries": [
{{
"agenda_number": 1,
"agenda_title": "안건 제목",
"summary_short": "짧은 요약 (20자 이내)",
"summary": "**논의 사항:**\\n- 논의내용1\\n- 논의내용2\\n\\n**결정 사항:**\\n- 결정1\\n- 결정2",
"summary_short": "짧은 요약",
"summary": "논의 사항:\\n- 논의내용1\\n- 논의내용2\\n\\n결정 사항:\\n- 결정1\\n- 결정2",
"decisions": ["결정사항1", "결정사항2"],
"pending": ["보류사항"],
"todos": [
@ -113,20 +126,27 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
3. **완전성**: 모든 필드를 빠짐없이 작성
4. **구조화**: 안건별로 명확히 분리
5. **결정사항 추출**:
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식)
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식, \\n 이스케이프 필수)
- 안건별 결정사항(agenda_summaries[].decisions): 안건의 결정사항을 배열로 추출
- 결정사항이 명확하게 언급된 경우에만 포함
6. **summary 작성**:
- summary_short: AI가 자동 생성한 1 요약 (사용자 수정 불가)
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능)
- summary_short: AI가 자동 생성한 1 요약 (사용자 수정 불가, 특수문자 이스케이프)
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능, \\n 이스케이프 필수)
- decisions: summary의 결정사항 부분을 배열로 별도 추출 (대시보드 표시용)
7. **Todo 추출**:
- 제목 필수, 담당자는 언급된 경우에만 추출
- 자연스러운 표현에서 추출: "김대리가 ~하기로 함" title: "~", assignee: "김대리"
- 담당자가 없으면 assignee: "" ( 문자열)
8. **JSON만 출력**: 추가 설명 없이 JSON만 반환
8. **JSON 형식 엄수 - 가장 중요**:
- 추가 설명, 주석, 서문 없이 JSON만 반환
- 문자열 큰따옴표(")는 작은따옴표(')로 대체
- 문자열 줄바꿈은 \\n으로 이스케이프
- 역슬래시는 \\\\ 이스케이프
- 모든 문자열을 올바르게 닫기
- 생성 유효한 JSON인지 자체 검증
이제 회의록들을 분석하여 통합 요약을 JSON 형식으로 생성해주세요.
**최종 지시**: 회의록들을 분석하여 **유효한 JSON 형식으로만** 통합 요약을 생성해주세요.
JSON 파싱 오류가 발생하지 않도록 모든 특수문자를 올바르게 이스케이프하세요.
"""
return prompt

View File

@ -43,7 +43,7 @@ class ClaudeService:
]
# API 호출
logger.info(f"Claude API 호출 시작 - Model: {self.model}")
logger.info(f"Claude API 호출 시작 - Model: {self.model}, Max Tokens: {self.max_tokens}")
if system_prompt:
response = self.client.messages.create(
@ -63,7 +63,12 @@ class ClaudeService:
# 응답 텍스트 추출
response_text = response.content[0].text
logger.info(f"Claude API 응답 수신 완료 - Tokens: {response.usage.input_tokens + response.usage.output_tokens}")
logger.info(
f"Claude API 응답 수신 완료 - "
f"Input Tokens: {response.usage.input_tokens}, "
f"Output Tokens: {response.usage.output_tokens}, "
f"Stop Reason: {response.stop_reason}"
)
# JSON 파싱
# ```json ... ``` 블록 제거
@ -72,13 +77,113 @@ class ClaudeService:
elif "```" in response_text:
response_text = response_text.split("```")[1].split("```")[0].strip()
# JSON 파싱 전 전처리: 제어 문자 및 문제 문자 정리
import re
# 탭 문자를 공백으로 변환
response_text = response_text.replace('\t', ' ')
# 연속된 공백을 하나로 축소 (JSON 문자열 내부는 제외)
# response_text = re.sub(r'\s+', ' ', response_text)
result = json.loads(response_text)
return result
except json.JSONDecodeError as e:
logger.error(f"JSON 파싱 실패: {e}")
logger.error(f"응답 텍스트: {response_text[:500]}...")
logger.error(f"응답 텍스트 전체 길이: {len(response_text)}")
logger.error(f"응답 텍스트 (처음 1000자): {response_text[:1000]}")
logger.error(f"응답 텍스트 (마지막 1000자): {response_text[-1000:]}")
# 전체 응답을 파일로 저장하여 디버깅
import os
from datetime import datetime
debug_dir = "/Users/jominseo/HGZero/ai-python/logs/debug"
os.makedirs(debug_dir, exist_ok=True)
debug_file = f"{debug_dir}/claude_response_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
with open(debug_file, 'w', encoding='utf-8') as f:
f.write(response_text)
logger.error(f"전체 응답을 파일로 저장: {debug_file}")
# JSON 파싱 재시도 전략
# 방법 1: 이스케이프되지 않은 개행 문자 처리
try:
import re
# JSON 문자열 내부의 이스케이프되지 않은 개행을 찾아 \\n으로 변환
# 패턴: 큰따옴표 내부에서 "\"로 시작하지 않는 개행
def fix_unescaped_newlines(text):
# 간단한 접근: 모든 실제 개행을 \\n으로 변환
# 단, JSON 구조의 개행 (객체/배열 사이)은 유지
in_string = False
escape_next = False
result = []
for char in text:
if escape_next:
result.append(char)
escape_next = False
continue
if char == '\\':
escape_next = True
result.append(char)
continue
if char == '"':
in_string = not in_string
result.append(char)
continue
if char == '\n':
if in_string:
# 문자열 내부의 개행은 \\n으로 변환
result.append('\\n')
else:
# JSON 구조의 개행은 유지
result.append(char)
else:
result.append(char)
return ''.join(result)
fixed_text = fix_unescaped_newlines(response_text)
result = json.loads(fixed_text)
logger.info("JSON 파싱 재시도 성공 (개행 문자 수정)")
return result
except Exception as e1:
logger.warning(f"개행 문자 수정 실패: {e1}")
# 방법 2: strict=False 옵션으로 파싱
try:
result = json.loads(response_text, strict=False)
logger.info("JSON 파싱 재시도 성공 (strict=False)")
return result
except Exception as e2:
logger.warning(f"strict=False 파싱 실패: {e2}")
# 방법 3: 마지막 닫는 괄호까지만 파싱 시도
try:
last_brace_idx = response_text.rfind('}')
if last_brace_idx > 0:
truncated_text = response_text[:last_brace_idx+1]
# 개행 수정도 적용
truncated_text = fix_unescaped_newlines(truncated_text)
result = json.loads(truncated_text, strict=False)
logger.info("JSON 파싱 재시도 성공 (잘린 부분 복구)")
return result
except Exception as e3:
logger.warning(f"잘린 부분 복구 실패: {e3}")
# 방법 4: 정규식으로 문제 문자 제거 후 재시도
try:
# 제어 문자 제거 (줄바꿈, 탭 제외)
cleaned = re.sub(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]', '', response_text)
result = json.loads(cleaned, strict=False)
logger.info("JSON 파싱 재시도 성공 (제어 문자 제거)")
return result
except Exception as e4:
logger.warning(f"제어 문자 제거 실패: {e4}")
raise ValueError(f"Claude API 응답을 JSON으로 파싱할 수 없습니다: {str(e)}")
except Exception as e:

File diff suppressed because one or more lines are too long

BIN
docs/aiHGZero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
docs/prototype.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because it is too large Load Diff

View File

@ -103,8 +103,8 @@ public class DashboardDTO {
.meetingId(meeting.getMeetingId())
.title(meeting.getTitle())
.startTime(meeting.getScheduledAt())
.endTime(null) // Meeting 도메인에 endTime이 없음
.location(null) // Meeting 도메인에 location이 없음
.endTime(meeting.getEndTime())
.location(meeting.getLocation())
.participantCount(meeting.getParticipants() != null ? meeting.getParticipants().size() : 0)
.status(meeting.getStatus())
.userRole(currentUserId.equals(meeting.getOrganizerId()) ? "CREATOR" : "PARTICIPANT")

View File

@ -41,5 +41,6 @@ public class MeetingEndDTO {
@Builder
public static class TodoSummaryDTO {
private final String title;
private final String assignee;
}
}

View File

@ -126,6 +126,12 @@ public class MinutesDTO {
*/
private final Integer participantCount;
/**
* 검증완료율 (작성중 상태일 때만 유효)
* 0-100 사이의
*/
private final Integer verificationRate;
/**
* 회의 정보
*/

View File

@ -31,6 +31,7 @@ import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@ -106,19 +107,25 @@ public class EndMeetingService implements EndMeetingUseCase {
ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request);
log.info("AI Service 호출 완료 - 안건 수: {}", aiResponse.getAgendaSummaries().size());
// 6. AI 분석 결과 저장
MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse);
// 6. 통합 회의록 생성 또는 조회
MinutesEntity consolidatedMinutes = getOrCreateConsolidatedMinutes(meeting);
// 7. Todo 생성 저장
// 7. 통합 회의록에 전체 결정사항 저장
saveConsolidatedDecisions(consolidatedMinutes, aiResponse.getDecisions());
// 8. AI 분석 결과 저장
MeetingAnalysis analysis = saveAnalysisResult(meeting, consolidatedMinutes, aiResponse);
// 9. Todo 생성 저장
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
// 8. 회의 종료 처리
// 10. 회의 종료 처리
meeting.end();
meetingRepository.save(meeting);
log.info("회의 상태 업데이트 완료 - status: {}", meeting.getStatus());
// 9. 응답 DTO 생성
MeetingEndDTO result = createMeetingEndDTO(meeting, analysis, todos, participantMinutesList.size());
// 9. 응답 DTO 생성 (AI 응답의 todos를 그대로 사용)
MeetingEndDTO result = createMeetingEndDTO(meeting, aiResponse, todos.size(), participantMinutesList.size());
log.info("회의 종료 처리 완료 - meetingId: {}, 안건 수: {}, Todo 수: {}",
meetingId, analysis.getAgendaAnalyses().size(), todos.size());
@ -164,10 +171,55 @@ public class EndMeetingService implements EndMeetingUseCase {
.build();
}
/**
* 통합 회의록 생성 또는 조회
* userId가 NULL인 회의록 = AI 통합 회의록
*/
private MinutesEntity getOrCreateConsolidatedMinutes(MeetingEntity meeting) {
// userId가 NULL인 회의록 찾기 (AI 통합 회의록)
List<MinutesEntity> existingList = minutesRepository
.findByMeetingIdAndUserIdIsNull(meeting.getMeetingId());
if (!existingList.isEmpty()) {
MinutesEntity existing = existingList.get(0);
log.debug("기존 통합 회의록 사용 - minutesId: {}", existing.getMinutesId());
return existing;
}
// 없으면 새로 생성
MinutesEntity consolidatedMinutes = MinutesEntity.builder()
.minutesId(UUID.randomUUID().toString())
.meetingId(meeting.getMeetingId())
.userId(null) // NULL = AI 통합 회의록
.title(meeting.getTitle() + " - AI 통합 회의록")
.status("FINALIZED")
.version(1)
.createdBy("AI")
.build();
MinutesEntity saved = minutesRepository.save(consolidatedMinutes);
log.info("통합 회의록 생성 완료 - minutesId: {}", saved.getMinutesId());
return saved;
}
/**
* 통합 회의록에 전체 결정사항 저장
*/
private void saveConsolidatedDecisions(MinutesEntity minutes, String decisions) {
if (decisions != null && !decisions.trim().isEmpty()) {
minutes.updateDecisions(decisions);
minutesRepository.save(minutes);
log.info("Minutes에 전체 결정사항 저장 완료 - minutesId: {}, 길이: {}",
minutes.getMinutesId(), decisions.length());
} else {
log.warn("저장할 결정사항이 없음 - minutesId: {}", minutes.getMinutesId());
}
}
/**
* AI 분석 결과 저장
*/
private MeetingAnalysis saveAnalysisResult(MeetingEntity meeting, ConsolidateResponse aiResponse) {
private MeetingAnalysis saveAnalysisResult(MeetingEntity meeting, MinutesEntity consolidatedMinutes, ConsolidateResponse aiResponse) {
// AgendaAnalysis 리스트 생성
List<MeetingAnalysis.AgendaAnalysis> agendaAnalyses = aiResponse.getAgendaSummaries().stream()
.<MeetingAnalysis.AgendaAnalysis>map(summary -> MeetingAnalysis.AgendaAnalysis.builder()
@ -203,7 +255,7 @@ public class EndMeetingService implements EndMeetingUseCase {
analysisRepository.save(entity);
// AgendaSection 저장 (안건별 회의록)
saveAgendaSections(meeting.getMeetingId(), aiResponse);
saveAgendaSections(meeting.getMeetingId(), consolidatedMinutes.getMinutesId(), aiResponse);
log.info("AI 분석 결과 저장 완료 - analysisId: {}", analysis.getAnalysisId());
@ -213,7 +265,7 @@ public class EndMeetingService implements EndMeetingUseCase {
/**
* AgendaSection 저장
*/
private void saveAgendaSections(String meetingId, ConsolidateResponse aiResponse) {
private void saveAgendaSections(String meetingId, String minutesId, ConsolidateResponse aiResponse) {
int agendaNumber = 1;
for (var summary : aiResponse.getAgendaSummaries()) {
@ -229,7 +281,7 @@ public class EndMeetingService implements EndMeetingUseCase {
AgendaSectionEntity agendaSection = AgendaSectionEntity.builder()
.id(UUID.randomUUID().toString())
.minutesId(meetingId) // AI 통합 회의록 ID 사용
.minutesId(minutesId) // 통합 회의록 ID
.meetingId(meetingId)
.agendaNumber(agendaNumber++)
.agendaTitle(summary.getAgendaTitle())
@ -247,7 +299,8 @@ public class EndMeetingService implements EndMeetingUseCase {
}
}
log.info("AgendaSection 저장 완료 - meetingId: {}, count: {}", meetingId, aiResponse.getAgendaSummaries().size());
log.info("AgendaSection 저장 완료 - meetingId: {}, minutesId: {}, count: {}",
meetingId, minutesId, aiResponse.getAgendaSummaries().size());
}
/**
@ -256,7 +309,8 @@ public class EndMeetingService implements EndMeetingUseCase {
private List<TodoEntity> createAndSaveTodos(MeetingEntity meeting, ConsolidateResponse aiResponse, MeetingAnalysis analysis) {
List<TodoEntity> todos = aiResponse.getAgendaSummaries().stream()
.<TodoEntity>flatMap(agenda -> {
// agendaId는 향후 Todo와 안건 매핑에 사용될 있음 (현재는 사용하지 않음)
// 안건 번호를 description에 저장하여 나중에 필터링에 사용
Integer agendaNumber = agenda.getAgendaNumber();
List<ExtractedTodoDTO> todoList = agenda.getTodos() != null ? agenda.getTodos() : List.of();
return todoList.stream()
.<TodoEntity>map(todo -> TodoEntity.builder()
@ -264,6 +318,7 @@ public class EndMeetingService implements EndMeetingUseCase {
.meetingId(meeting.getMeetingId())
.minutesId(meeting.getMeetingId()) // 실제로는 minutesId 필요
.title(todo.getTitle())
.description("안건" + agendaNumber) // 안건 번호를 description에 임시 저장
.assigneeId(todo.getAssignee() != null ? todo.getAssignee() : "") // AI가 추출한 담당자
.status("PENDING")
.build());
@ -290,33 +345,36 @@ public class EndMeetingService implements EndMeetingUseCase {
}
/**
* 회의 종료 결과 DTO 생성
* 회의 종료 결과 DTO 생성 (AI 응답 직접 사용)
*/
private MeetingEndDTO createMeetingEndDTO(MeetingEntity meeting, MeetingAnalysis analysis,
List<TodoEntity> todos, int participantCount) {
private MeetingEndDTO createMeetingEndDTO(MeetingEntity meeting, ConsolidateResponse aiResponse,
int todoCount, int participantCount) {
// 회의 소요 시간 계산
int durationMinutes = calculateDurationMinutes(meeting.getStartedAt(), meeting.getEndedAt());
// 안건별 요약 DTO 생성
List<MeetingEndDTO.AgendaSummaryDTO> agendaSummaries = analysis.getAgendaAnalyses().stream()
// AI 응답의 안건 정보를 그대로 DTO로 변환 (todos 포함)
List<MeetingEndDTO.AgendaSummaryDTO> agendaSummaries = aiResponse.getAgendaSummaries().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()
// 안건별 todos 변환
List<MeetingEndDTO.TodoSummaryDTO> todoList = new ArrayList<>();
if (agenda.getTodos() != null) {
for (ExtractedTodoDTO todo : agenda.getTodos()) {
todoList.add(MeetingEndDTO.TodoSummaryDTO.builder()
.title(todo.getTitle())
.build())
.collect(Collectors.toList());
.assignee(todo.getAssignee())
.build());
}
}
return MeetingEndDTO.AgendaSummaryDTO.builder()
.title(agenda.getTitle())
.aiSummaryShort(agenda.getAiSummaryShort())
.title(agenda.getAgendaTitle())
.aiSummaryShort(agenda.getSummaryShort())
.details(MeetingEndDTO.AgendaDetailsDTO.builder()
.discussion(agenda.getDiscussion())
.discussion(agenda.getSummary())
.decisions(agenda.getDecisions())
.pending(agenda.getPending())
.build())
.todos(agendaTodos)
.todos(todoList)
.build();
})
.collect(Collectors.toList());
@ -325,9 +383,9 @@ public class EndMeetingService implements EndMeetingUseCase {
.title(meeting.getTitle())
.participantCount(participantCount)
.durationMinutes(durationMinutes)
.agendaCount(analysis.getAgendaAnalyses().size())
.todoCount(todos.size())
.keywords(analysis.getKeywords())
.agendaCount(aiResponse.getAgendaSummaries().size())
.todoCount(todoCount)
.keywords(aiResponse.getKeywords())
.agendaSummaries(agendaSummaries)
.build();
}

View File

@ -240,20 +240,21 @@ public class MinutesService implements
/**
* 사용자 ID로 회의록 목록 조회 (페이징)
* 사용자가 생성했거나 참여한 회의의 회의록을 모두 조회
*/
@Transactional(readOnly = true)
public Page<MinutesDTO> getMinutesListByUserId(String userId, Pageable pageable) {
log.debug("Getting minutes list by userId: {}", userId);
// 여기서는 임시로 작성자 기준으로 조회 (실제로는 참석자나 권한 기반으로 조회해야 )
List<Minutes> minutesList = minutesReader.findByCreatedBy(userId);
// 사용자가 생성했거나 참여한 회의의 회의록 조회
List<Minutes> minutesList = minutesReader.findByParticipantUserId(userId);
// Minutes를 MinutesDTO로 변환
List<MinutesDTO> minutesDTOList = minutesList.stream()
.map(this::convertToMinutesDTO)
.collect(Collectors.toList());
// 페이징 처리 (임시로 전체 목록 반환)
// 페이징 처리
int start = (int) pageable.getOffset();
int end = Math.min((start + pageable.getPageSize()), minutesDTOList.size());
List<MinutesDTO> pageContent = minutesDTOList.subList(start, end);
@ -371,6 +372,15 @@ public class MinutesService implements
log.warn("섹션 정보 변환 실패 - minutesId: {}", minutes.getMinutesId(), e);
}
// 검증완료율 계산 (작성중 상태일 때만)
Integer verificationRate = null;
if ("DRAFT".equals(minutes.getStatus()) && sectionDTOs != null && !sectionDTOs.isEmpty()) {
long verifiedCount = sectionDTOs.stream()
.filter(section -> Boolean.TRUE.equals(section.getIsVerified()))
.count();
verificationRate = (int) ((verifiedCount * 100) / sectionDTOs.size());
}
// decisions 로깅
log.info("Minutes decisions 값 확인 - minutesId: {}, decisions: {}",
minutes.getMinutesId(), minutes.getDecisions());
@ -389,6 +399,7 @@ public class MinutesService implements
.todoCount(todoCount)
.completedTodoCount(completedTodoCount)
.participantCount(participantCount)
.verificationRate(verificationRate)
.memo("") // 메모 필드는 추후 구현
.sections(sectionDTOs) // 섹션 정보 추가
.decisions(minutes.getDecisions()) // decisions 필드 추가

View File

@ -56,4 +56,13 @@ public interface MinutesReader {
* @return AI 통합 회의록
*/
Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId);
/**
* 사용자가 참여한 회의의 회의록 목록 조회
* 사용자가 생성했거나 참여한 회의의 회의록을 모두 조회
*
* @param userId 사용자 ID
* @return 회의록 목록
*/
List<Minutes> findByParticipantUserId(String userId);
}

View File

@ -3,15 +3,12 @@ package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.domain.Minutes;
import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
import com.unicorn.hgzero.meeting.biz.domain.Todo;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
import com.unicorn.hgzero.meeting.biz.service.MeetingService;
import com.unicorn.hgzero.meeting.biz.service.MinutesService;
import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService;
import com.unicorn.hgzero.meeting.biz.service.TodoService;
import com.unicorn.hgzero.meeting.biz.service.AgendaSectionService;
import com.unicorn.hgzero.meeting.infra.dto.request.UpdateMinutesRequest;
import com.unicorn.hgzero.meeting.infra.dto.request.UpdateAgendaSectionsRequest;
@ -21,7 +18,6 @@ import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import com.unicorn.hgzero.meeting.infra.event.publisher.EventPublisher;
import com.unicorn.hgzero.meeting.infra.gateway.AiServiceGateway;
import com.unicorn.hgzero.meeting.biz.dto.AiAnalysisDTO;
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesAnalysisRequestEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesFinalizedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesSectionDTO;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
@ -40,17 +36,10 @@ import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@ -69,7 +58,6 @@ public class MinutesController {
private final CacheService cacheService;
private final EventPublisher eventPublisher;
private final MeetingService meetingService;
private final TodoService todoService;
private final AiServiceGateway aiServiceGateway;
private final AgendaSectionService agendaSectionService;
private final ParticipantReader participantReader;
@ -110,15 +98,19 @@ public class MinutesController {
// DTO를 Response 형식으로 변환
List<MinutesListResponse.MinutesItem> minutesList = minutesPage.getContent().stream()
.map(this::convertToMinutesItem)
.map(dto -> convertToMinutesItem(dto, userId))
.collect(Collectors.toList());
// 필터링 적용 (상태별)
// 필터링 적용 (상태별, 참여 유형별, 검색어)
List<MinutesListResponse.MinutesItem> filteredMinutes = minutesList.stream()
.filter(item -> filterByStatus(item, status))
.filter(item -> filterByParticipationType(item, participationType))
.filter(item -> filterBySearch(item, search))
.collect(Collectors.toList());
// 필터링 정렬 적용 (프론트엔드 정렬과 일치)
applySorting(filteredMinutes, sortBy, sortDir);
// 통계 계산 (전체 데이터 기준)
MinutesListResponse.Statistics stats = calculateRealStatistics(userId, participationType);
@ -477,7 +469,8 @@ public class MinutesController {
case "title":
return Sort.by(direction, "title");
case "meeting":
return Sort.by(direction, "createdAt"); // 회의 일시로 정렬 (임시로 생성일시 사용)
// 회의 일시로 정렬 - DB에 meetingDate 필드가 없으므로 createdAt 사용
return Sort.by(direction, "createdAt");
case "modified":
default:
return Sort.by(direction, "lastModifiedAt");
@ -489,8 +482,21 @@ public class MinutesController {
*/
private MinutesListResponse.Statistics calculateRealStatistics(String userId, String participationType) {
try {
// 전체 회의록 조회 (작성자 기준)
List<Minutes> allMinutes = minutesService.getMinutesByCreator(userId);
// 전체 회의록 조회 (참여자 기준)
List<MinutesDTO> allMinutes = minutesService.getMinutesListByUserId(userId, PageRequest.of(0, Integer.MAX_VALUE)).getContent();
// 참여 유형 필터링
if (participationType != null && !participationType.isEmpty()) {
if ("created".equals(participationType)) {
allMinutes = allMinutes.stream()
.filter(m -> userId.equals(m.getCreatedBy()))
.collect(Collectors.toList());
} else if ("attended".equals(participationType)) {
allMinutes = allMinutes.stream()
.filter(m -> !userId.equals(m.getCreatedBy()))
.collect(Collectors.toList());
}
}
long totalCount = allMinutes.size();
long draftCount = allMinutes.stream()
@ -515,10 +521,39 @@ public class MinutesController {
}
}
private MinutesListResponse.MinutesItem convertToMinutesItem(MinutesDTO minutesDTO) {
// 완료율 계산
int completionRate = minutesDTO.getTodoCount() > 0 ?
(minutesDTO.getCompletedTodoCount() * 100) / minutesDTO.getTodoCount() : 100;
private MinutesListResponse.MinutesItem convertToMinutesItem(MinutesDTO minutesDTO, String userId) {
// 검증완료율 계산 (작성중 상태일 때는 verificationRate 사용, 확정완료시 100%)
int verificationRate;
if ("DRAFT".equals(minutesDTO.getStatus()) && minutesDTO.getVerificationRate() != null) {
verificationRate = minutesDTO.getVerificationRate();
} else if ("FINALIZED".equals(minutesDTO.getStatus())) {
verificationRate = 100;
} else {
// 기본값 0
verificationRate = 0;
}
// 회의 날짜/시간 추출
LocalDateTime meetingDateTime = minutesDTO.getCreatedAt(); // 임시로 생성일시 사용
String meetingTime = null;
// 실제 회의 정보에서 날짜/시간 추출 시도
try {
var meeting = meetingService.getMeeting(minutesDTO.getMeetingId());
if (meeting.getScheduledAt() != null) {
meetingDateTime = meeting.getScheduledAt();
meetingTime = meeting.getScheduledAt().toLocalTime().toString().substring(0, 5);
} else if (meeting.getStartedAt() != null) {
meetingDateTime = meeting.getStartedAt();
meetingTime = meeting.getStartedAt().toLocalTime().toString().substring(0, 5);
}
} catch (Exception e) {
log.debug("회의 정보 조회 실패, 기본값 사용 - meetingId: {}", minutesDTO.getMeetingId());
meetingTime = meetingDateTime.toLocalTime().toString().substring(0, 5);
}
// 생성자 이름 조회 (임시)
String creatorName = getUserName(minutesDTO.getCreatedBy());
return MinutesListResponse.MinutesItem.builder()
.minutesId(minutesDTO.getMinutesId())
@ -528,14 +563,16 @@ public class MinutesController {
.version(minutesDTO.getVersion())
.createdAt(minutesDTO.getCreatedAt())
.lastModifiedAt(minutesDTO.getLastModifiedAt())
.meetingDate(minutesDTO.getCreatedAt()) // 임시로 생성일시 사용
.meetingDate(meetingDateTime)
.meetingTime(meetingTime)
.createdBy(minutesDTO.getCreatedBy())
.createdByName(creatorName)
.lastModifiedBy(minutesDTO.getLastModifiedBy())
.participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 0)
.todoCount(minutesDTO.getTodoCount())
.completedTodoCount(minutesDTO.getCompletedTodoCount())
.completionRate(completionRate)
.isCreatedByUser(true) // 현재는 작성자 기준으로만 조회하므로 true
.verificationRate(verificationRate)
.isCreator(minutesDTO.getCreatedBy().equals(userId)) // 현재 사용자가 생성자인지 확인
.build();
}
@ -556,10 +593,19 @@ public class MinutesController {
}
/**
* 참여 유형별 필터링 - 현재는 사용하지 않음 (작성자 기준으로만 조회)
* 참여 유형별 필터링
*/
private boolean filterByParticipationType(MinutesListResponse.MinutesItem item, String participationType, String userId) {
// 현재는 작성자 기준으로만 조회하므로 항상 true 반환
private boolean filterByParticipationType(MinutesListResponse.MinutesItem item, String participationType) {
if (participationType == null || participationType.isEmpty()) {
return true; // 필터 미적용시 모두 표시
}
if ("created".equals(participationType)) {
return item.isCreator(); // 사용자가 생성한 회의록만
} else if ("attended".equals(participationType)) {
return !item.isCreator(); // 사용자가 참여만 회의록
}
return true;
}
@ -576,7 +622,7 @@ public class MinutesController {
}
/**
* 정렬 적용
* 정렬 적용 (필터링 )
*/
private void applySorting(List<MinutesListResponse.MinutesItem> items, String sortBy, String sortDir) {
boolean ascending = "asc".equalsIgnoreCase(sortDir);
@ -588,12 +634,14 @@ public class MinutesController {
b.getTitle().compareTo(a.getTitle()));
break;
case "meeting":
// 회의 날짜순 정렬 (최근회의순은 desc가 기본)
items.sort((a, b) -> ascending ?
a.getMeetingDate().compareTo(b.getMeetingDate()) :
b.getMeetingDate().compareTo(a.getMeetingDate()));
break;
case "modified":
default:
// 최근수정순 정렬 (desc가 기본)
items.sort((a, b) -> ascending ?
a.getLastModifiedAt().compareTo(b.getLastModifiedAt()) :
b.getLastModifiedAt().compareTo(a.getLastModifiedAt()));
@ -601,66 +649,6 @@ public class MinutesController {
}
}
/**
* 통계 계산
*/
private MinutesListResponse.Statistics calculateStatistics(List<MinutesListResponse.MinutesItem> allItems,
String participationType, String userId) {
List<MinutesListResponse.MinutesItem> filteredItems = allItems.stream()
.filter(item -> filterByParticipationType(item, participationType, userId))
.collect(Collectors.toList());
long totalCount = filteredItems.size();
long draftCount = filteredItems.stream()
.filter(item -> "DRAFT".equals(item.getStatus()))
.count();
long completeCount = filteredItems.stream()
.filter(item -> "FINALIZED".equals(item.getStatus()))
.count();
return MinutesListResponse.Statistics.builder()
.totalCount(totalCount)
.draftCount(draftCount)
.completeCount(completeCount)
.build();
}
/**
* Mock 관련회의록 생성 (프로토타입 기반)
*/
private List<MinutesDetailResponse.RelatedMinutes> createMockRelatedMinutes() {
return List.of(
MinutesDetailResponse.RelatedMinutes.builder()
.minutesId("minutes-related-001")
.title("AI 기능 개선 회의")
.meetingDate(LocalDateTime.of(2025, 10, 23, 15, 0))
.author("이준호")
.relevancePercentage(92)
.relevanceLevel("HIGH")
.summary("AI 요약 정확도 개선 방안 논의. BERT 모델 도입 및 학습 데이터 확보 계획 수립.")
.build(),
MinutesDetailResponse.RelatedMinutes.builder()
.minutesId("minutes-related-002")
.title("개발 리소스 계획 회의")
.meetingDate(LocalDateTime.of(2025, 10, 22, 11, 0))
.author("김민준")
.relevancePercentage(88)
.relevanceLevel("MEDIUM")
.summary("Q4 개발 리소스 현황 및 배분 계획. 신규 프로젝트 우선순위 협의.")
.build(),
MinutesDetailResponse.RelatedMinutes.builder()
.minutesId("minutes-related-003")
.title("경쟁사 분석 회의")
.meetingDate(LocalDateTime.of(2025, 10, 20, 10, 0))
.author("박서연")
.relevancePercentage(78)
.relevanceLevel("MEDIUM")
.summary("경쟁사 A, B, C 분석 결과. 우리의 차별점은 실시간 협업 및 검증 기능.")
.build()
);
}
private MinutesDetailResponse convertToMinutesDetailResponse(MinutesDTO minutesDTO) {
try {
// 실제 회의 정보 조회
@ -921,11 +909,6 @@ public class MinutesController {
.build();
}
private int calculateActualDuration(Object meeting) {
// TODO: 실제 회의 시간 계산 로직 구현
// Meeting 객체에서 startedAt, endedAt을 사용하여 계산
return 90;
}
private MinutesDetailResponse.AgendaInfo convertToAgendaInfo(Object section) {
if (!(section instanceof MinutesSection)) {
@ -959,38 +942,6 @@ public class MinutesController {
private MinutesDetailResponse.SimpleTodo convertToSimpleTodo(Object todo) {
if (!(todo instanceof Todo)) {
log.warn("Todo가 아닌 객체가 전달됨: {}", todo.getClass().getSimpleName());
return MinutesDetailResponse.SimpleTodo.builder()
.todoId("unknown-todo")
.title("변환 실패 Todo")
.assigneeName("알 수 없음")
.status("PENDING")
.priority("LOW")
.dueDate(LocalDateTime.now().plusDays(7))
.dueDayStatus("D-7")
.build();
}
Todo todoEntity = (Todo) todo;
// 담당자 이름 조회 (현재는 기본값 사용, 실제로는 User 서비스에서 조회 필요)
String assigneeName = getAssigneeName(todoEntity.getAssigneeId());
// 마감일 상태 계산
String dueDayStatus = calculateDueDayStatus(todoEntity.getDueDate(), todoEntity.getStatus());
return MinutesDetailResponse.SimpleTodo.builder()
.todoId(todoEntity.getTodoId())
.title(todoEntity.getTitle() != null ? todoEntity.getTitle() : "제목 없음")
.assigneeName(assigneeName)
.status(todoEntity.getStatus() != null ? todoEntity.getStatus() : "PENDING")
.priority(todoEntity.getPriority() != null ? todoEntity.getPriority() : "MEDIUM")
.dueDate(todoEntity.getDueDate() != null ? todoEntity.getDueDate().atStartOfDay() : null)
.dueDayStatus(dueDayStatus)
.build();
}
private List<MinutesDetailResponse.KeyPoint> extractKeyPoints(List<MinutesDetailResponse.AgendaInfo> agendas) {
// 안건별 AI 요약에서 핵심내용 추출
@ -1014,51 +965,6 @@ public class MinutesController {
}
/**
* 담당자 이름 조회 (실제로는 User 서비스에서 조회 필요)
*/
private String getAssigneeName(String assigneeId) {
if (assigneeId == null) {
return "미지정";
}
// TODO: 실제 User 서비스에서 사용자 정보 조회
// 현재는 간단한 매핑 사용
switch (assigneeId) {
case "user1":
return "김민준";
case "user2":
return "박서연";
case "user3":
return "이준호";
default:
return "사용자" + assigneeId;
}
}
/**
* 마감일 상태 계산
*/
private String calculateDueDayStatus(LocalDate dueDate, String status) {
if (dueDate == null) {
return "마감일 없음";
}
if ("COMPLETED".equals(status)) {
return "완료";
}
LocalDate today = LocalDate.now();
long daysDiff = ChronoUnit.DAYS.between(today, dueDate);
if (daysDiff < 0) {
return "D+" + Math.abs(daysDiff); // 마감일 지남
} else if (daysDiff == 0) {
return "D-Day";
} else {
return "D-" + daysDiff;
}
}
private List<String> extractKeywords(List<MinutesDetailResponse.AgendaInfo> agendas) {
// TODO: AI를 통한 키워드 추출 로직 구현
@ -1231,41 +1137,6 @@ public class MinutesController {
.collect(Collectors.toList());
}
/**
* AI 분석 요청 이벤트 발행
*/
private void publishAiAnalysisRequest(MinutesDTO minutesDTO, String requesterId, String requesterName) {
try {
// 회의 메타정보 구성
MinutesAnalysisRequestEvent.MeetingMeta meetingMeta = MinutesAnalysisRequestEvent.MeetingMeta.builder()
.title(minutesDTO.getMeetingTitle())
.meetingDate(minutesDTO.getCreatedAt())
.participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 1)
.durationMinutes(90) // 기본값
.organizerId(minutesDTO.getCreatedBy())
.participantIds(new String[]{requesterId}) // 기본값
.build();
// AI 분석 요청 이벤트 생성
MinutesAnalysisRequestEvent requestEvent = MinutesAnalysisRequestEvent.create(
minutesDTO.getMinutesId(),
minutesDTO.getMeetingId(),
requesterId,
requesterName,
extractContentForAiAnalysis(minutesDTO),
meetingMeta
);
// 이벤트 발행
eventPublisher.publishMinutesAnalysisRequest(requestEvent);
log.info("AI 분석 요청 이벤트 발행 완료 - minutesId: {}, eventId: {}",
minutesDTO.getMinutesId(), requestEvent.getEventId());
} catch (Exception e) {
log.error("AI 분석 요청 이벤트 발행 실패 - minutesId: {}", minutesDTO.getMinutesId(), e);
}
}
/**
* 실제 회의 시간 계산

View File

@ -46,12 +46,32 @@ public class MinutesListResponse {
private LocalDateTime createdAt;
private LocalDateTime lastModifiedAt;
private LocalDateTime meetingDate; // 회의 일시
private String meetingTime; // 회의 시간 (HH:mm 형식)
private String createdBy;
private String createdByName; // 생성자 이름
private String lastModifiedBy;
private int participantCount; // 참석자
private int todoCount;
private int completedTodoCount;
private int completionRate; // 검증완료율
private boolean isCreatedByUser; // 사용자가 생성한 회의록 여부
private int verificationRate; // 검증완료율 (프로토타입과 일치)
private boolean isCreator; // 현재 사용자가 생성자인지 여부
// 편의 메서드 추가
public String getFormattedDate() {
if (meetingDate != null) {
return meetingDate.toLocalDate().toString();
}
return "";
}
public String getFormattedTime() {
if (meetingTime != null) {
return meetingTime;
}
if (meetingDate != null) {
return meetingDate.toLocalTime().toString().substring(0, 5);
}
return "";
}
}
}

View File

@ -18,8 +18,10 @@ import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@ -44,7 +46,7 @@ public class DashboardGateway implements DashboardReader {
// 1. 다가오는 회의 목록 조회 (향후 30일, 최대 10개)
List<Meeting> upcomingMeetings = getUpcomingMeetings(userId);
// 2. 최근 회의록 목록 조회 (최근 7일, 최대 10)
// 2. 최근 회의록 목록 조회 (최근 30일, 최대 4)
List<Minutes> recentMinutes = getRecentMinutes(userId);
// 3. 통계 정보 계산 (최근 30일 기준)
@ -73,11 +75,32 @@ public class DashboardGateway implements DashboardReader {
LocalDateTime startTime = calculateStartTime(period);
LocalDateTime endTime = LocalDateTime.now();
// 1. 기간 다가오는 회의 목록 조회
List<Meeting> upcomingMeetings = getUpcomingMeetingsByPeriod(userId, startTime, endTime);
// 1. 기간 다가오는 회의 목록 조회 (UFR-USER-020 기준 적용)
List<Meeting> meetings = getUpcomingMeetingsByPeriod(userId, startTime, endTime);
// 2. 기간 최근 회의록 목록 조회
List<Minutes> recentMinutes = getRecentMinutesByPeriod(userId, startTime, endTime);
// 회의록 생성 여부 확인을 위한 생성
Set<String> meetingsWithMinutes = new HashSet<>();
minutesJpaRepository.findAll().stream()
.forEach(minutes -> meetingsWithMinutes.add(minutes.getMeetingId()));
// 정렬: 1) 회의록 미생성 우선, 2) 빠른 일시
List<Meeting> upcomingMeetings = meetings.stream()
.sorted((m1, m2) -> {
boolean m1HasMinutes = meetingsWithMinutes.contains(m1.getMeetingId());
boolean m2HasMinutes = meetingsWithMinutes.contains(m2.getMeetingId());
if (!m1HasMinutes && m2HasMinutes) return -1;
if (m1HasMinutes && !m2HasMinutes) return 1;
return m1.getScheduledAt().compareTo(m2.getScheduledAt());
})
.limit(3) // 최대 3개
.collect(Collectors.toList());
// 2. 기간 최근 회의록 목록 조회 (최대 4개, 최신순)
List<Minutes> recentMinutes = getRecentMinutesByPeriod(userId, startTime, endTime).stream()
.limit(4)
.collect(Collectors.toList());
// 3. 기간별 통계 정보 계산
Dashboard.Statistics statistics = calculateStatisticsByPeriod(userId, startTime, endTime);
@ -96,93 +119,145 @@ public class DashboardGateway implements DashboardReader {
}
/**
* 다가오는 회의 목록 조회
* 다가오는 회의 목록 조회 (유저스토리 UFR-USER-020 기준)
* - 최대 3개
* - 회의록 미생성 우선
* - 빠른 일시 (회의 시작 시간 기준)
*/
private List<Meeting> getUpcomingMeetings(String userId) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime endTime = now.plusDays(30); // 향후 30일
// 과거 7일부터 향후 30일까지의 회의를 조회 (진행중/완료된 최근 회의 포함)
LocalDateTime startTime = now.minusDays(7);
LocalDateTime endTime = now.plusDays(30);
return getUpcomingMeetingsByPeriod(userId, now, endTime);
List<Meeting> meetings = getUpcomingMeetingsByPeriod(userId, startTime, endTime);
// 회의록 생성 여부 확인을 위한 생성
Set<String> meetingsWithMinutes = new HashSet<>();
minutesJpaRepository.findAll().stream()
.forEach(minutes -> meetingsWithMinutes.add(minutes.getMeetingId()));
// 정렬: 1) 회의록 미생성 우선, 2) 빠른 일시 (오름차순)
return meetings.stream()
.sorted((m1, m2) -> {
boolean m1HasMinutes = meetingsWithMinutes.contains(m1.getMeetingId());
boolean m2HasMinutes = meetingsWithMinutes.contains(m2.getMeetingId());
// 회의록 미생성이 우선
if (!m1HasMinutes && m2HasMinutes) return -1;
if (m1HasMinutes && !m2HasMinutes) return 1;
// 같은 상태면 시간순 (빠른 일시 우선)
return m1.getScheduledAt().compareTo(m2.getScheduledAt());
})
.limit(3) // 최대 3개만
.collect(Collectors.toList());
}
/**
* 기간별 다가오는 회의 목록 조회
* SCHEDULED, IN_PROGRESS, COMPLETED 상태의 회의를 모두 포함
*/
private List<Meeting> getUpcomingMeetingsByPeriod(String userId, LocalDateTime startTime, LocalDateTime endTime) {
Set<String> userMeetingIds = new HashSet<>();
// 주최자로 참여하는 예정/진행중 회의 조회
// 주최자로 참여하는 모든 상태의 회의 조회 (CANCELLED 제외)
List<MeetingEntity> organizerMeetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.filter(m -> userId.equals(m.getOrganizerId()))
.filter(m -> "SCHEDULED".equals(m.getStatus()) || "IN_PROGRESS".equals(m.getStatus()))
.filter(m -> !"CANCELLED".equals(m.getStatus())) // CANCELLED만 제외
.toList();
organizerMeetings.forEach(m -> userMeetingIds.add(m.getMeetingId()));
// 참석자로 참여하는 예정/진행중 회의 조회
// 참석자로 참여하는 모든 상태의 회의 조회 (CANCELLED 제외)
List<String> participantMeetingIds = meetingParticipantJpaRepository.findByUserId(userId).stream()
.map(p -> p.getMeetingId())
.toList();
List<MeetingEntity> participantMeetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.filter(m -> participantMeetingIds.contains(m.getMeetingId()))
.filter(m -> "SCHEDULED".equals(m.getStatus()) || "IN_PROGRESS".equals(m.getStatus()))
.filter(m -> !"CANCELLED".equals(m.getStatus())) // CANCELLED만 제외
.toList();
participantMeetings.forEach(m -> userMeetingIds.add(m.getMeetingId()));
// 중복 제거된 회의 목록을 시간순 정렬하여 최대 10개만 반환
return meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
// 중복 제거된 회의 목록을 시간순 정렬하여 반환
// 과거 회의는 최신순(내림차순), 미래 회의는 오래된순(오름차순)으로 정렬
LocalDateTime now = LocalDateTime.now();
List<Meeting> meetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.filter(m -> userMeetingIds.contains(m.getMeetingId()))
.filter(m -> "SCHEDULED".equals(m.getStatus()) || "IN_PROGRESS".equals(m.getStatus()))
.sorted((m1, m2) -> m1.getScheduledAt().compareTo(m2.getScheduledAt()))
.limit(10)
.filter(m -> !"CANCELLED".equals(m.getStatus())) // CANCELLED만 제외
.sorted((m1, m2) -> {
boolean m1IsPast = m1.getScheduledAt().isBefore(now);
boolean m2IsPast = m2.getScheduledAt().isBefore(now);
if (m1IsPast && m2IsPast) {
// 과거 회의면 최신순(내림차순)
return m2.getScheduledAt().compareTo(m1.getScheduledAt());
} else if (!m1IsPast && !m2IsPast) {
// 미래 회의면 오래된순(오름차순)
return m1.getScheduledAt().compareTo(m2.getScheduledAt());
} else {
// 하나는 과거, 하나는 미래면 미래 회의를 먼저
return m1IsPast ? 1 : -1;
}
})
// limit 제거 - 상위 메서드에서 필터링
.map(MeetingEntity::toDomain)
.collect(Collectors.toList());
log.debug("조회된 회의 목록 - userId: {}, 총 {}개 (SCHEDULED: {}, IN_PROGRESS: {}, COMPLETED: {})",
userId,
meetings.size(),
meetings.stream().filter(m -> "SCHEDULED".equals(m.getStatus())).count(),
meetings.stream().filter(m -> "IN_PROGRESS".equals(m.getStatus())).count(),
meetings.stream().filter(m -> "COMPLETED".equals(m.getStatus())).count()
);
return meetings;
}
/**
* 최근 회의록 목록 조회
* 최근 회의록 목록 조회 (유저스토리 UFR-USER-020 기준)
* - 최대 4개
* - 최신순 (최근 수정/생성 시간 기준)
*/
private List<Minutes> getRecentMinutes(String userId) {
LocalDateTime startTime = LocalDateTime.now().minusDays(7);
return getRecentMinutesByPeriod(userId, startTime, LocalDateTime.now());
LocalDateTime startTime = LocalDateTime.now().minusDays(30); // 넓은 범위에서 조회
List<Minutes> minutes = getRecentMinutesByPeriod(userId, startTime, LocalDateTime.now());
// 이미 getRecentMinutesByPeriod에서 최신순으로 정렬되어 있으므로
// 최대 4개만 반환
return minutes.stream()
.limit(4)
.collect(Collectors.toList());
}
/**
* 기간별 최근 회의록 목록 조회
*/
private List<Minutes> getRecentMinutesByPeriod(String userId, LocalDateTime startTime, LocalDateTime endTime) {
Set<String> userMinutesIds = new HashSet<>();
log.debug("회의록 조회 시작 - userId: {}, startTime: {}, endTime: {}", userId, startTime, endTime);
// 작성자로 참여한 회의록 조회
List<MinutesEntity> createdMinutes = minutesJpaRepository.findByCreatedBy(userId).stream()
.filter(m -> m.getCreatedAt().isAfter(startTime) && m.getCreatedAt().isBefore(endTime))
.toList();
createdMinutes.forEach(m -> userMinutesIds.add(m.getMinutesId()));
// 참석한 회의의 회의록 조회
List<String> participantMeetingIds = meetingParticipantJpaRepository.findByUserId(userId).stream()
.map(p -> p.getMeetingId())
.toList();
List<MinutesEntity> participatedMinutes = minutesJpaRepository.findAll().stream()
.filter(m -> participantMeetingIds.contains(m.getMeetingId()))
.filter(m -> m.getCreatedAt().isAfter(startTime) && m.getCreatedAt().isBefore(endTime))
.toList();
participatedMinutes.forEach(m -> userMinutesIds.add(m.getMinutesId()));
// 중복 제거 최종 수정 시간순 정렬하여 최대 10개만 반환
return minutesJpaRepository.findAll().stream()
.filter(m -> userMinutesIds.contains(m.getMinutesId()))
// 사용자가 작성한 회의록만 조회 (createdBy = userId)
List<MinutesEntity> userMinutes = minutesJpaRepository.findByCreatedBy(userId).stream()
.filter(m -> m.getCreatedAt() != null &&
m.getCreatedAt().isAfter(startTime) &&
m.getCreatedAt().isBefore(endTime))
.sorted((m1, m2) -> {
LocalDateTime time1 = m1.getUpdatedAt() != null ? m1.getUpdatedAt() : m1.getCreatedAt();
LocalDateTime time2 = m2.getUpdatedAt() != null ? m2.getUpdatedAt() : m2.getCreatedAt();
return time2.compareTo(time1); // 최신순
})
.limit(10)
.toList();
log.debug("조회된 회의록 수: {}", userMinutes.size());
userMinutes.forEach(m -> {
log.debug(" - minutesId: {}, meetingId: {}, title: {}, createdBy: {}, createdAt: {}",
m.getMinutesId(), m.getMeetingId(), m.getTitle(), m.getCreatedBy(), m.getCreatedAt());
});
return userMinutes.stream()
.map(MinutesEntity::toDomain)
.collect(Collectors.toList());
}

View File

@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@ -75,10 +76,33 @@ public class MinutesGateway implements MinutesReader, MinutesWriter {
@Override
public Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId) {
log.debug("회의 ID로 AI 통합 회의록 조회: {}", meetingId);
return minutesJpaRepository.findByMeetingIdAndUserIdIsNull(meetingId)
List<MinutesEntity> consolidatedMinutes = minutesJpaRepository.findByMeetingIdAndUserIdIsNull(meetingId);
if (consolidatedMinutes.isEmpty()) {
return Optional.empty();
}
// 여러 개가 있을 경우 가장 최신 것을 반환 (updatedAt 또는 createdAt 기준)
return consolidatedMinutes.stream()
.sorted((m1, m2) -> {
LocalDateTime time1 = m1.getUpdatedAt() != null ? m1.getUpdatedAt() : m1.getCreatedAt();
LocalDateTime time2 = m2.getUpdatedAt() != null ? m2.getUpdatedAt() : m2.getCreatedAt();
return time2.compareTo(time1); // 최신순
})
.findFirst()
.map(MinutesEntity::toDomain);
}
@Override
public List<Minutes> findByParticipantUserId(String userId) {
log.debug("사용자가 참여한 회의의 회의록 조회: {}", userId);
// 사용자가 생성한 회의록과 사용자가 참여한 회의의 회의록을 모두 조회
// 현재는 생성자 기준으로만 조회 (추후 참석자 테이블과 조인하여 구현 필요)
return minutesJpaRepository.findByCreatedByOrParticipantUserId(userId).stream()
.map(MinutesEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Minutes save(Minutes minutes) {
// 기존 엔티티 조회 (update) 또는 새로 생성 (insert)

View File

@ -57,6 +57,13 @@ public class MinutesEntity extends BaseTimeEntity {
@Column(name = "finalized_at")
private LocalDateTime finalizedAt;
/**
* 결정사항 업데이트
*/
public void updateDecisions(String decisions) {
this.decisions = decisions;
}
public Minutes toDomain() {
return Minutes.builder()
.minutesId(this.minutesId)

View File

@ -50,7 +50,20 @@ public interface MinutesJpaRepository extends JpaRepository<MinutesEntity, Strin
List<MinutesEntity> findByMeetingIdAndUserIdIsNotNull(String meetingId);
/**
* 회의 ID로 AI 통합 회의록 조회 (user_id IS NULL)
* 회의 ID로 AI 통합 회의록 목록 조회 (user_id IS NULL)
*/
Optional<MinutesEntity> findByMeetingIdAndUserIdIsNull(String meetingId);
List<MinutesEntity> findByMeetingIdAndUserIdIsNull(String meetingId);
/**
* 사용자가 생성했거나 참여한 회의의 회의록 조회
* 현재는 생성자 기준으로만 조회 (추후 참석자 테이블과 조인 필요)
*
* @param userId 사용자 ID
* @return 회의록 목록
*/
default List<MinutesEntity> findByCreatedByOrParticipantUserId(String userId) {
// TODO: 참석자 테이블(participants) 조인하여 참여한 회의의 회의록도 조회하도록 구현 필요
// 현재는 임시로 생성자 기준으로만 조회
return findByCreatedBy(userId);
}
}

View File

@ -156,4 +156,4 @@ azure:
ai:
service:
url: ${AI_SERVICE_URL:http://localhost:8087}
timeout: ${AI_SERVICE_TIMEOUT:30000}
timeout: ${AI_SERVICE_TIMEOUT:60000}

View File

@ -0,0 +1,274 @@
-- ========================================
-- AI 회의록 요약 테스트 데이터
-- ========================================
-- 목적: Minutes.decisions 및 AgendaSection 저장 검증
-- 회의: 2025년 신제품 런칭 전략 회의
-- 참석자: 3명 (마케팅팀장, 개발팀장, 디자인팀장)
-- 안건: 3개 (타겟 고객 설정, 핵심 기능 정의, 런칭 일정)
-- ========================================
-- 1. 회의 생성
-- ========================================
INSERT INTO meetings (
meeting_id,
title,
purpose,
description,
location,
scheduled_at,
end_time,
started_at,
ended_at,
status,
organizer_id,
template_id,
created_at,
updated_at
) VALUES (
'ai_test_meeting',
'2025년 신제품 런칭 전략 회의',
'신제품 출시를 위한 전략 수립 및 일정 협의',
'타겟 고객층 정의, 핵심 기능 결정, 출시 일정 확정',
'본사 4층 회의실',
'2025-01-15 14:00:00',
'2025-01-15 16:00:00',
'2025-01-15 14:05:00',
NULL, -- 아직 종료 안됨
'IN_PROGRESS',
'user_organizer',
'template_general',
NOW(),
NOW()
);
-- ========================================
-- 2. 참석자별 회의록 생성 (user_id NOT NULL)
-- ========================================
-- 2-1. 마케팅팀장 회의록
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'ai_test_minutes_marketing',
'ai_test_meeting',
'user_marketing',
'2025년 신제품 런칭 전략 회의 - 마케팅팀장',
'DRAFT',
1,
'user_marketing_head',
NOW(),
NOW()
);
-- 2-2. 개발팀장 회의록
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'ai_test_minutes_dev',
'ai_test_meeting',
'user_dev',
'2025년 신제품 런칭 전략 회의 - 개발팀장',
'DRAFT',
1,
'user_dev',
NOW(),
NOW()
);
-- 2-3. 디자인팀장 회의록
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'ai_test_minutes_design',
'ai_test_meeting',
'user_design',
'2025년 신제품 런칭 전략 회의 - 디자인팀장',
'DRAFT',
1,
'user_design',
NOW(),
NOW()
);
-- ========================================
-- 3. 마케팅팀장 회의록 섹션 (MEMO 타입)
-- ========================================
INSERT INTO minutes_sections (
id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
locked_by
) VALUES (
'ai_test_section_marketing',
'ai_test_minutes_marketing',
'MEMO',
'마케팅팀장 메모',
'【안건 1: 타겟 고객 설정】
- : 20-30 , 1
- : 20 ~30
- : 40
- : 1 20-30 , SNS
2:
- AI
- :
- 1
- : IoT 2 ( )
- : AI
3:
- : 2025 6 1
- 5
- : 5
- : 3
- : 2 ',
1,
FALSE,
FALSE,
NULL
);
-- ========================================
-- 4. 개발팀장 회의록 섹션 (MEMO 타입)
-- ========================================
INSERT INTO minutes_sections (
id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
locked_by
) VALUES (
'ai_test_section_dev',
'ai_test_minutes_dev',
'MEMO',
'개발팀장 메모',
'【안건 1: 타겟 고객 설정】
- : 20-30 UX가
- 20-30
- AI
2:
- AI: OpenAI Whisper Google Speech API
- :
- : AI는
- : IoT 2 ( )
- : 1 AI
- : 2
3:
- 6 1
- : 2 , 3 , 4 , 5 QA
- : 3 , 5
- : 4
- : , ',
1,
FALSE,
FALSE,
NULL
);
-- ========================================
-- 5. 디자인팀장 회의록 섹션 (MEMO 타입)
-- ========================================
INSERT INTO minutes_sections (
id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
locked_by
) VALUES (
'ai_test_section_design',
'ai_test_minutes_design',
'MEMO',
'디자인팀장 메모',
'【안건 1: 타겟 고객 설정】
- 20-30 , MZ세대
- : "미니멀하고 감각적인 디자인으로 차별화해야 합니다"
- : HomePod, Nest Hub
- , , 3
2:
- UI가 - LED
- : "사용자가 AI가 듣고 있다는 걸 직관적으로 알 수 있어야 합니다"
-
- :
- : 2 UI/UX
3:
- 6 3
- : 2 , 3 , 4
- : "패키지 디자인도 프리미엄 느낌으로 가야 합니다"
- :
- : 3
- : 4 ',
1,
FALSE,
FALSE,
NULL
);
-- ========================================
-- 검증 쿼리
-- ========================================
-- 회의 확인
-- SELECT * FROM meetings WHERE meeting_id = 'ai_test_meeting_001';
-- 참석자별 회의록 확인
-- SELECT minutes_id, user_id, title FROM minutes WHERE meeting_id = 'ai_test_meeting_001';
-- 회의록 섹션 확인
-- SELECT id, minutes_id, title, LEFT(content, 100) as content_preview
-- FROM minutes_sections
-- WHERE minutes_id LIKE 'ai_test_minutes_%';
-- ========================================
-- 테스트 실행 가이드
-- ========================================
-- 1. 이 SQL 실행하여 테스트 데이터 생성
-- 2. POST /api/meetings/ai_test_meeting_001/end 호출
-- 3. 검증:
-- - minutes 테이블에 userId=NULL인 통합 회의록 생성 확인
-- - minutes.decisions 필드에 전체 결정사항 저장 확인
-- - agenda_sections 테이블에 3개 안건 저장 확인
-- - agenda_sections.summary에 논의+결정 내용 저장 확인