mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 18:26:23 +00:00
Compare commits
12 Commits
ec73def9d1
...
af53c80439
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af53c80439 | ||
|
|
3d6742505a | ||
|
|
44f02a2cc6 | ||
|
|
de6c68d4d1 | ||
|
|
a2ef408a85 | ||
|
|
c4bd8064ec | ||
|
|
5515909206 | ||
|
|
e1741c707e | ||
|
|
599f880e81 | ||
|
|
4d4fd5cd32 | ||
|
|
db16306b06 | ||
|
|
b5159ef74e |
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
Binary file not shown.
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.11.pdf
Normal file
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.11.pdf
Normal file
Binary file not shown.
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.11.pptx
Normal file
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.11.pptx
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 실제 회의 시간 계산
|
* 실제 회의 시간 계산
|
||||||
|
|||||||
@ -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 "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
274
meeting/src/test/resources/test-data-ai-minutes.sql
Normal file
274
meeting/src/test/resources/test-data-ai-minutes.sql
Normal 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에 논의+결정 내용 저장 확인
|
||||||
Loading…
x
Reference in New Issue
Block a user