mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 11:26:25 +00:00
Compare commits
No commits in common. "af53c80439fb8177fcd00e6ef61ccdfa3aefa3d3" and "ec73def9d17fdf9b2e92efe420ec1dd14a4b09b7" have entirely different histories.
af53c80439
...
ec73def9d1
@ -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 = 25000 # 회의록 통합을 위해 25000으로 증가
|
||||
claude_max_tokens: int = 8192 # 4096 → 8192 증가 (더 많은 제안사항 생성 가능)
|
||||
claude_temperature: float = 0.7
|
||||
|
||||
# Redis
|
||||
|
||||
@ -19,9 +19,7 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
||||
f"{i+1}. {agenda}" for i, agenda in enumerate(agendas)
|
||||
])
|
||||
|
||||
prompt = f"""당신은 회의록 작성 전문가이며 JSON 생성 전문가입니다. 여러 참석자가 작성한 회의록을 통합하여 정확하고 체계적인 회의록을 생성해주세요.
|
||||
|
||||
**매우 중요**: 응답은 반드시 유효한 JSON 형식이어야 합니다. 문자열 내의 모든 특수문자를 올바르게 이스케이프해야 합니다.
|
||||
prompt = f"""당신은 회의록 작성 전문가입니다. 여러 참석자가 작성한 회의록을 통합하여 정확하고 체계적인 회의록을 생성해주세요.
|
||||
|
||||
# 입력 데이터
|
||||
|
||||
@ -42,11 +40,7 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
||||
3. **회의 전체 결정사항 (decisions)**:
|
||||
- 회의 전체에서 최종 결정된 사항들을 TEXT 형식으로 정리
|
||||
- 안건별 결정사항을 모두 포함하여 회의록 수정 페이지에서 사용자가 확인 및 수정할 수 있도록 작성
|
||||
- 형식: "안건1 결정사항:\n- 결정1\n- 결정2\n\n안건2 결정사항:\n- 결정3"
|
||||
- **JSON 이스케이프 필수**:
|
||||
* 큰따옴표(")는 반드시 제거하거나 작은따옴표(')로 대체
|
||||
* 줄바꿈은 \\n으로 이스케이프
|
||||
* 역슬래시(\\)는 \\\\로 이스케이프
|
||||
- 형식: "**안건1 결정사항:**\n- 결정1\n- 결정2\n\n**안건2 결정사항:**\n- 결정3"
|
||||
|
||||
4. **안건별 요약 (agenda_summaries)**:
|
||||
회의 내용을 분석하여 안건별로 구조화:
|
||||
@ -57,9 +51,8 @@ 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"]
|
||||
* 회의에서 최종 결정된 사항만 포함
|
||||
@ -78,28 +71,22 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
||||
|
||||
# 출력 형식
|
||||
|
||||
**매우 중요 - JSON 생성 규칙**:
|
||||
1. JSON 외의 다른 텍스트는 절대 포함하지 마세요
|
||||
2. 문자열 내부의 큰따옴표(")는 반드시 작은따옴표(')로 대체하세요
|
||||
3. 문자열 내부의 줄바꿈은 반드시 \\n으로 이스케이프하세요
|
||||
4. 역슬래시(\\)는 \\\\로 이스케이프하세요
|
||||
5. 모든 문자열이 끝까지 올바르게 닫혀 있는지 확인하세요
|
||||
6. 각 문자열 값이 유효한 JSON 문자열인지 검증하세요
|
||||
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요.
|
||||
|
||||
```json
|
||||
{{
|
||||
"keywords": ["키워드1", "키워드2", "키워드3"],
|
||||
"statistics": {{
|
||||
"agendas_count": 2,
|
||||
"todos_count": 3
|
||||
"agendas_count": 숫자,
|
||||
"todos_count": 숫자
|
||||
}},
|
||||
"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": "짧은 요약",
|
||||
"summary": "논의 사항:\\n- 논의내용1\\n- 논의내용2\\n\\n결정 사항:\\n- 결정1\\n- 결정2",
|
||||
"summary_short": "짧은 요약 (20자 이내)",
|
||||
"summary": "**논의 사항:**\\n- 논의내용1\\n- 논의내용2\\n\\n**결정 사항:**\\n- 결정1\\n- 결정2",
|
||||
"decisions": ["결정사항1", "결정사항2"],
|
||||
"pending": ["보류사항"],
|
||||
"todos": [
|
||||
@ -126,27 +113,20 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
|
||||
3. **완전성**: 모든 필드를 빠짐없이 작성
|
||||
4. **구조화**: 안건별로 명확히 분리
|
||||
5. **결정사항 추출**:
|
||||
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식, \\n 이스케이프 필수)
|
||||
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식)
|
||||
- 안건별 결정사항(agenda_summaries[].decisions): 각 안건의 결정사항을 배열로 추출
|
||||
- 결정사항이 명확하게 언급된 경우에만 포함
|
||||
6. **summary 작성**:
|
||||
- summary_short: AI가 자동 생성한 1줄 요약 (사용자 수정 불가, 특수문자 이스케이프)
|
||||
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능, \\n 이스케이프 필수)
|
||||
- summary_short: AI가 자동 생성한 1줄 요약 (사용자 수정 불가)
|
||||
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능)
|
||||
- decisions: summary의 결정사항 부분을 배열로 별도 추출 (대시보드 표시용)
|
||||
7. **Todo 추출**:
|
||||
- 제목 필수, 담당자는 언급된 경우에만 추출
|
||||
- 자연스러운 표현에서 추출: "김대리가 ~하기로 함" → title: "~", assignee: "김대리"
|
||||
- 담당자가 없으면 assignee: "" (빈 문자열)
|
||||
8. **JSON 형식 엄수 - 가장 중요**:
|
||||
- 추가 설명, 주석, 서문 없이 JSON만 반환
|
||||
- 문자열 내 큰따옴표(")는 작은따옴표(')로 대체
|
||||
- 문자열 내 줄바꿈은 \\n으로 이스케이프
|
||||
- 역슬래시는 \\\\로 이스케이프
|
||||
- 모든 문자열을 올바르게 닫기
|
||||
- 생성 후 유효한 JSON인지 자체 검증
|
||||
8. **JSON만 출력**: 추가 설명 없이 JSON만 반환
|
||||
|
||||
**최종 지시**: 위 회의록들을 분석하여 **유효한 JSON 형식으로만** 통합 요약을 생성해주세요.
|
||||
JSON 파싱 오류가 발생하지 않도록 모든 특수문자를 올바르게 이스케이프하세요.
|
||||
이제 위 회의록들을 분석하여 통합 요약을 JSON 형식으로 생성해주세요.
|
||||
"""
|
||||
|
||||
return prompt
|
||||
|
||||
@ -43,7 +43,7 @@ class ClaudeService:
|
||||
]
|
||||
|
||||
# API 호출
|
||||
logger.info(f"Claude API 호출 시작 - Model: {self.model}, Max Tokens: {self.max_tokens}")
|
||||
logger.info(f"Claude API 호출 시작 - Model: {self.model}")
|
||||
|
||||
if system_prompt:
|
||||
response = self.client.messages.create(
|
||||
@ -63,12 +63,7 @@ class ClaudeService:
|
||||
|
||||
# 응답 텍스트 추출
|
||||
response_text = response.content[0].text
|
||||
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}"
|
||||
)
|
||||
logger.info(f"Claude API 응답 수신 완료 - Tokens: {response.usage.input_tokens + response.usage.output_tokens}")
|
||||
|
||||
# JSON 파싱
|
||||
# ```json ... ``` 블록 제거
|
||||
@ -77,113 +72,13 @@ 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"응답 텍스트 전체 길이: {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}")
|
||||
|
||||
logger.error(f"응답 텍스트: {response_text[:500]}...")
|
||||
raise ValueError(f"Claude API 응답을 JSON으로 파싱할 수 없습니다: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -41,6 +41,5 @@ public class MeetingEndDTO {
|
||||
@Builder
|
||||
public static class TodoSummaryDTO {
|
||||
private final String title;
|
||||
private final String assignee;
|
||||
}
|
||||
}
|
||||
@ -31,7 +31,6 @@ 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;
|
||||
|
||||
@ -107,25 +106,19 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request);
|
||||
log.info("AI Service 호출 완료 - 안건 수: {}", aiResponse.getAgendaSummaries().size());
|
||||
|
||||
// 6. 통합 회의록 생성 또는 조회
|
||||
MinutesEntity consolidatedMinutes = getOrCreateConsolidatedMinutes(meeting);
|
||||
// 6. AI 분석 결과 저장
|
||||
MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse);
|
||||
|
||||
// 7. 통합 회의록에 전체 결정사항 저장
|
||||
saveConsolidatedDecisions(consolidatedMinutes, aiResponse.getDecisions());
|
||||
|
||||
// 8. AI 분석 결과 저장
|
||||
MeetingAnalysis analysis = saveAnalysisResult(meeting, consolidatedMinutes, aiResponse);
|
||||
|
||||
// 9. Todo 생성 및 저장
|
||||
// 7. Todo 생성 및 저장
|
||||
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
|
||||
|
||||
// 10. 회의 종료 처리
|
||||
// 8. 회의 종료 처리
|
||||
meeting.end();
|
||||
meetingRepository.save(meeting);
|
||||
log.info("회의 상태 업데이트 완료 - status: {}", meeting.getStatus());
|
||||
|
||||
// 9. 응답 DTO 생성 (AI 응답의 todos를 그대로 사용)
|
||||
MeetingEndDTO result = createMeetingEndDTO(meeting, aiResponse, todos.size(), participantMinutesList.size());
|
||||
// 9. 응답 DTO 생성
|
||||
MeetingEndDTO result = createMeetingEndDTO(meeting, analysis, todos, participantMinutesList.size());
|
||||
|
||||
log.info("회의 종료 처리 완료 - meetingId: {}, 안건 수: {}, Todo 수: {}",
|
||||
meetingId, analysis.getAgendaAnalyses().size(), todos.size());
|
||||
@ -171,55 +164,10 @@ 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, MinutesEntity consolidatedMinutes, ConsolidateResponse aiResponse) {
|
||||
private MeetingAnalysis saveAnalysisResult(MeetingEntity meeting, ConsolidateResponse aiResponse) {
|
||||
// AgendaAnalysis 리스트 생성
|
||||
List<MeetingAnalysis.AgendaAnalysis> agendaAnalyses = aiResponse.getAgendaSummaries().stream()
|
||||
.<MeetingAnalysis.AgendaAnalysis>map(summary -> MeetingAnalysis.AgendaAnalysis.builder()
|
||||
@ -255,7 +203,7 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
analysisRepository.save(entity);
|
||||
|
||||
// AgendaSection 저장 (안건별 회의록)
|
||||
saveAgendaSections(meeting.getMeetingId(), consolidatedMinutes.getMinutesId(), aiResponse);
|
||||
saveAgendaSections(meeting.getMeetingId(), aiResponse);
|
||||
|
||||
log.info("AI 분석 결과 저장 완료 - analysisId: {}", analysis.getAnalysisId());
|
||||
|
||||
@ -265,7 +213,7 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
/**
|
||||
* AgendaSection 저장
|
||||
*/
|
||||
private void saveAgendaSections(String meetingId, String minutesId, ConsolidateResponse aiResponse) {
|
||||
private void saveAgendaSections(String meetingId, ConsolidateResponse aiResponse) {
|
||||
int agendaNumber = 1;
|
||||
|
||||
for (var summary : aiResponse.getAgendaSummaries()) {
|
||||
@ -281,7 +229,7 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
|
||||
AgendaSectionEntity agendaSection = AgendaSectionEntity.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.minutesId(minutesId) // 통합 회의록 ID
|
||||
.minutesId(meetingId) // AI 통합 회의록 ID로 사용
|
||||
.meetingId(meetingId)
|
||||
.agendaNumber(agendaNumber++)
|
||||
.agendaTitle(summary.getAgendaTitle())
|
||||
@ -299,8 +247,7 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
log.info("AgendaSection 저장 완료 - meetingId: {}, minutesId: {}, count: {}",
|
||||
meetingId, minutesId, aiResponse.getAgendaSummaries().size());
|
||||
log.info("AgendaSection 저장 완료 - meetingId: {}, count: {}", meetingId, aiResponse.getAgendaSummaries().size());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -309,8 +256,7 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
private List<TodoEntity> createAndSaveTodos(MeetingEntity meeting, ConsolidateResponse aiResponse, MeetingAnalysis analysis) {
|
||||
List<TodoEntity> todos = aiResponse.getAgendaSummaries().stream()
|
||||
.<TodoEntity>flatMap(agenda -> {
|
||||
// 안건 번호를 description에 저장하여 나중에 필터링에 사용
|
||||
Integer agendaNumber = agenda.getAgendaNumber();
|
||||
// agendaId는 향후 Todo와 안건 매핑에 사용될 수 있음 (현재는 사용하지 않음)
|
||||
List<ExtractedTodoDTO> todoList = agenda.getTodos() != null ? agenda.getTodos() : List.of();
|
||||
return todoList.stream()
|
||||
.<TodoEntity>map(todo -> TodoEntity.builder()
|
||||
@ -318,7 +264,6 @@ 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());
|
||||
@ -345,36 +290,33 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의 종료 결과 DTO 생성 (AI 응답 직접 사용)
|
||||
* 회의 종료 결과 DTO 생성
|
||||
*/
|
||||
private MeetingEndDTO createMeetingEndDTO(MeetingEntity meeting, ConsolidateResponse aiResponse,
|
||||
int todoCount, int participantCount) {
|
||||
private MeetingEndDTO createMeetingEndDTO(MeetingEntity meeting, MeetingAnalysis analysis,
|
||||
List<TodoEntity> todos, int participantCount) {
|
||||
// 회의 소요 시간 계산
|
||||
int durationMinutes = calculateDurationMinutes(meeting.getStartedAt(), meeting.getEndedAt());
|
||||
|
||||
// AI 응답의 안건 정보를 그대로 DTO로 변환 (todos 포함)
|
||||
List<MeetingEndDTO.AgendaSummaryDTO> agendaSummaries = aiResponse.getAgendaSummaries().stream()
|
||||
// 안건별 요약 DTO 생성
|
||||
List<MeetingEndDTO.AgendaSummaryDTO> agendaSummaries = analysis.getAgendaAnalyses().stream()
|
||||
.<MeetingEndDTO.AgendaSummaryDTO>map(agenda -> {
|
||||
// 안건별 todos 변환
|
||||
List<MeetingEndDTO.TodoSummaryDTO> todoList = new ArrayList<>();
|
||||
if (agenda.getTodos() != null) {
|
||||
for (ExtractedTodoDTO todo : agenda.getTodos()) {
|
||||
todoList.add(MeetingEndDTO.TodoSummaryDTO.builder()
|
||||
// 해당 안건의 Todo 필터링 (agendaId가 없을 수 있음)
|
||||
List<MeetingEndDTO.TodoSummaryDTO> agendaTodos = todos.stream()
|
||||
.filter(todo -> agenda.getAgendaId().equals(todo.getMinutesId())) // 임시 매핑
|
||||
.<MeetingEndDTO.TodoSummaryDTO>map(todo -> MeetingEndDTO.TodoSummaryDTO.builder()
|
||||
.title(todo.getTitle())
|
||||
.assignee(todo.getAssignee())
|
||||
.build());
|
||||
}
|
||||
}
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return MeetingEndDTO.AgendaSummaryDTO.builder()
|
||||
.title(agenda.getAgendaTitle())
|
||||
.aiSummaryShort(agenda.getSummaryShort())
|
||||
.title(agenda.getTitle())
|
||||
.aiSummaryShort(agenda.getAiSummaryShort())
|
||||
.details(MeetingEndDTO.AgendaDetailsDTO.builder()
|
||||
.discussion(agenda.getSummary())
|
||||
.discussion(agenda.getDiscussion())
|
||||
.decisions(agenda.getDecisions())
|
||||
.pending(agenda.getPending())
|
||||
.build())
|
||||
.todos(todoList)
|
||||
.todos(agendaTodos)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
@ -383,9 +325,9 @@ public class EndMeetingService implements EndMeetingUseCase {
|
||||
.title(meeting.getTitle())
|
||||
.participantCount(participantCount)
|
||||
.durationMinutes(durationMinutes)
|
||||
.agendaCount(aiResponse.getAgendaSummaries().size())
|
||||
.todoCount(todoCount)
|
||||
.keywords(aiResponse.getKeywords())
|
||||
.agendaCount(analysis.getAgendaAnalyses().size())
|
||||
.todoCount(todos.size())
|
||||
.keywords(analysis.getKeywords())
|
||||
.agendaSummaries(agendaSummaries)
|
||||
.build();
|
||||
}
|
||||
|
||||
@ -3,12 +3,15 @@ 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;
|
||||
@ -18,6 +21,7 @@ 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;
|
||||
@ -36,10 +40,17 @@ 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;
|
||||
|
||||
/**
|
||||
@ -58,6 +69,7 @@ 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;
|
||||
@ -107,9 +119,6 @@ public class MinutesController {
|
||||
.filter(item -> filterByParticipationType(item, participationType))
|
||||
.filter(item -> filterBySearch(item, search))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 필터링 후 정렬 적용 (프론트엔드 정렬과 일치)
|
||||
applySorting(filteredMinutes, sortBy, sortDir);
|
||||
|
||||
// 통계 계산 (전체 데이터 기준)
|
||||
MinutesListResponse.Statistics stats = calculateRealStatistics(userId, participationType);
|
||||
@ -469,8 +478,7 @@ public class MinutesController {
|
||||
case "title":
|
||||
return Sort.by(direction, "title");
|
||||
case "meeting":
|
||||
// 회의 일시로 정렬 - DB에 meetingDate 필드가 없으므로 createdAt 사용
|
||||
return Sort.by(direction, "createdAt");
|
||||
return Sort.by(direction, "createdAt"); // 회의 일시로 정렬 (임시로 생성일시 사용)
|
||||
case "modified":
|
||||
default:
|
||||
return Sort.by(direction, "lastModifiedAt");
|
||||
@ -523,38 +531,16 @@ public class MinutesController {
|
||||
|
||||
private MinutesListResponse.MinutesItem convertToMinutesItem(MinutesDTO minutesDTO, String userId) {
|
||||
// 검증완료율 계산 (작성중 상태일 때는 verificationRate 사용, 확정완료시 100%)
|
||||
int verificationRate;
|
||||
int completionRate;
|
||||
if ("DRAFT".equals(minutesDTO.getStatus()) && minutesDTO.getVerificationRate() != null) {
|
||||
verificationRate = minutesDTO.getVerificationRate();
|
||||
completionRate = minutesDTO.getVerificationRate();
|
||||
} else if ("FINALIZED".equals(minutesDTO.getStatus())) {
|
||||
verificationRate = 100;
|
||||
completionRate = 100;
|
||||
} else {
|
||||
// 기본값 0
|
||||
verificationRate = 0;
|
||||
completionRate = 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())
|
||||
.title(minutesDTO.getTitle())
|
||||
@ -563,16 +549,14 @@ public class MinutesController {
|
||||
.version(minutesDTO.getVersion())
|
||||
.createdAt(minutesDTO.getCreatedAt())
|
||||
.lastModifiedAt(minutesDTO.getLastModifiedAt())
|
||||
.meetingDate(meetingDateTime)
|
||||
.meetingTime(meetingTime)
|
||||
.meetingDate(minutesDTO.getCreatedAt()) // 임시로 생성일시 사용
|
||||
.createdBy(minutesDTO.getCreatedBy())
|
||||
.createdByName(creatorName)
|
||||
.lastModifiedBy(minutesDTO.getLastModifiedBy())
|
||||
.participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 0)
|
||||
.todoCount(minutesDTO.getTodoCount())
|
||||
.completedTodoCount(minutesDTO.getCompletedTodoCount())
|
||||
.verificationRate(verificationRate)
|
||||
.isCreator(minutesDTO.getCreatedBy().equals(userId)) // 현재 사용자가 생성자인지 확인
|
||||
.completionRate(completionRate)
|
||||
.isCreatedByUser(minutesDTO.getCreatedBy().equals(userId)) // 현재 사용자가 생성자인지 확인
|
||||
.build();
|
||||
}
|
||||
|
||||
@ -601,9 +585,9 @@ public class MinutesController {
|
||||
}
|
||||
|
||||
if ("created".equals(participationType)) {
|
||||
return item.isCreator(); // 사용자가 생성한 회의록만
|
||||
return item.isCreatedByUser(); // 사용자가 생성한 회의록만
|
||||
} else if ("attended".equals(participationType)) {
|
||||
return !item.isCreator(); // 사용자가 참여만 한 회의록
|
||||
return !item.isCreatedByUser(); // 사용자가 참여만 한 회의록
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -622,7 +606,7 @@ public class MinutesController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 적용 (필터링 후)
|
||||
* 정렬 적용
|
||||
*/
|
||||
private void applySorting(List<MinutesListResponse.MinutesItem> items, String sortBy, String sortDir) {
|
||||
boolean ascending = "asc".equalsIgnoreCase(sortDir);
|
||||
@ -634,20 +618,78 @@ 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()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 계산
|
||||
*/
|
||||
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) {
|
||||
try {
|
||||
@ -909,6 +951,11 @@ 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)) {
|
||||
@ -942,6 +989,38 @@ 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 요약에서 핵심내용 추출
|
||||
@ -965,6 +1044,51 @@ 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를 통한 키워드 추출 로직 구현
|
||||
@ -1137,6 +1261,41 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 회의 시간 계산
|
||||
|
||||
@ -46,32 +46,12 @@ 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 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 "";
|
||||
}
|
||||
private int completionRate; // 검증완료율
|
||||
private boolean isCreatedByUser; // 사용자가 생성한 회의록 여부
|
||||
}
|
||||
}
|
||||
@ -57,13 +57,6 @@ 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)
|
||||
|
||||
@ -156,4 +156,4 @@ azure:
|
||||
ai:
|
||||
service:
|
||||
url: ${AI_SERVICE_URL:http://localhost:8087}
|
||||
timeout: ${AI_SERVICE_TIMEOUT:60000}
|
||||
timeout: ${AI_SERVICE_TIMEOUT:30000}
|
||||
|
||||
@ -1,274 +0,0 @@
|
||||
-- ========================================
|
||||
-- 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에 논의+결정 내용 저장 확인
|
||||
Loading…
x
Reference in New Issue
Block a user