Compare commits

..

No commits in common. "db16306b064a7c1c0e6d7858b05d4d9283395589" and "7e88cdceee522d1ff0a1c48ed05c94c677c92abe" have entirely different histories.

9 changed files with 49 additions and 16570 deletions

View File

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

View File

@ -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

View File

@ -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 it is too large Load Diff

View File

@ -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,19 +106,13 @@ 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());
@ -171,54 +164,10 @@ public class EndMeetingService implements EndMeetingUseCase {
.build();
}
/**
* 통합 회의록 생성 또는 조회
* userId가 NULL인 회의록 = AI 통합 회의록
*/
private MinutesEntity getOrCreateConsolidatedMinutes(MeetingEntity meeting) {
// userId가 NULL인 회의록 찾기 (AI 통합 회의록)
Optional<MinutesEntity> existing = minutesRepository
.findByMeetingIdAndUserIdIsNull(meeting.getMeetingId());
if (existing.isPresent()) {
log.debug("기존 통합 회의록 사용 - minutesId: {}", existing.get().getMinutesId());
return existing.get();
}
// 없으면 새로 생성
MinutesEntity consolidatedMinutes = MinutesEntity.builder()
.minutesId(UUID.randomUUID().toString())
.meetingId(meeting.getMeetingId())
.userId(null) // NULL = AI 통합 회의록
.title(meeting.getTitle() + " - AI 통합 회의록")
.status("FINALIZED")
.version(1)
.createdBy("AI")
.build();
MinutesEntity saved = minutesRepository.save(consolidatedMinutes);
log.info("통합 회의록 생성 완료 - minutesId: {}", saved.getMinutesId());
return saved;
}
/**
* 통합 회의록에 전체 결정사항 저장
*/
private void saveConsolidatedDecisions(MinutesEntity minutes, String decisions) {
if (decisions != null && !decisions.trim().isEmpty()) {
minutes.updateDecisions(decisions);
minutesRepository.save(minutes);
log.info("Minutes에 전체 결정사항 저장 완료 - minutesId: {}, 길이: {}",
minutes.getMinutesId(), decisions.length());
} else {
log.warn("저장할 결정사항이 없음 - minutesId: {}", minutes.getMinutesId());
}
}
/**
* AI 분석 결과 저장
*/
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()
@ -254,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());
@ -264,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()) {
@ -280,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())
@ -298,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());
}
/**

View File

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

View File

@ -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)

View File

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

View File

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