Compare commits

...

12 Commits

Author SHA1 Message Date
cyjadela
af53c80439 Fix: meeting 빌드 에러 해결 2025-10-31 13:36:05 +09:00
Cho Yoon Jin
3d6742505a
Merge pull request #64 from hwanny1128/fix/dashboard
Fix: 회의록 목록 조회 API 수정
2025-10-31 13:15:06 +09:00
cyjadela
44f02a2cc6 Fix: 회의록 목록 조회 API 수정 2025-10-31 13:14:18 +09:00
yabo0812
de6c68d4d1 Merge branch 'main' of https://github.com/hwanny1128/HGZero 2025-10-31 13:10:31 +09:00
yabo0812
a2ef408a85 발표자료 최종 (현재까지) 2025-10-31 13:10:24 +09:00
Minseo-Jo
c4bd8064ec 회의 종료 시 AI 응답 처리 개선
- MeetingEndDTO.TodoSummaryDTO에 assignee 필드 추가
- AI 응답의 todos를 직접 DTO로 변환하여 반환
- 안건별 todos 매핑 로직 개선

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 12:52:15 +09:00
Cho Yoon Jin
5515909206
Merge pull request #63 from hwanny1128/fix/dashboard
Fix: dashboard 대시보드 조회 API 수정
2025-10-31 12:13:14 +09:00
yabo0812
e1741c707e 발표자료 v1.11 추가
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 12:01:49 +09:00
yabo0812
599f880e81 Merge origin/main into wip/document-yabo for synchronization 2025-10-31 11:56:07 +09:00
yabo0812
4d4fd5cd32 발표자료 중간버전 2025-10-31 11:50:34 +09:00
Cho Yoon Jin
db16306b06
Merge pull request #62 from hwanny1128/fix/dashboard
Fix: 대시보드 최근 회의 로직 수정
2025-10-31 11:10:25 +09:00
Minseo-Jo
b5159ef74e AI 제안사항 Hallucination 문제 해결 및 추출 개선
주요 변경사항:
1. AI 서비스 설정
   - claude_max_tokens: 8192 → 25000으로 증가 (회의록 통합을 위한 충분한 토큰 확보)
   - AI 서비스 타임아웃: 30초 → 60초로 증가

2. 프롬프트 개선 (consolidate_prompt.py)
   - JSON 생성 전문가 역할 추가
   - JSON 이스케이프 규칙 명시 (큰따옴표, 줄바꿈, 역슬래시)
   - Markdown 볼드체(**) 제거하여 JSON 파싱 오류 방지
   - 문자열 검증 지시사항 추가

3. JSON 파싱 개선 (claude_service.py)
   - 4단계 재시도 전략 구현:
     * 이스케이프되지 않은 개행 문자 자동 수정
     * strict=False 옵션으로 파싱
     * 잘린 응답 복구 시도
     * 제어 문자 제거 후 재시도
   - 디버깅 로깅 강화 (Input/Output Tokens, Stop Reason)
   - 파싱 실패 시 전체 응답을 파일로 저장

4. 회의 종료 로직 개선 (EndMeetingService.java)
   - 통합 회의록 생성 또는 조회 로직 추가 (userId=NULL)
   - Minutes 테이블에 전체 결정사항 저장
   - AgendaSection에 minutesId 정확히 매핑

5. 테스트 데이터 추가
   - AI 회의록 요약 테스트용 SQL 스크립트 작성
   - 3명 참석자, 3개 안건의 현실적인 회의 시나리오

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 11:09:22 +09:00
15 changed files with 5850 additions and 253 deletions

View File

@ -15,7 +15,7 @@ class Settings(BaseSettings):
# Claude API # Claude API
claude_api_key: str = "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" claude_api_key: str = "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"
claude_model: str = "claude-sonnet-4-5-20250929" 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 claude_temperature: float = 0.7
# Redis # 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) 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)**: 3. **회의 전체 결정사항 (decisions)**:
- 회의 전체에서 최종 결정된 사항들을 TEXT 형식으로 정리 - 회의 전체에서 최종 결정된 사항들을 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)**: 4. **안건별 요약 (agenda_summaries)**:
회의 내용을 분석하여 안건별로 구조화: 회의 내용을 분석하여 안건별로 구조화:
@ -51,8 +57,9 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
- **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"
* 사용자가 자유롭게 편집할 있도록 구조화된 텍스트로 작성 * 사용자가 자유롭게 편집할 있도록 구조화된 텍스트로 작성
* **JSON 이스케이프 필수**: 큰따옴표(")는 제거하거나 작은따옴표(')로 대체, 줄바꿈은 \\n으로 표현
- **decisions**: 안건별 결정사항 배열 (대시보드 표시용, summary의 결정사항 부분을 배열로 추출) - **decisions**: 안건별 결정사항 배열 (대시보드 표시용, summary의 결정사항 부분을 배열로 추출)
* 형식: ["결정사항1", "결정사항2", "결정사항3"] * 형식: ["결정사항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 ```json
{{ {{
"keywords": ["키워드1", "키워드2", "키워드3"], "keywords": ["키워드1", "키워드2", "키워드3"],
"statistics": {{ "statistics": {{
"agendas_count": 숫자, "agendas_count": 2,
"todos_count": 숫자 "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_summaries": [
{{ {{
"agenda_number": 1, "agenda_number": 1,
"agenda_title": "안건 제목", "agenda_title": "안건 제목",
"summary_short": "짧은 요약 (20자 이내)", "summary_short": "짧은 요약",
"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"], "decisions": ["결정사항1", "결정사항2"],
"pending": ["보류사항"], "pending": ["보류사항"],
"todos": [ "todos": [
@ -113,20 +126,27 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
3. **완전성**: 모든 필드를 빠짐없이 작성 3. **완전성**: 모든 필드를 빠짐없이 작성
4. **구조화**: 안건별로 명확히 분리 4. **구조화**: 안건별로 명확히 분리
5. **결정사항 추출**: 5. **결정사항 추출**:
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식) - 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식, \\n 이스케이프 필수)
- 안건별 결정사항(agenda_summaries[].decisions): 안건의 결정사항을 배열로 추출 - 안건별 결정사항(agenda_summaries[].decisions): 안건의 결정사항을 배열로 추출
- 결정사항이 명확하게 언급된 경우에만 포함 - 결정사항이 명확하게 언급된 경우에만 포함
6. **summary 작성**: 6. **summary 작성**:
- summary_short: AI가 자동 생성한 1 요약 (사용자 수정 불가) - summary_short: AI가 자동 생성한 1 요약 (사용자 수정 불가, 특수문자 이스케이프)
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능) - summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능, \\n 이스케이프 필수)
- decisions: summary의 결정사항 부분을 배열로 별도 추출 (대시보드 표시용) - decisions: summary의 결정사항 부분을 배열로 별도 추출 (대시보드 표시용)
7. **Todo 추출**: 7. **Todo 추출**:
- 제목 필수, 담당자는 언급된 경우에만 추출 - 제목 필수, 담당자는 언급된 경우에만 추출
- 자연스러운 표현에서 추출: "김대리가 ~하기로 함" title: "~", assignee: "김대리" - 자연스러운 표현에서 추출: "김대리가 ~하기로 함" title: "~", assignee: "김대리"
- 담당자가 없으면 assignee: "" ( 문자열) - 담당자가 없으면 assignee: "" ( 문자열)
8. **JSON만 출력**: 추가 설명 없이 JSON만 반환 8. **JSON 형식 엄수 - 가장 중요**:
- 추가 설명, 주석, 서문 없이 JSON만 반환
- 문자열 큰따옴표(")는 작은따옴표(')로 대체
- 문자열 줄바꿈은 \\n으로 이스케이프
- 역슬래시는 \\\\ 이스케이프
- 모든 문자열을 올바르게 닫기
- 생성 유효한 JSON인지 자체 검증
이제 회의록들을 분석하여 통합 요약을 JSON 형식으로 생성해주세요. **최종 지시**: 회의록들을 분석하여 **유효한 JSON 형식으로만** 통합 요약을 생성해주세요.
JSON 파싱 오류가 발생하지 않도록 모든 특수문자를 올바르게 이스케이프하세요.
""" """
return prompt return prompt

View File

@ -43,7 +43,7 @@ class ClaudeService:
] ]
# API 호출 # 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: if system_prompt:
response = self.client.messages.create( response = self.client.messages.create(
@ -63,7 +63,12 @@ class ClaudeService:
# 응답 텍스트 추출 # 응답 텍스트 추출
response_text = response.content[0].text 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 파싱
# ```json ... ``` 블록 제거 # ```json ... ``` 블록 제거
@ -72,13 +77,113 @@ class ClaudeService:
elif "```" in response_text: elif "```" in response_text:
response_text = response_text.split("```")[1].split("```")[0].strip() 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) result = json.loads(response_text)
return result return result
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"JSON 파싱 실패: {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)}") raise ValueError(f"Claude API 응답을 JSON으로 파싱할 수 없습니다: {str(e)}")
except Exception as e: except Exception as e:

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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.dto.ApiResponse;
import com.unicorn.hgzero.common.exception.BusinessException; import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.meeting.biz.domain.Meeting; 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.MinutesSection;
import com.unicorn.hgzero.meeting.biz.domain.Todo;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection; import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO; import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
import com.unicorn.hgzero.meeting.biz.service.MeetingService; import com.unicorn.hgzero.meeting.biz.service.MeetingService;
import com.unicorn.hgzero.meeting.biz.service.MinutesService; import com.unicorn.hgzero.meeting.biz.service.MinutesService;
import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService; 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.biz.service.AgendaSectionService;
import com.unicorn.hgzero.meeting.infra.dto.request.UpdateMinutesRequest; import com.unicorn.hgzero.meeting.infra.dto.request.UpdateMinutesRequest;
import com.unicorn.hgzero.meeting.infra.dto.request.UpdateAgendaSectionsRequest; 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.event.publisher.EventPublisher;
import com.unicorn.hgzero.meeting.infra.gateway.AiServiceGateway; import com.unicorn.hgzero.meeting.infra.gateway.AiServiceGateway;
import com.unicorn.hgzero.meeting.biz.dto.AiAnalysisDTO; 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.MinutesFinalizedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesSectionDTO; import com.unicorn.hgzero.meeting.infra.event.dto.MinutesSectionDTO;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader; import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
@ -40,17 +36,10 @@ import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -69,7 +58,6 @@ public class MinutesController {
private final CacheService cacheService; private final CacheService cacheService;
private final EventPublisher eventPublisher; private final EventPublisher eventPublisher;
private final MeetingService meetingService; private final MeetingService meetingService;
private final TodoService todoService;
private final AiServiceGateway aiServiceGateway; private final AiServiceGateway aiServiceGateway;
private final AgendaSectionService agendaSectionService; private final AgendaSectionService agendaSectionService;
private final ParticipantReader participantReader; private final ParticipantReader participantReader;
@ -120,6 +108,9 @@ public class MinutesController {
.filter(item -> filterBySearch(item, search)) .filter(item -> filterBySearch(item, search))
.collect(Collectors.toList()); .collect(Collectors.toList());
// 필터링 정렬 적용 (프론트엔드 정렬과 일치)
applySorting(filteredMinutes, sortBy, sortDir);
// 통계 계산 (전체 데이터 기준) // 통계 계산 (전체 데이터 기준)
MinutesListResponse.Statistics stats = calculateRealStatistics(userId, participationType); MinutesListResponse.Statistics stats = calculateRealStatistics(userId, participationType);
@ -478,7 +469,8 @@ public class MinutesController {
case "title": case "title":
return Sort.by(direction, "title"); return Sort.by(direction, "title");
case "meeting": case "meeting":
return Sort.by(direction, "createdAt"); // 회의 일시로 정렬 (임시로 생성일시 사용) // 회의 일시로 정렬 - DB에 meetingDate 필드가 없으므로 createdAt 사용
return Sort.by(direction, "createdAt");
case "modified": case "modified":
default: default:
return Sort.by(direction, "lastModifiedAt"); return Sort.by(direction, "lastModifiedAt");
@ -531,16 +523,38 @@ public class MinutesController {
private MinutesListResponse.MinutesItem convertToMinutesItem(MinutesDTO minutesDTO, String userId) { private MinutesListResponse.MinutesItem convertToMinutesItem(MinutesDTO minutesDTO, String userId) {
// 검증완료율 계산 (작성중 상태일 때는 verificationRate 사용, 확정완료시 100%) // 검증완료율 계산 (작성중 상태일 때는 verificationRate 사용, 확정완료시 100%)
int completionRate; int verificationRate;
if ("DRAFT".equals(minutesDTO.getStatus()) && minutesDTO.getVerificationRate() != null) { if ("DRAFT".equals(minutesDTO.getStatus()) && minutesDTO.getVerificationRate() != null) {
completionRate = minutesDTO.getVerificationRate(); verificationRate = minutesDTO.getVerificationRate();
} else if ("FINALIZED".equals(minutesDTO.getStatus())) { } else if ("FINALIZED".equals(minutesDTO.getStatus())) {
completionRate = 100; verificationRate = 100;
} else { } else {
// 기본값 0 // 기본값 0
completionRate = 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() return MinutesListResponse.MinutesItem.builder()
.minutesId(minutesDTO.getMinutesId()) .minutesId(minutesDTO.getMinutesId())
.title(minutesDTO.getTitle()) .title(minutesDTO.getTitle())
@ -549,14 +563,16 @@ public class MinutesController {
.version(minutesDTO.getVersion()) .version(minutesDTO.getVersion())
.createdAt(minutesDTO.getCreatedAt()) .createdAt(minutesDTO.getCreatedAt())
.lastModifiedAt(minutesDTO.getLastModifiedAt()) .lastModifiedAt(minutesDTO.getLastModifiedAt())
.meetingDate(minutesDTO.getCreatedAt()) // 임시로 생성일시 사용 .meetingDate(meetingDateTime)
.meetingTime(meetingTime)
.createdBy(minutesDTO.getCreatedBy()) .createdBy(minutesDTO.getCreatedBy())
.createdByName(creatorName)
.lastModifiedBy(minutesDTO.getLastModifiedBy()) .lastModifiedBy(minutesDTO.getLastModifiedBy())
.participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 0) .participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 0)
.todoCount(minutesDTO.getTodoCount()) .todoCount(minutesDTO.getTodoCount())
.completedTodoCount(minutesDTO.getCompletedTodoCount()) .completedTodoCount(minutesDTO.getCompletedTodoCount())
.completionRate(completionRate) .verificationRate(verificationRate)
.isCreatedByUser(minutesDTO.getCreatedBy().equals(userId)) // 현재 사용자가 생성자인지 확인 .isCreator(minutesDTO.getCreatedBy().equals(userId)) // 현재 사용자가 생성자인지 확인
.build(); .build();
} }
@ -585,9 +601,9 @@ public class MinutesController {
} }
if ("created".equals(participationType)) { if ("created".equals(participationType)) {
return item.isCreatedByUser(); // 사용자가 생성한 회의록만 return item.isCreator(); // 사용자가 생성한 회의록만
} else if ("attended".equals(participationType)) { } else if ("attended".equals(participationType)) {
return !item.isCreatedByUser(); // 사용자가 참여만 회의록 return !item.isCreator(); // 사용자가 참여만 회의록
} }
return true; return true;
@ -606,7 +622,7 @@ public class MinutesController {
} }
/** /**
* 정렬 적용 * 정렬 적용 (필터링 )
*/ */
private void applySorting(List<MinutesListResponse.MinutesItem> items, String sortBy, String sortDir) { private void applySorting(List<MinutesListResponse.MinutesItem> items, String sortBy, String sortDir) {
boolean ascending = "asc".equalsIgnoreCase(sortDir); boolean ascending = "asc".equalsIgnoreCase(sortDir);
@ -618,12 +634,14 @@ public class MinutesController {
b.getTitle().compareTo(a.getTitle())); b.getTitle().compareTo(a.getTitle()));
break; break;
case "meeting": case "meeting":
// 회의 날짜순 정렬 (최근회의순은 desc가 기본)
items.sort((a, b) -> ascending ? items.sort((a, b) -> ascending ?
a.getMeetingDate().compareTo(b.getMeetingDate()) : a.getMeetingDate().compareTo(b.getMeetingDate()) :
b.getMeetingDate().compareTo(a.getMeetingDate())); b.getMeetingDate().compareTo(a.getMeetingDate()));
break; break;
case "modified": case "modified":
default: default:
// 최근수정순 정렬 (desc가 기본)
items.sort((a, b) -> ascending ? items.sort((a, b) -> ascending ?
a.getLastModifiedAt().compareTo(b.getLastModifiedAt()) : a.getLastModifiedAt().compareTo(b.getLastModifiedAt()) :
b.getLastModifiedAt().compareTo(a.getLastModifiedAt())); b.getLastModifiedAt().compareTo(a.getLastModifiedAt()));
@ -631,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))
.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) { private MinutesDetailResponse convertToMinutesDetailResponse(MinutesDTO minutesDTO) {
try { try {
// 실제 회의 정보 조회 // 실제 회의 정보 조회
@ -951,11 +909,6 @@ public class MinutesController {
.build(); .build();
} }
private int calculateActualDuration(Object meeting) {
// TODO: 실제 회의 시간 계산 로직 구현
// Meeting 객체에서 startedAt, endedAt을 사용하여 계산
return 90;
}
private MinutesDetailResponse.AgendaInfo convertToAgendaInfo(Object section) { private MinutesDetailResponse.AgendaInfo convertToAgendaInfo(Object section) {
if (!(section instanceof MinutesSection)) { if (!(section instanceof MinutesSection)) {
@ -989,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) { private List<MinutesDetailResponse.KeyPoint> extractKeyPoints(List<MinutesDetailResponse.AgendaInfo> agendas) {
// 안건별 AI 요약에서 핵심내용 추출 // 안건별 AI 요약에서 핵심내용 추출
@ -1044,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) { private List<String> extractKeywords(List<MinutesDetailResponse.AgendaInfo> agendas) {
// TODO: AI를 통한 키워드 추출 로직 구현 // TODO: AI를 통한 키워드 추출 로직 구현
@ -1261,41 +1137,6 @@ public class MinutesController {
.collect(Collectors.toList()); .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 createdAt;
private LocalDateTime lastModifiedAt; private LocalDateTime lastModifiedAt;
private LocalDateTime meetingDate; // 회의 일시 private LocalDateTime meetingDate; // 회의 일시
private String meetingTime; // 회의 시간 (HH:mm 형식)
private String createdBy; private String createdBy;
private String createdByName; // 생성자 이름
private String lastModifiedBy; private String lastModifiedBy;
private int participantCount; // 참석자 private int participantCount; // 참석자
private int todoCount; private int todoCount;
private int completedTodoCount; private int completedTodoCount;
private int completionRate; // 검증완료율 private int verificationRate; // 검증완료율 (프로토타입과 일치)
private boolean isCreatedByUser; // 사용자가 생성한 회의록 여부 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

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

View File

@ -156,4 +156,4 @@ azure:
ai: ai:
service: service:
url: ${AI_SERVICE_URL:http://localhost:8087} 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에 논의+결정 내용 저장 확인