Compare commits

...

3 Commits

Author SHA1 Message Date
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
cyjadela
d647cbc4bb Fix: 대시보드 최근 회의 로직 수정 2025-10-31 11:08:35 +09:00
9 changed files with 16570 additions and 49 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 it is too large Load Diff

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,13 +107,19 @@ 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());
@ -164,10 +171,54 @@ public class EndMeetingService implements EndMeetingUseCase {
.build(); .build();
} }
/**
* 통합 회의록 생성 또는 조회
* userId가 NULL인 회의록 = AI 통합 회의록
*/
private MinutesEntity getOrCreateConsolidatedMinutes(MeetingEntity meeting) {
// userId가 NULL인 회의록 찾기 (AI 통합 회의록)
Optional<MinutesEntity> existing = minutesRepository
.findByMeetingIdAndUserIdIsNull(meeting.getMeetingId());
if (existing.isPresent()) {
log.debug("기존 통합 회의록 사용 - minutesId: {}", existing.get().getMinutesId());
return existing.get();
}
// 없으면 새로 생성
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 +254,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 +264,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 +280,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 +298,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());
} }
/** /**

View File

@ -73,11 +73,32 @@ public class DashboardGateway implements DashboardReader {
LocalDateTime startTime = calculateStartTime(period); LocalDateTime startTime = calculateStartTime(period);
LocalDateTime endTime = LocalDateTime.now(); LocalDateTime endTime = LocalDateTime.now();
// 1. 기간 다가오는 회의 목록 조회 // 1. 기간 다가오는 회의 목록 조회 (UFR-USER-020 기준 적용)
List<Meeting> upcomingMeetings = getUpcomingMeetingsByPeriod(userId, startTime, endTime); 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. 기간별 통계 정보 계산 // 3. 기간별 통계 정보 계산
Dashboard.Statistics statistics = calculateStatisticsByPeriod(userId, startTime, endTime); Dashboard.Statistics statistics = calculateStatisticsByPeriod(userId, startTime, endTime);
@ -96,57 +117,118 @@ public class DashboardGateway implements DashboardReader {
} }
/** /**
* 다가오는 회의 목록 조회 * 다가오는 회의 목록 조회 (유저스토리 UFR-USER-020 기준)
* - 최대 3개
* - 회의록 미생성 우선
* - 빠른 일시 (회의 시작 시간 기준)
*/ */
private List<Meeting> getUpcomingMeetings(String userId) { private List<Meeting> getUpcomingMeetings(String userId) {
LocalDateTime now = LocalDateTime.now(); 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) { private List<Meeting> getUpcomingMeetingsByPeriod(String userId, LocalDateTime startTime, LocalDateTime endTime) {
Set<String> userMeetingIds = new HashSet<>(); Set<String> userMeetingIds = new HashSet<>();
// 주최자로 참여하는 예정/진행중 회의 조회 // 주최자로 참여하는 모든 상태의 회의 조회 (CANCELLED 제외)
List<MeetingEntity> organizerMeetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream() List<MeetingEntity> organizerMeetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.filter(m -> userId.equals(m.getOrganizerId())) .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(); .toList();
organizerMeetings.forEach(m -> userMeetingIds.add(m.getMeetingId())); organizerMeetings.forEach(m -> userMeetingIds.add(m.getMeetingId()));
// 참석자로 참여하는 예정/진행중 회의 조회 // 참석자로 참여하는 모든 상태의 회의 조회 (CANCELLED 제외)
List<String> participantMeetingIds = meetingParticipantJpaRepository.findByUserId(userId).stream() List<String> participantMeetingIds = meetingParticipantJpaRepository.findByUserId(userId).stream()
.map(p -> p.getMeetingId()) .map(p -> p.getMeetingId())
.toList(); .toList();
List<MeetingEntity> participantMeetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream() List<MeetingEntity> participantMeetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.filter(m -> participantMeetingIds.contains(m.getMeetingId())) .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(); .toList();
participantMeetings.forEach(m -> userMeetingIds.add(m.getMeetingId())); 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 -> userMeetingIds.contains(m.getMeetingId()))
.filter(m -> "SCHEDULED".equals(m.getStatus()) || "IN_PROGRESS".equals(m.getStatus())) .filter(m -> !"CANCELLED".equals(m.getStatus())) // CANCELLED만 제외
.sorted((m1, m2) -> m1.getScheduledAt().compareTo(m2.getScheduledAt())) .sorted((m1, m2) -> {
.limit(10) 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) .map(MeetingEntity::toDomain)
.collect(Collectors.toList()); .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) { private List<Minutes> getRecentMinutes(String userId) {
LocalDateTime startTime = LocalDateTime.now().minusDays(7); LocalDateTime startTime = LocalDateTime.now().minusDays(30); // 넓은 범위에서 조회
return getRecentMinutesByPeriod(userId, startTime, LocalDateTime.now()); List<Minutes> minutes = getRecentMinutesByPeriod(userId, startTime, LocalDateTime.now());
// 이미 getRecentMinutesByPeriod에서 최신순으로 정렬되어 있으므로
// 최대 4개만 반환
return minutes.stream()
.limit(4)
.collect(Collectors.toList());
} }
/** /**
@ -174,7 +256,7 @@ public class DashboardGateway implements DashboardReader {
participatedMinutes.forEach(m -> userMinutesIds.add(m.getMinutesId())); participatedMinutes.forEach(m -> userMinutesIds.add(m.getMinutesId()));
// 중복 제거 최종 수정 시간순 정렬하여 최대 10개만 반환 // 중복 제거 최종 수정 시간순 정렬
return minutesJpaRepository.findAll().stream() return minutesJpaRepository.findAll().stream()
.filter(m -> userMinutesIds.contains(m.getMinutesId())) .filter(m -> userMinutesIds.contains(m.getMinutesId()))
.sorted((m1, m2) -> { .sorted((m1, m2) -> {
@ -182,7 +264,7 @@ public class DashboardGateway implements DashboardReader {
LocalDateTime time2 = m2.getUpdatedAt() != null ? m2.getUpdatedAt() : m2.getCreatedAt(); LocalDateTime time2 = m2.getUpdatedAt() != null ? m2.getUpdatedAt() : m2.getCreatedAt();
return time2.compareTo(time1); // 최신순 return time2.compareTo(time1); // 최신순
}) })
.limit(10) // limit 제거 - 상위 메서드에서 필터링
.map(MinutesEntity::toDomain) .map(MinutesEntity::toDomain)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

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에 논의+결정 내용 저장 확인