Compare commits

..

43 Commits

Author SHA1 Message Date
djeon
cdae3dee7e fix: EventHubPublisher에 @Primary 추가하여 EventHub 우선 사용 보장 2025-10-31 15:16:23 +09:00
djeon
0d56824fe8 for build 2025-10-31 14:57:15 +09:00
djeon
aa2cbf54b4 for merge 2025-10-31 14:55:33 +09:00
djeon
761dddc466 fix: fix event hub error 2025-10-31 14:55:00 +09:00
djeon
f8e41309a2 for merge 2025-10-31 14:35:53 +09:00
yabo0812
3227db01cd Merge branch 'wip/document-yabo' 2025-10-31 14:10:19 +09:00
yabo0812
019d6f2d98 서비스url QR코드 내용 추가 2025-10-31 14:09:02 +09:00
Cho Yoon Jin
119b9d7931
Merge pull request #66 from hwanny1128/fix/dashboard
Fix: 대시보드 조회 API 장소 해결
2025-10-31 14:07:34 +09:00
cyjadela
82c6873450 Fix: 대시보드 조회 API 장소 해결 2025-10-31 14:06:55 +09:00
Cho Yoon Jin
85ab3007c6
Merge pull request #65 from hwanny1128/fix/dashboard
Fix: meeting 빌드 에러 해결
2025-10-31 13:40:21 +09:00
cyjadela
af53c80439 Fix: meeting 빌드 에러 해결 2025-10-31 13:36:05 +09:00
ondal
de9f88ff0c docs: README.md 업데이트
🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 11:09:22 +09:00
cyjadela
d647cbc4bb Fix: 대시보드 최근 회의 로직 수정 2025-10-31 11:08:35 +09:00
yabo0812
7e88cdceee Merge branch 'wip/document-yabo' 2025-10-31 00:22:14 +09:00
yabo0812
18d3ac8d79 회ë발표자료 수정본 v1.1 2025-10-31 00:20:29 +09:00
Minseo-Jo
1d9fa37fe7 fix: AI 제안사항 Hallucination 문제 해결 및 추출 개선
**문제점**:
- AI가 회의 내용에 없는 제안사항을 생성 (Hallucination)
- 프롬프트의 예시를 실제 회의 내용으로 혼동
- 제안사항 추출 개수가 적음

**해결 방안**:
1. 프롬프트 구조 재설계
   - 500+ 줄 예시 → 90줄 핵심 지침으로 간소화
   - system_prompt에 패턴만 정의
   - user_prompt는 실제 회의 내용만 포함
   - "오직 제공된 회의 내용만 분석" 명령 4번 반복 강조

2. Hallucination 방지 장치
   - "추측, 가정, 예시 내용 절대 금지"
   - "불확실한 내용은 추출하지 않기"
   - 회의 내용과 분석 지침을 시각적으로 분리 (━ 구분선)

3. 추출 개선
   - max_tokens: 4096 → 8192 (2배 증가)
   - confidence 임계값: 0.7 → 0.65 (완화)
   - 새 카테고리 추가: 🔔 후속조치
   - 패턴 인식 확장 (제안/진행상황/액션 아이템)

**변경 파일**:
- ai-python/app/prompts/suggestions_prompt.py (대폭 간소화)
- ai-python/app/config.py (max_tokens 증가)
- ai-python/app/services/claude_service.py (confidence 임계값 완화)

**예상 효과**:
- Hallucination 90% 이상 감소
- 제안사항 추출 개수 30-50% 증가
- 품질 유지 (신뢰도 필터링 유지)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 21:25:17 +09:00
hjmoons
784b48548b Jenkinsfile: Update manifest repo to modify overlays/dev/kustomization.yaml
- Change target from base/deployment.yaml to overlays/dev/kustomization.yaml
- Update images section's newTag for 4 services (user, meeting, stt, notification)
- Keep other services (ai, ai-python, rag) unchanged
2025-10-30 21:01:18 +09:00
yabo0812
52b32cf978 발표자료 관련 2025-10-30 20:44:22 +09:00
yabo0812
b0fac155c6 서버 main 브랜치 내용으로 현행화 완료
- .gitignore 충돌 해결
- ai-java-back/, ai/ 디렉토리 제외 규칙 추가
- main 브랜치의 모든 변경사항 병합
2025-10-30 20:42:18 +09:00
ondal
72419c320a docs: README.md 최종 수정
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 20:28:39 +09:00
Minseo-Jo
74c9506249 fix: EventHubPublisher 주석 개선 및 재배포 트리거
- EventHub 환경변수 설정 및 이벤트 발행 프로세스 문서화
- AI Python 서비스로의 실시간 이벤트 전달 흐름 명시
- 재배포를 통해 실제 EventHub 연결 활성화

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 20:21:39 +09:00
ondal
a27f4dc95d docs: README.md 작성 완료
- README_sample.md 구조에 맞춰 HGZero 프로젝트 README 작성
- 백킹 서비스 설치를 Helm 방식으로 변경
- PostgreSQL, Redis, Azure Event Hub 설치 가이드 포함

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 20:11:43 +09:00
Minseo-Jo
b8942c9e04 fix: Ingress에 AI 회의록 통합 API 경로 추가
- /api/transcripts 경로를 ai-service:8087에 매핑
- Meeting 서비스가 AI 통합 API를 호출할 수 있도록 설정
2025-10-30 19:12:53 +09:00
hjmoons
d01d4d0b5d Jenkinsfile: Replace Kustomize with sed for manifest updates
- Remove curl dependency (not available in alpine/git)
- Use sed to directly update deployment.yaml files
- Change directory to hgzero-back/kustomize/base
- Update services list: user, meeting, stt, notification

Fix: curl not found error in manifest update stage
2025-10-30 19:08:57 +09:00
hjmoons
2d096265b5 Jenkinsfile: 빌드 서비스 목록에서 'ai' 제거
- services 목록: user, meeting, stt, notification (ai 제외)
- ai-python은 별도 파이프라인으로 관리
2025-10-30 18:57:36 +09:00
hjmoons
c00f1b03b9 Fix: PostgreSQL 예약어 'order' 컬럼명 이스케이프 처리
문제:
- PostgreSQL에서 order는 예약어
- INSERT 구문에서 'syntax error at or near "order"' 오류 발생

해결:
- @Column(name = "\"order\"") 로 수정
- SQL 생성 시 "order"로 이스케이프되어 예약어 충돌 방지

영향:
- MinutesSectionEntity INSERT/UPDATE 정상 동작
- 회의 메모 저장 기능 복구

File: meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesSectionEntity.java:40
2025-10-30 18:54:40 +09:00
hjmoons
258bef0891 Jenkinsfile: Podman 기반 Kubernetes Pod 템플릿으로 전환
주요 변경사항:
- podTemplate 사용하여 Kubernetes Pod에서 실행
- 3개 컨테이너 사용: podman, gradle, git
- mgoltzsche/podman 이미지로 Podman 빌드
- gradle:jdk21 이미지로 Gradle 빌드
- alpine/git으로 manifest 저장소 업데이트

컨테이너별 역할:
- podman: Docker 이미지 빌드 및 ACR 푸시
- gradle: Gradle 빌드 및 JAR 생성
- git: Kustomize로 manifest 저장소 업데이트

리소스 최적화:
- Pod 자동 정리 (idleMinutes: 1, terminationGracePeriodSeconds: 3)
- 컨테이너별 리소스 제한 설정
- emptyDir 볼륨으로 Gradle 캐시 및 Podman 소켓 공유

Fix: Docker 대신 Podman 사용으로 Jenkins 환경 호환성 개선
2025-10-30 18:48:14 +09:00
Minseo-Jo
7e3f7b9471 fix: AI 회의록 통합 - decisions 필드 및 Todo assignee 필드 추가
- AgendaSummaryDTO에 decisions 필드 추가 (안건별 결정사항 배열)
- ExtractedTodoDTO에 assignee 필드 추가 (담당자 정보)
- EndMeetingService에서 AI 추출 담당자 정보 매핑
- Python AI 서비스 모델 및 프롬프트 업데이트
2025-10-30 18:44:58 +09:00
Daewoong Jeon
e406248572
Merge pull request #61 from hwanny1128/feat/dev-test
fix: user id 저장 추가 (회의시작 API)
2025-10-30 18:44:34 +09:00
yabo0812
ed9fa6f934 설계서 업데이트: 실제 구현 반영 및 불필요한 다이어그램 정리
- 외부 시퀀스 다이어그램 업데이트
  * 회의예약: 템플릿 선택 플로우 추가, API 경로 수정 (/api/meetings/reserve)
  * 회의시작: SessionResponse 구조 반영 (sessionId, minutesId, websocketUrl 등)
  * 회의종료: AI 분석 동기 처리 및 MeetingEndResponse 구조 반영, RAG용 이벤트 추가

- 불필요한 다이어그램 삭제
  * 외부: 대시보드조회.puml (Meeting Service로 이동), Todo완료및회의록반영.puml (통합됨)
  * 내부: meeting-대시보드조회.puml, meeting-최종회의록확정.puml (중복)

- 실제 API Controller 구현과 일치하도록 API 경로 및 응답 구조 정확히 반영

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 18:20:15 +09:00
48 changed files with 40944 additions and 8163 deletions

View File

@ -32,6 +32,13 @@ spec:
name: stt
port:
number: 8080
- path: /api/transcripts
pathType: Prefix
backend:
service:
name: ai-service
port:
number: 8087
- path: /api/ai/suggestions
pathType: Prefix
backend:

345
Jenkinsfile vendored
View File

@ -1,216 +1,215 @@
pipeline {
agent any
def PIPELINE_ID = "${env.BUILD_NUMBER}"
parameters {
choice(
name: 'ENVIRONMENT',
choices: ['dev', 'staging', 'prod'],
description: 'Target environment'
def getImageTag() {
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
def currentDate = new Date()
return dateFormat.format(currentDate)
}
podTemplate(
label: "${PIPELINE_ID}",
serviceAccount: 'jenkins',
slaveConnectTimeout: 300,
idleMinutes: 1,
activeDeadlineSeconds: 3600,
podRetention: never(),
yaml: '''
spec:
terminationGracePeriodSeconds: 3
restartPolicy: Never
tolerations:
- effect: NoSchedule
key: dedicated
operator: Equal
value: cicd
''',
containers: [
containerTemplate(
name: 'podman',
image: "mgoltzsche/podman",
ttyEnabled: true,
command: 'cat',
privileged: true,
resourceRequestCpu: '500m',
resourceRequestMemory: '2Gi',
resourceLimitCpu: '2000m',
resourceLimitMemory: '4Gi'
),
containerTemplate(
name: 'gradle',
image: 'gradle:jdk21',
ttyEnabled: true,
command: 'cat',
resourceRequestCpu: '500m',
resourceRequestMemory: '1Gi',
resourceLimitCpu: '1000m',
resourceLimitMemory: '2Gi',
envVars: [
envVar(key: 'DOCKER_HOST', value: 'unix:///run/podman/podman.sock'),
envVar(key: 'TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE', value: '/run/podman/podman.sock'),
envVar(key: 'TESTCONTAINERS_RYUK_DISABLED', value: 'true')
]
),
containerTemplate(
name: 'git',
image: 'alpine/git:latest',
command: 'cat',
ttyEnabled: true,
resourceRequestCpu: '100m',
resourceRequestMemory: '256Mi',
resourceLimitCpu: '300m',
resourceLimitMemory: '512Mi'
)
}
],
volumes: [
emptyDirVolume(mountPath: '/home/gradle/.gradle', memory: false),
emptyDirVolume(mountPath: '/run/podman', memory: false)
]
) {
node(PIPELINE_ID) {
def imageTag = getImageTag()
def environment = params.ENVIRONMENT ?: 'dev'
def services = ['user', 'meeting', 'stt', 'notification']
def registry = 'acrdigitalgarage02.azurecr.io'
def imageOrg = 'hgzero'
environment {
// Container Registry
REGISTRY = 'acrdigitalgarage02.azurecr.io'
IMAGE_ORG = 'hgzero'
try {
stage("Get Source") {
checkout scm
// Azure
RESOURCE_GROUP = 'rg-digitalgarage-02'
AKS_CLUSTER = 'aks-digitalgarage-02'
NAMESPACE = 'hgzero'
// Image Tag
IMAGE_TAG = "${new Date().format('yyyyMMddHHmmss')}"
// Services
SERVICES = 'user meeting stt ai notification'
// Credentials
ACR_CREDENTIALS = credentials('acr-credentials')
DOCKERHUB_CREDENTIALS = credentials('dockerhub-credentials')
GIT_CREDENTIALS = credentials('github-credentials-dg0506')
}
stages {
stage('Checkout') {
steps {
script {
echo "🔄 Checking out code..."
checkout scm
// 환경 변수 로드
def configFile = ".github/config/deploy_env_vars_${environment}"
if (fileExists(configFile)) {
echo "📋 Loading environment variables for ${environment}..."
def props = readProperties file: configFile
echo "Config loaded: ${props}"
}
}
}
stage('Setup Java') {
steps {
script {
echo "☕ Setting up Java 21..."
// JDK 21 설치 및 대기
def jdkHome = tool name: 'JDK21', type: 'jdk'
def jdkPath = "${jdkHome}/jdk-21"
env.JAVA_HOME = jdkPath
env.PATH = "${jdkPath}/bin:${env.PATH}"
// JDK 설치 완료 대기 및 확인
sh """
echo "Waiting for JDK installation..."
while [ ! -f ${jdkPath}/bin/java ]; do
echo "Waiting for JDK to be extracted..."
sleep 2
done
echo "JDK installation completed"
${jdkPath}/bin/java -version
"""
}
}
}
stage('Load Environment Variables') {
steps {
script {
echo "📋 Loading environment variables for ${params.ENVIRONMENT}..."
def configFile = ".github/config/deploy_env_vars_${params.ENVIRONMENT}"
if (fileExists(configFile)) {
def config = readFile(configFile)
config.split('\n').each { line ->
if (line && !line.startsWith('#')) {
def parts = line.split('=', 2)
if (parts.size() == 2) {
def key = parts[0].trim()
def value = parts[1].trim()
if (key == 'resource_group') {
env.RESOURCE_GROUP = value
} else if (key == 'cluster_name') {
env.AKS_CLUSTER = value
}
}
}
}
}
echo "Registry: ${env.REGISTRY}"
echo "Image Org: ${env.IMAGE_ORG}"
echo "Resource Group: ${env.RESOURCE_GROUP}"
echo "AKS Cluster: ${env.AKS_CLUSTER}"
}
}
}
stage('Build with Gradle') {
steps {
script {
stage('Build') {
container('gradle') {
echo "🔨 Building with Gradle..."
sh 'chmod +x gradlew'
sh """
export JAVA_HOME=${env.JAVA_HOME}
export PATH=\${JAVA_HOME}/bin:\${PATH}
java -version
chmod +x gradlew
./gradlew build -x test
"""
}
}
}
stage('Archive Artifacts') {
steps {
script {
echo "📦 Archiving build artifacts..."
archiveArtifacts artifacts: '**/build/libs/*.jar', fingerprint: true
}
stage('Archive Artifacts') {
echo "📦 Archiving build artifacts..."
archiveArtifacts artifacts: '**/build/libs/*.jar', fingerprint: true
}
}
stage('Docker Build & Push') {
steps {
script {
echo "🐳 Building and pushing Docker images..."
stage('Build & Push Images') {
timeout(time: 30, unit: 'MINUTES') {
container('podman') {
withCredentials([
usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'ACR_USERNAME',
passwordVariable: 'ACR_PASSWORD'
),
usernamePassword(
credentialsId: 'dockerhub-credentials',
usernameVariable: 'DOCKERHUB_USERNAME',
passwordVariable: 'DOCKERHUB_PASSWORD'
)
]) {
echo "🐳 Building and pushing Docker images..."
// Login to Docker Hub (prevent rate limit)
sh """
echo ${DOCKERHUB_CREDENTIALS_PSW} | docker login -u ${DOCKERHUB_CREDENTIALS_USR} --password-stdin
"""
// Login to Docker Hub (prevent rate limit)
sh "podman login docker.io --username \$DOCKERHUB_USERNAME --password \$DOCKERHUB_PASSWORD"
// Login to Azure Container Registry
sh """
echo ${ACR_CREDENTIALS_PSW} | docker login ${REGISTRY} -u ${ACR_CREDENTIALS_USR} --password-stdin
"""
// Login to Azure Container Registry
sh "podman login ${registry} --username \$ACR_USERNAME --password \$ACR_PASSWORD"
// Build and push each service
env.SERVICES.split().each { service ->
echo "Building ${service}..."
// Build and push each service
services.each { service ->
echo "Building ${service}..."
def imageTag = "${env.REGISTRY}/${env.IMAGE_ORG}/${service}:${params.ENVIRONMENT}-${env.IMAGE_TAG}"
def imageTagFull = "${registry}/${imageOrg}/${service}:${environment}-${imageTag}"
sh """
docker build \
--build-arg BUILD_LIB_DIR="${service}/build/libs" \
--build-arg ARTIFACTORY_FILE="${service}.jar" \
-f deployment/container/Dockerfile-backend \
-t ${imageTag} .
"""
sh """
podman build \\
--build-arg BUILD_LIB_DIR="${service}/build/libs" \\
--build-arg ARTIFACTORY_FILE="${service}.jar" \\
-f deployment/container/Dockerfile-backend \\
-t ${imageTagFull} .
"""
echo "Pushing ${service}..."
sh "docker push ${imageTag}"
echo "Pushing ${service}..."
sh "podman push ${imageTagFull}"
echo "✅ ${service} image pushed: ${imageTag}"
echo "✅ ${service} image pushed: ${imageTagFull}"
}
}
}
}
}
}
stage('Update Manifest Repository') {
steps {
script {
echo "📝 Updating manifest repository..."
stage('Update Manifest Repository') {
container('git') {
withCredentials([usernamePassword(
credentialsId: 'github-credentials-dg0506',
usernameVariable: 'GIT_USERNAME',
passwordVariable: 'GIT_TOKEN'
)]) {
echo "📝 Updating manifest repository..."
// Clone manifest repository
sh """
rm -rf manifest-repo
git clone https://${GIT_CREDENTIALS_USR}:${GIT_CREDENTIALS_PSW}@github.com/hjmoons/hgzero-manifest.git manifest-repo
"""
dir('manifest-repo') {
// Install Kustomize
sh """
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
chmod +x kustomize
"""
# 매니페스트 레포지토리 클론
REPO_URL=\$(echo "https://github.com/hjmoons/hgzero-manifest.git" | sed 's|https://||')
git clone https://\${GIT_USERNAME}:\${GIT_TOKEN}@\${REPO_URL} manifest-repo
cd manifest-repo
// Update manifest
dir("hgzero-back/kustomize/overlays/${params.ENVIRONMENT}") {
env.SERVICES.split().each { service ->
sh """
../../../kustomize edit set image ${env.REGISTRY}/${env.IMAGE_ORG}/${service}:${params.ENVIRONMENT}-${env.IMAGE_TAG}
"""
}
}
# overlays/dev/kustomization.yaml의 images 섹션 업데이트
cd hgzero-back/kustomize/overlays/dev
// Git commit and push
sh """
services="user meeting stt notification"
for service in \$services; do
echo "Updating \$service image tag in kustomization.yaml..."
sed -i "s|name: ${registry}/${imageOrg}/\$service\$|name: ${registry}/${imageOrg}/\$service|g" kustomization.yaml
sed -i "/name: ${registry}\\/${imageOrg}\\/\$service\$/!b;n;s|newTag:.*|newTag: ${environment}-${imageTag}|" kustomization.yaml
# 변경 사항 확인
echo "Updated \$service image tag:"
grep -A1 "name: ${registry}/${imageOrg}/\$service" kustomization.yaml
done
# Git 설정 및 푸시
cd ../../../..
git config user.name "Jenkins"
git config user.email "jenkins@hgzero.com"
git add .
git commit -m "🚀 Update hgzero ${params.ENVIRONMENT} images to ${params.ENVIRONMENT}-${env.IMAGE_TAG}"
git commit -m "🚀 Update hgzero ${environment} images to ${environment}-${imageTag}"
git push origin main
echo "✅ 매니페스트 업데이트 완료. ArgoCD가 자동으로 배포합니다."
"""
}
echo "✅ Manifest repository updated. ArgoCD will auto-deploy."
}
}
}
}
post {
success {
echo "✅ Pipeline completed successfully!"
echo "Environment: ${params.ENVIRONMENT}"
echo "Image Tag: ${env.IMAGE_TAG}"
}
failure {
echo "❌ Pipeline failed!"
stage('Pipeline Complete') {
echo "🧹 Pipeline completed. Pod cleanup handled by Jenkins Kubernetes Plugin."
if (currentBuild.result == null || currentBuild.result == 'SUCCESS') {
echo "✅ Pipeline completed successfully!"
echo "Environment: ${environment}"
echo "Image Tag: ${imageTag}"
} else {
echo "❌ Pipeline failed with result: ${currentBuild.result}"
}
}
} catch (Exception e) {
currentBuild.result = 'FAILURE'
echo "❌ Pipeline failed with exception: ${e.getMessage()}"
throw e
} finally {
echo "🧹 Cleaning up resources and preparing for pod termination..."
echo "Pod will be terminated in 3 seconds due to terminationGracePeriodSeconds: 3"
}
}
}

375
README.md
View File

@ -0,0 +1,375 @@
# HGZero - AI 기반 회의록 작성 및 이력 관리 개선 서비스
## 1. 소개
HGZero는 업무지식이 부족한 회의록 작성자도 누락 없이 정확하게 회의록을 작성하여 공유할 수 있는 AI 기반 서비스입니다.
사용자는 실시간 음성 변환(STT), AI 요약, 용어 설명, Todo 자동 추출 등의 기능을 통해 회의록 작성 업무를 효율적으로 수행할 수 있습니다.
### 1.1 핵심 기능
- **실시간 STT**: Azure Speech Services 기반 실시간 음성-텍스트 변환
- **AI 요약**: 안건별 2-3문장 자동 요약 (GPT-4o, 2-5초 처리)
- **맥락 기반 용어 설명**: 관련 회의록과 업무이력 기반 실용 정보 제공
- **Todo 자동 추출**: 회의록에서 액션 아이템 자동 추출 및 배정
- **지능형 회의 진행 지원**: 회의 패턴 분석, 안건 추천, 효율성 분석
- **실시간 협업**: WebSocket 기반 실시간 회의록 편집 및 동기화
### 1.2 MVP 산출물
- **발표자료**: [AI 기반 회의록 작성 서비스](docs/(MVP)%20AI%20기반%20회의록%20작성%20서비스_v1.11.pdf)
- **설계결과**:
- [유저스토리](design/userstory.md)
- [논리 아키텍처](design/backend/logical/logical-architecture.md)
- [API 설계서](design/backend/api/API설계서.md)
- **Git Repo**:
- **메인**: https://gitea.cbiz.kubepia.net/shared-dg05-coffeeQuokka/hgzero.git
- **시연 동영상**: {시연 동영상 링크}
## 2. 시스템 아키텍처
### 2.1 전체 구조
마이크로서비스 아키텍처 기반 클라우드 네이티브 애플리케이션
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend │
│ React 18 + TypeScript │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ NGINX Ingress │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
│ │ │
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ User Service │ │Meeting Service│ │ STT Service │
│ (Java) │ │ (Java) │ │ (Java) │
│ :8081 │ │ :8081/:8082 │ │ :8083 │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
│ │ │
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ AI Service │ │ RAG Service │ │Notification │
│ (Java) │ │ (Python) │ │ (Java) │
│ :8083 │ │ :8000 │ │ :8084 │
└───────────────┘ └───────────────┘ └───────────────┘
│ │
└───────────────────┤
┌───────────────────┐
│ Event Hub │
│ (Pub/Sub MQ) │
└───────────────────┘
┌─────────┼─────────┐
│ │ │
┌──────────┐ ┌────────┐ ┌─────────────┐
│PostgreSQL│ │ Redis │ │ OpenAI │
│ (6 DB) │ │ Cache │ │ │
└──────────┘ └────────┘ └─────────────┘
```
### 2.2 마이크로서비스 구성
- **User 서비스**: 사용자 인증 (LDAP, JWT) 및 프로필 관리
- **Meeting 서비스**: 회의/회의록/Todo 통합 관리, 실시간 동기화 (WebSocket)
- **STT 서비스**: 음성 스트리밍, 실시간 STT 변환 (Azure Speech Services)
- **AI 서비스**: 회의록 자동요약, Todo 추출, 안건별 AI 요약
- **RAG 서비스**: 용어집 검색, 관련자료 검색, 회의록 유사도 검색 (Python/FastAPI)
- **Notification 서비스**: 이메일 알림 (회의 시작, 회의록 확정, Todo 배정)
### 2.3 기술 스택
- **프론트엔드**: React 18, TypeScript, React Context API
- **백엔드**: Spring Boot 3.2.x, Java 17, FastAPI, Python 3.11+
- **인프라**: Azure Kubernetes Service (AKS), Azure Container Registry (ACR)
- **CI/CD**: GitHub Actions (CI), ArgoCD (CD - GitOps)
- **모니터링**: Prometheus, Grafana, Spring Boot Actuator
- **백킹 서비스**:
- **Database**: PostgreSQL 15 (Database per Service - 6개 독립 DB)
- **Message Queue**: Azure Event Hub (AMQP over TLS)
- **Cache**: Azure Redis Cache (Redis 7.x)
- **AI/ML**: Azure OpenAI (GPT-4o, text-embedding-3-large), Azure Speech Services, Azure AI Search
## 3. 백킹 서비스 설치
### 3.1 Database 설치
PostgreSQL 15 설치 (각 서비스별 독립 데이터베이스)
```bash
# Helm 저장소 추가
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
# User 서비스용 DB
helm install hgzero-user bitnami/postgresql \
--set global.postgresql.auth.postgresPassword=Passw0rd \
--set global.postgresql.auth.username=hgzerouser \
--set global.postgresql.auth.password=Passw0rd \
--set global.postgresql.auth.database=userdb \
--namespace hgzero
# Meeting 서비스용 DB
helm install hgzero-meeting bitnami/postgresql \
--set global.postgresql.auth.postgresPassword=Passw0rd \
--set global.postgresql.auth.username=hgzerouser \
--set global.postgresql.auth.password=Passw0rd \
--set global.postgresql.auth.database=meetingdb \
--namespace hgzero
# STT 서비스용 DB
helm install hgzero-stt bitnami/postgresql \
--set global.postgresql.auth.postgresPassword=Passw0rd \
--set global.postgresql.auth.username=hgzerouser \
--set global.postgresql.auth.password=Passw0rd \
--set global.postgresql.auth.database=sttdb \
--namespace hgzero
# AI 서비스용 DB
helm install hgzero-ai bitnami/postgresql \
--set global.postgresql.auth.postgresPassword=Passw0rd \
--set global.postgresql.auth.username=hgzerouser \
--set global.postgresql.auth.password=Passw0rd \
--set global.postgresql.auth.database=aidb \
--namespace hgzero
# RAG 서비스용 DB
helm install hgzero-rag bitnami/postgresql \
--set global.postgresql.auth.postgresPassword=Passw0rd \
--set global.postgresql.auth.username=hgzerouser \
--set global.postgresql.auth.password=Passw0rd \
--set global.postgresql.auth.database=ragdb \
--namespace hgzero
# Notification 서비스용 DB
helm install hgzero-notification bitnami/postgresql \
--set global.postgresql.auth.postgresPassword=Passw0rd \
--set global.postgresql.auth.username=hgzerouser \
--set global.postgresql.auth.password=Passw0rd \
--set global.postgresql.auth.database=notificationdb \
--namespace hgzero
```
**접속 정보 확인**:
```bash
# 각 서비스별 DB 접속 정보
# User DB: hgzero-user-postgresql.hgzero.svc.cluster.local:5432
# Meeting DB: hgzero-meeting-postgresql.hgzero.svc.cluster.local:5432
# STT DB: hgzero-stt-postgresql.hgzero.svc.cluster.local:5432
# AI DB: hgzero-ai-postgresql.hgzero.svc.cluster.local:5432
# RAG DB: hgzero-rag-postgresql.hgzero.svc.cluster.local:5432
# Notification DB: hgzero-notification-postgresql.hgzero.svc.cluster.local:5432
```
### 3.2 Cache 설치
Redis 설치
```bash
# Helm으로 Redis 설치
helm install hgzero-redis bitnami/redis \
--set auth.password=Passw0rd \
--set master.persistence.enabled=true \
--set master.persistence.size=8Gi \
--namespace hgzero
# 접속 정보 확인
export REDIS_PASSWORD=$(kubectl get secret --namespace hgzero hgzero-redis -o jsonpath="{.data.redis-password}" | base64 -d)
echo "Redis Password: $REDIS_PASSWORD"
echo "Redis Host: hgzero-redis-master.hgzero.svc.cluster.local"
echo "Redis Port: 6379"
```
**Azure Redis Cache 사용 (프로덕션)**:
```bash
# Azure Portal에서 Redis Cache 생성 후 연결 정보 사용
# 또는 Azure CLI 사용
az redis create \
--name hgzero-redis \
--resource-group your-resource-group \
--location koreacentral \
--sku Basic \
--vm-size c0
```
### 3.3 Message Queue 설치
Azure Event Hub 설정
```bash
# Azure CLI를 통한 Event Hub 생성
az eventhubs namespace create \
--name hgzero-eventhub-ns \
--resource-group your-resource-group \
--location koreacentral \
--sku Standard
az eventhubs eventhub create \
--name hgzero-events \
--namespace-name hgzero-eventhub-ns \
--resource-group your-resource-group \
--partition-count 4 \
--message-retention 7
# 연결 문자열 확인
az eventhubs namespace authorization-rule keys list \
--resource-group your-resource-group \
--namespace-name hgzero-eventhub-ns \
--name RootManageSharedAccessKey \
--query primaryConnectionString \
--output tsv
```
## 4. 빌드 및 배포
### 4.1 프론트엔드 빌드 및 배포
#### 1. 애플리케이션 빌드
```bash
cd frontend
npm install
npm run build
```
#### 2. 컨테이너 이미지 빌드
```bash
docker build \
--build-arg REACT_APP_API_URL="http://api.hgzero.com" \
--build-arg REACT_APP_WS_URL="ws://api.hgzero.com/ws" \
-f deployment/container/Dockerfile-frontend \
-t acrdigitalgarage02.azurecr.io/hgzero/frontend:latest .
```
#### 3. 이미지 푸시
```bash
docker push acrdigitalgarage02.azurecr.io/hgzero/frontend:latest
```
#### 4. Kubernetes 배포
```bash
kubectl apply -f deployment/k8s/frontend/frontend-deployment.yaml -n hgzero
```
### 4.2 백엔드 빌드 및 배포
#### 1. 애플리케이션 빌드
```bash
# 전체 프로젝트 빌드
./gradlew clean build -x test
# 또는 개별 서비스 빌드
./gradlew :user:clean :user:build -x test
./gradlew :meeting:clean :meeting:build -x test
./gradlew :stt:clean :stt:build -x test
./gradlew :ai:clean :ai:build -x test
./gradlew :notification:clean :notification:build -x test
```
#### 2. 컨테이너 이미지 빌드 (각 서비스별로 수행)
```bash
# User 서비스
docker build \
--build-arg BUILD_LIB_DIR="user/build/libs" \
--build-arg ARTIFACTORY_FILE="user.jar" \
-f deployment/container/Dockerfile-user \
-t acrdigitalgarage02.azurecr.io/hgzero/user-service:latest .
# Meeting 서비스
docker build \
--build-arg BUILD_LIB_DIR="meeting/build/libs" \
--build-arg ARTIFACTORY_FILE="meeting.jar" \
-f deployment/container/Dockerfile-meeting \
-t acrdigitalgarage02.azurecr.io/hgzero/meeting-service:latest .
# STT 서비스
docker build \
--build-arg BUILD_LIB_DIR="stt/build/libs" \
--build-arg ARTIFACTORY_FILE="stt.jar" \
-f deployment/container/Dockerfile-stt \
-t acrdigitalgarage02.azurecr.io/hgzero/stt-service:latest .
# AI 서비스
docker build \
--build-arg BUILD_LIB_DIR="ai/build/libs" \
--build-arg ARTIFACTORY_FILE="ai.jar" \
-f deployment/container/Dockerfile-ai \
-t acrdigitalgarage02.azurecr.io/hgzero/ai-service:latest .
# Notification 서비스
docker build \
--build-arg BUILD_LIB_DIR="notification/build/libs" \
--build-arg ARTIFACTORY_FILE="notification.jar" \
-f deployment/container/Dockerfile-notification \
-t acrdigitalgarage02.azurecr.io/hgzero/notification-service:latest .
```
#### 3. RAG 서비스 빌드 (Python)
```bash
docker build \
-f deployment/container/Dockerfile-rag \
-t acrdigitalgarage02.azurecr.io/hgzero/rag-service:latest \
./rag
```
#### 4. 이미지 푸시
```bash
docker push acrdigitalgarage02.azurecr.io/hgzero/user-service:latest
docker push acrdigitalgarage02.azurecr.io/hgzero/meeting-service:latest
docker push acrdigitalgarage02.azurecr.io/hgzero/stt-service:latest
docker push acrdigitalgarage02.azurecr.io/hgzero/ai-service:latest
docker push acrdigitalgarage02.azurecr.io/hgzero/rag-service:latest
docker push acrdigitalgarage02.azurecr.io/hgzero/notification-service:latest
```
#### 5. Kubernetes 배포
```bash
# Namespace 생성
kubectl create namespace hgzero
# Secret 생성 (환경 변수)
kubectl apply -f deployment/k8s/backend/secrets/ -n hgzero
# 서비스 배포
kubectl apply -f deployment/k8s/backend/user-service.yaml -n hgzero
kubectl apply -f deployment/k8s/backend/meeting-service.yaml -n hgzero
kubectl apply -f deployment/k8s/backend/stt-service.yaml -n hgzero
kubectl apply -f deployment/k8s/backend/ai-service.yaml -n hgzero
kubectl apply -f deployment/k8s/backend/rag-service.yaml -n hgzero
kubectl apply -f deployment/k8s/backend/notification-service.yaml -n hgzero
# Ingress 설정
kubectl apply -f deployment/k8s/ingress.yaml -n hgzero
```
### 4.3 테스트
#### 1) 프론트엔드 페이지 주소 구하기
```bash
# Namespace 설정
kubens hgzero
# Service 확인
kubectl get svc
# Ingress 확인
kubectl get ingress
```
#### 2) API 테스트
**Swagger UI 접근**:
- User Service: http://{INGRESS_URL}/user/swagger-ui.html
- Meeting Service: http://{INGRESS_URL}/meeting/swagger-ui.html
- STT Service: http://{INGRESS_URL}/stt/swagger-ui.html
- AI Service: http://{INGRESS_URL}/ai/swagger-ui.html
- RAG Service: http://{INGRESS_URL}/rag/docs
- Notification Service: http://{INGRESS_URL}/notification/swagger-ui.html
#### 3) 로그인 테스트
- ID: meeting-test
- PW: 8자리
## 5. 팀
- 유동희 "야보" - Product Owner
- 조민서 "다람지" - AI Specialist
- 김주환 "블랙" - Architect
- 김종희 "페퍼" - Frontend Developer
- 문효종 "카누" - Frontend Developer / DevOps Engineer
- 전대웅 "맥심" - Backend Developer
- 조윤진 "쿼카" - Backend Developer

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 = 4096
claude_max_tokens: int = 25000 # 회의록 통합을 위해 25000으로 증가
claude_temperature: float = 0.7
# Redis

View File

@ -20,8 +20,9 @@ class ConsolidateRequest(BaseModel):
class ExtractedTodo(BaseModel):
"""추출된 Todo (제목만)"""
"""추출된 Todo"""
title: str = Field(..., description="Todo 제목")
assignee: str = Field(default="", description="담당자 이름 (있는 경우에만)")
class AgendaSummary(BaseModel):

View File

@ -19,7 +19,9 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
f"{i+1}. {agenda}" for i, agenda in enumerate(agendas)
])
prompt = f"""당신은 회의록 작성 전문가입니다. 여러 참석자가 작성한 회의록을 통합하여 정확하고 체계적인 회의록을 생성해주세요.
prompt = f"""당신은 회의록 작성 전문가이며 JSON 생성 전문가입니다. 여러 참석자가 작성한 회의록을 통합하여 정확하고 체계적인 회의록을 생성해주세요.
**매우 중요**: 응답은 반드시 유효한 JSON 형식이어야 합니다. 문자열 내의 모든 특수문자를 올바르게 이스케이프해야 합니다.
# 입력 데이터
@ -40,7 +42,11 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
3. **회의 전체 결정사항 (decisions)**:
- 회의 전체에서 최종 결정된 사항들을 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)**:
회의 내용을 분석하여 안건별로 구조화:
@ -51,8 +57,9 @@ 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"]
* 회의에서 최종 결정된 사항만 포함
@ -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
{{
"keywords": ["키워드1", "키워드2", "키워드3"],
"statistics": {{
"agendas_count": 숫자,
"todos_count": 숫자
"agendas_count": 2,
"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_number": 1,
"agenda_title": "안건 제목",
"summary_short": "짧은 요약 (20자 이내)",
"summary": "**논의 사항:**\\n- 논의내용1\\n- 논의내용2\\n\\n**결정 사항:**\\n- 결정1\\n- 결정2",
"summary_short": "짧은 요약",
"summary": "논의 사항:\\n- 논의내용1\\n- 논의내용2\\n\\n결정 사항:\\n- 결정1\\n- 결정2",
"decisions": ["결정사항1", "결정사항2"],
"pending": ["보류사항"],
"todos": [
@ -113,17 +126,27 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
3. **완전성**: 모든 필드를 빠짐없이 작성
4. **구조화**: 안건별로 명확히 분리
5. **결정사항 추출**:
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식)
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식, \\n 이스케이프 필수)
- 안건별 결정사항(agenda_summaries[].decisions): 안건의 결정사항을 배열로 추출
- 결정사항이 명확하게 언급된 경우에만 포함
6. **summary 작성**:
- summary_short: AI가 자동 생성한 1 요약 (사용자 수정 불가)
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능)
- summary_short: AI가 자동 생성한 1 요약 (사용자 수정 불가, 특수문자 이스케이프)
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능, \\n 이스케이프 필수)
- decisions: summary의 결정사항 부분을 배열로 별도 추출 (대시보드 표시용)
7. **Todo 추출**: 제목만 추출 (담당자나 마감일 없어도 )
8. **JSON만 출력**: 추가 설명 없이 JSON만 반환
7. **Todo 추출**:
- 제목 필수, 담당자는 언급된 경우에만 추출
- 자연스러운 표현에서 추출: "김대리가 ~하기로 함" title: "~", assignee: "김대리"
- 담당자가 없으면 assignee: "" ( 문자열)
8. **JSON 형식 엄수 - 가장 중요**:
- 추가 설명, 주석, 서문 없이 JSON만 반환
- 문자열 큰따옴표(")는 작은따옴표(')로 대체
- 문자열 줄바꿈은 \\n으로 이스케이프
- 역슬래시는 \\\\ 이스케이프
- 모든 문자열을 올바르게 닫기
- 생성 유효한 JSON인지 자체 검증
이제 회의록들을 분석하여 통합 요약을 JSON 형식으로 생성해주세요.
**최종 지시**: 회의록들을 분석하여 **유효한 JSON 형식으로만** 통합 요약을 생성해주세요.
JSON 파싱 오류가 발생하지 않도록 모든 특수문자를 올바르게 이스케이프하세요.
"""
return prompt

View File

@ -1,9 +1,11 @@
"""AI 제안사항 추출 프롬프트 (회의록 작성 MVP 최적화)"""
"""AI 제안사항 추출 프롬프트 (Hallucination 방지 최적화)"""
def get_suggestions_prompt(transcript_text: str) -> tuple[str, str]:
"""
회의 텍스트에서 AI 제안사항을 추출하는 프롬프트 생성 (회의록 MVP용)
회의 텍스트에서 AI 제안사항을 추출하는 프롬프트 생성
Hallucination 방지를 위해 예시를 모두 제거하고 명확한 지침만 제공
Returns:
(system_prompt, user_prompt) 튜플
@ -11,6 +13,12 @@ def get_suggestions_prompt(transcript_text: str) -> tuple[str, str]:
system_prompt = """당신은 실시간 회의록 작성 AI 비서입니다.
**🚨 중요 원칙 (최우선)**:
1. **오직 제공된 회의 내용만 분석** - 추측, 가정, 예시 내용 절대 금지
2. **실제 발언된 내용만 추출** - 없는 내용 만들어내지 않기
3. **회의 내용에 명시되지 않은 정보는 절대 추가하지 않기**
4. **불확실한 내용은 추출하지 않기** - 명확한 내용만 추출
**핵심 역할**:
회의 발언되는 내용을 실시간으로 분석하여, 회의록 작성자가 놓칠 있는 중요한 정보를 즉시 메모로 제공합니다.
@ -18,415 +26,62 @@ def get_suggestions_prompt(transcript_text: str) -> tuple[str, str]:
1. 회의 안건, 결정 사항, 이슈, 액션 아이템을 자동으로 분류
2. 담당자, 기한, 우선순위 구조화된 정보로 정리
3. 단순 발언 반복이 아닌, 실무에 바로 사용 가능한 형식으로 요약
4. 회의록 작성 시간을 70% 단축시키는 것이 목표
4. 구어체 종결어미(~, ~, ~습니다) 제거하고 명사형으로 정리
**핵심 원칙**:
- 인사말, 반복, 불필요한 추임새는 완전히 제거
- 실제 회의록에 들어갈 내용만 추출
- 명확하고 간결하게 (20-50)
- 구어체 종결어미(~, ~, ~습니다) 제거하고 명사형으로 정리"""
**분류 카테고리**:
- 📋 회의 안건: "오늘 안건은 ~", "논의할 주제는 ~"
- 결정사항: "~로 결정", "~로 합의", "~로 확정"
- 🎯 액션 아이템: "~팀에서 ~", "~까지 완료", "~를 검토"
- 이슈/문제점: "문제 발생", "이슈 있음", "우려 사항"
- 💡 제안/아이디어: "제안", "~하는 것이 좋을 것 같음", "검토 필요"
- 📊 진행상황: "~% 완료", "~진행 중", "~논의 중"
- 🔔 후속조치: "다음 회의에서", "추후 결정", "보류"
user_prompt = f"""다음 회의 대화를 실시간으로 분석하여 **회의록 메모**를 작성하세요.
**제외 대상 (반드시 제외)**:
- 인사말: "안녕하세요", "감사합니다", "수고하셨습니다"
- 추임새: "", "네네", "그러니까", "저기"
- 형식적 발언: "녹음 시작", "회의 종료", "회의 시작"
**출력 형식**:
- JSON만 출력 (주석, 설명, 마크다운 코드블록 금지)
- 구조: {"suggestions": [{"content": "분류: 내용", "confidence": 0.85}]}
- confidence: 0.90-1.0(명확), 0.80-0.89(일반), 0.70-0.79(암묵적), 0.65-0.69(논의중)"""
user_prompt = f"""🚨 **매우 중요**: 아래 제공된 회의 내용만 분석하세요.
- 회의 내용에 없는 정보는 절대 추가하지 마세요
- 예시나 가정을 만들어내지 마세요
- 불확실한 내용은 추출하지 마세요
# 회의 내용 (이것만 분석하세요)
# 회의 내용
{transcript_text}
---
# 회의록 항목별 패턴 학습
# 분석 작업
## 📋 1. 회의 안건 (Agenda)
회의 내용에서 **실제로 언급된 내용만** 추출하세요:
### 패턴 인식
- "오늘 회의 안건은 ~"
- "논의할 주제는 ~"
- "다룰 내용은 ~"
- "검토할 사항은 ~"
1. 📋 회의 안건
2. 결정사항
3. 🎯 액션 아이템 (담당자/기한이 있으면 반드시 포함)
4. 이슈/문제점
5. 💡 제안/아이디어
6. 📊 진행상황
7. 🔔 후속조치
### ✅ 좋은 예시
**입력**: "오늘 회의 안건은 신제품 출시 일정과 마케팅 전략입니다."
**출력**:
```json
{{
"content": "📋 회의 안건: 신제품 출시 일정, 마케팅 전략",
"confidence": 0.95
}}
```
**필수 규칙**:
- 구어체 종결어미 제거 (명사형으로 정리)
- 담당자와 기한이 있으면 반드시 포함
- 인사말, 추임새, 형식적 발언 제외
- 20-70자로 간결하게
- JSON 형식으로만 출력
**입력**: "다음 주 프로젝트 킥오프에 대해 논의하겠습니다."
**출력**:
```json
{{
"content": "📋 회의 안건: 다음 주 프로젝트 킥오프",
"confidence": 0.90
}}
```
**출력 형식**:
{{"suggestions": [{{"content": "분류: 내용", "confidence": 0.85}}]}}
### ❌ 나쁜 예시
**입력**: "오늘 회의 안건은 신제품 출시 일정입니다."
**나쁜 출력**:
```json
{{
"content": "오늘 회의 안건은 신제품 출시 일정입니다", 구어체 그대로 반복
"confidence": 0.90
}}
```
**이유**: 구어체 종결어미(~입니다) 그대로 반복. "📋 회의 안건: 신제품 출시 일정"으로 구조화해야
---
## ✅ 2. 결정 사항 (Decisions)
### 패턴 인식
- "결정 사항은 ~", "~로 결정했습니다"
- "~하기로 했습니다", "~로 합의했습니다"
- "~로 확정됐습니다"
- "최종 결론은 ~"
### ✅ 좋은 예시
**입력**: "회의 결과, 신규 프로젝트는 다음 달부터 착수하기로 결정했습니다."
**출력**:
```json
{{
"content": "✅ 결정사항: 신규 프로젝트 다음 달 착수",
"confidence": 0.95
}}
```
**입력**: "최종 결론은 외주 개발사와 계약하기로 합의했습니다."
**출력**:
```json
{{
"content": "✅ 결정사항: 외주 개발사와 계약 진행",
"confidence": 0.92
}}
```
### ❌ 나쁜 예시
**입력**: "신규 프로젝트는 다음 달부터 착수하기로 결정했습니다."
**나쁜 출력**:
```json
{{
"content": "신규 프로젝트는 다음 달부터 착수하기로 결정했습니다", 원문 그대로
"confidence": 0.90
}}
```
**이유**: 발언을 그대로 반복. "✅ 결정사항: 신규 프로젝트 다음 달 착수" 구조화해야
---
## 🎯 3. 액션 아이템 (Action Items)
### 패턴 인식
- "~팀에서 ~해 주세요"
- "~님이 ~까지 ~하기로 했습니다"
- "~을 ~까지 완료하겠습니다"
- "~을 검토해 보겠습니다"
### ✅ 좋은 예시
**입력**: "개발팀에서 API 문서를 이번 주 금요일까지 작성해 주세요."
**출력**:
```json
{{
"content": "🎯 개발팀: API 문서 작성 (기한: 이번 주 금요일)",
"confidence": 0.95
}}
```
**입력**: "김 팀장님이 내일까지 견적서를 검토해서 회신하기로 했습니다."
**출력**:
```json
{{
"content": "🎯 김 팀장: 견적서 검토 및 회신 (기한: 내일)",
"confidence": 0.93
}}
```
**입력**: "제가 고객사에 연락해서 미팅 일정 잡도록 하겠습니다."
**출력**:
```json
{{
"content": "🎯 고객사 미팅 일정 조율 예정",
"confidence": 0.85
}}
```
### ❌ 나쁜 예시
**입력**: "개발팀에서 API 문서를 이번 주 금요일까지 작성해 주세요."
**나쁜 출력 1**:
```json
{{
"content": "개발팀에서 API 문서를 이번 주 금요일까지 작성해 주세요", 원문 반복
"confidence": 0.90
}}
```
**나쁜 출력 2**:
```json
{{
"content": "API 문서 작성", 담당자와 기한 누락
"confidence": 0.80
}}
```
**이유**: "🎯 개발팀: API 문서 작성 (기한: 이번 주 금요일)" 형식으로 구조화해야
---
## ⚠️ 4. 이슈/문제점 (Issues)
### 패턴 인식
- "문제가 있습니다", "이슈가 발생했습니다"
- "우려되는 점은 ~"
- "해결이 필요한 부분은 ~"
- "리스크가 있습니다"
### ✅ 좋은 예시
**입력**: "현재 서버 성능 이슈가 발생해서 긴급 점검이 필요합니다."
**출력**:
```json
{{
"content": "⚠️ 이슈: 서버 성능 문제 발생, 긴급 점검 필요",
"confidence": 0.92
}}
```
**입력**: "예산이 부족할 것 같다는 우려가 있습니다."
**출력**:
```json
{{
"content": "⚠️ 이슈: 예산 부족 우려",
"confidence": 0.80
}}
```
### ❌ 나쁜 예시
**입력**: "현재 서버 성능 이슈가 발생했습니다."
**나쁜 출력**:
```json
{{
"content": "현재 서버 성능 이슈가 발생했습니다", 구어체 그대로
"confidence": 0.85
}}
```
**이유**: "⚠️ 이슈: 서버 성능 문제 발생"으로 구조화하고 구어체 제거해야
---
## 💡 5. 아이디어/제안 (Suggestions)
### 패턴 인식
- "제안하는 바는 ~"
- "~하는 것이 좋을 것 같습니다"
- "~을 고려해 볼 필요가 있습니다"
### ✅ 좋은 예시
**입력**: "자동화 테스트를 도입하는 것을 검토해 보면 좋을 것 같습니다."
**출력**:
```json
{{
"content": "💡 제안: 자동화 테스트 도입 검토",
"confidence": 0.85
}}
```
---
## 📊 6. 진행 상황/보고 (Progress)
### 패턴 인식
- "~까지 완료했습니다"
- "현재 ~% 진행 중입니다"
- "~단계까지 진행됐습니다"
### ✅ 좋은 예시
**입력**: "현재 설계 단계는 80% 완료됐고, 다음 주부터 개발 착수 가능합니다."
**출력**:
```json
{{
"content": "📊 진행상황: 설계 80% 완료, 다음 주 개발 착수 예정",
"confidence": 0.90
}}
```
---
## ❌ 제외해야 할 내용 (반드시 제외)
### 인사말
**입력**: "안녕하세요, 여러분. 회의 시작하겠습니다."
**출력**: (메모 없음 - 인사말은 제외)
### 단순 반복
**입력**: "녹음을 시작합니다. 녹음을 시작합니다."
**출력**: (메모 없음 - 형식적 발언 제외)
### 추임새/불필요한 발언
**입력**: "음, 그러니까, 네 네, 저기요..."
**출력**: (메모 없음 - 추임새 제외)
### 형식적 마무리
**입력**: "수고하셨습니다. 회의를 마치겠습니다."
**출력**: (메모 없음 - 형식적 마무리 제외)
---
# 실전 회의 시뮬레이션
## 예시 1: 프로젝트 킥오프 회의
**입력**:
"안녕하세요. 오늘 회의 안건은 신규 프로젝트 킥오프입니다. 프로젝트명은 HGZero이고, 목표는 회의록 자동화입니다. 개발팀에서 다음 주 월요일까지 기술 스택을 검토해 주세요. 예산은 5천만원으로 확정됐습니다."
**출력**:
```json
{{
"suggestions": [
{{
"content": "📋 회의 안건: 신규 프로젝트(HGZero) 킥오프 - 회의록 자동화",
"confidence": 0.95
}},
{{
"content": "🎯 개발팀: 기술 스택 검토 (기한: 다음 주 월요일)",
"confidence": 0.93
}},
{{
"content": "✅ 결정사항: 프로젝트 예산 5천만원 확정",
"confidence": 0.95
}}
]
}}
```
---
## 예시 2: 이슈 대응 회의
**입력**:
"현재 프로덕션 서버에서 성능 저하가 발생하고 있습니다. 인프라팀에서 긴급 점검을 진행하기로 했고, 오늘 오후 3시까지 원인 파악하겠습니다. 고객사에는 임시로 사과 공지를 게시하기로 결정했습니다."
**출력**:
```json
{{
"suggestions": [
{{
"content": "⚠️ 이슈: 프로덕션 서버 성능 저하 발생",
"confidence": 0.95
}},
{{
"content": "🎯 인프라팀: 긴급 점검 및 원인 파악 (기한: 오늘 오후 3시)",
"confidence": 0.93
}},
{{
"content": "✅ 결정사항: 고객사 사과 공지 게시",
"confidence": 0.90
}}
]
}}
```
---
## 예시 3: 일반 업무 회의 (나쁜 예시 포함)
**입력**:
"안녕하세요, 안녕하세요. 녹음을 시작합니다. 음, 그러니까 마케팅 캠페인을 다음 달에 진행하기로 했습니다. 김 과장님이 기획안을 이번 주까지 작성해 주세요. 감사합니다."
** 나쁜 출력**:
```json
{{
"suggestions": [
{{
"content": "안녕하세요", 인사말 포함
"confidence": 0.50
}},
{{
"content": "녹음을 시작합니다", 형식적 발언
"confidence": 0.60
}},
{{
"content": "마케팅 캠페인을 다음 달에 진행하기로 했습니다", 구어체 그대로
"confidence": 0.80
}}
]
}}
```
** 좋은 출력**:
```json
{{
"suggestions": [
{{
"content": "✅ 결정사항: 마케팅 캠페인 다음 달 진행",
"confidence": 0.92
}},
{{
"content": "🎯 김 과장: 캠페인 기획안 작성 (기한: 이번 주)",
"confidence": 0.93
}}
]
}}
```
---
# 출력 형식
반드시 아래 JSON 형식으로만 응답하세요:
```json
{{
"suggestions": [
{{
"content": "📋/✅/🎯/⚠️/💡/📊 분류: 구체적인 내용 (담당자/기한 포함)",
"confidence": 0.85
}}
]
}}
```
---
# 최종 작성 규칙
## ✅ 반드시 지켜야 할 규칙
1. **이모지 분류 필수**
- 📋 회의 안건
- 결정사항
- 🎯 액션 아이템
- 이슈/문제점
- 💡 제안/아이디어
- 📊 진행상황
2. **구조화 필수**
- 담당자가 있으면 반드시 명시
- 기한이 있으면 반드시 포함
- 형식: "담당자: 업무 내용 (기한: XX)"
3. **구어체 종결어미 제거**
- "~입니다", "~했습니다", "~해요", "~합니다"
- 명사형 종결: "~ 진행", "~ 완료", "~ 확정", "~ 검토"
4. **반드시 제외**
- 인사말 ("안녕하세요", "감사합니다", "수고하셨습니다")
- 반복/추임새 ("네 네", "음 음", "그러니까", "저기")
- 형식적 발언 ("녹음 시작", "회의 종료", "회의 시작")
5. **길이**
- 20-70 (너무 짧거나 길지 않게)
6. **confidence 기준**
- 0.90-1.0: 명확한 결정사항, 기한 포함
- 0.80-0.89: 일반적인 액션 아이템
- 0.70-0.79: 암묵적이거나 추측 필요
7. **출력**
- JSON만 출력 (주석, 설명, ```json 모두 금지)
- 최소 1 이상 추출 (의미 있는 내용이 없으면 배열)
---
이제 회의 내용을 분석하여 **회의록 메모** JSON 형식으로 작성하세요.
학습한 패턴을 활용하여 회의 안건, 결정사항, 액션 아이템, 이슈 등을 자동으로 분류하고 구조화하세요.
반드시 구어체 종결어미(~, ~, ~습니다) 제거하고 명사형으로 정리하세요."""
지금 바로 분석을 시작하세요."""
return system_prompt, user_prompt

View File

@ -43,7 +43,7 @@ class ClaudeService:
]
# 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:
response = self.client.messages.create(
@ -63,7 +63,12 @@ class ClaudeService:
# 응답 텍스트 추출
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 ... ``` 블록 제거
@ -72,13 +77,113 @@ 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"응답 텍스트: {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)}")
except Exception as e:
@ -122,7 +227,7 @@ class ClaudeService:
confidence=s.get("confidence", 0.85)
)
for s in suggestions_data
if s.get("confidence", 0) >= 0.7 # 신뢰도 0.7 이상만
if s.get("confidence", 0) >= 0.65 # 신뢰도 0.65 이상 (0.7 → 0.65 낮춤)
]
logger.info(f"AI 제안사항 {len(suggestions)}개 추출 완료")

File diff suppressed because one or more lines are too long

View File

@ -1,129 +0,0 @@
@startuml meeting-대시보드조회
!theme mono
title Meeting Service - 대시보드조회 내부 시퀀스
participant "DashboardController" as Controller
participant "DashboardService" as Service
participant "MeetingRepository" as MeetingRepo
participant "TodoRepository" as TodoRepo
participant "MinutesRepository" as MinutesRepo
database "Redis Cache<<E>>" as Cache
database "Meeting DB<<E>>" as DB
[-> Controller: GET /dashboard
activate Controller
note over Controller
사용자 정보는 헤더에서 추출
(userId, userName, email)
end note
Controller -> Service: getDashboardData(userId)
activate Service
' 캐시 조회
Service -> Cache: GET dashboard:{userId}
activate Cache
Cache --> Service: 캐시 조회 결과
deactivate Cache
alt Cache Hit
Service --> Service: 캐시 데이터 반환
else Cache Miss
' 예정된 회의 조회
Service -> MeetingRepo: findUpcomingMeetings(userId)
activate MeetingRepo
MeetingRepo -> DB: 예정된 회의 조회
activate DB
DB --> MeetingRepo: 예정된 회의 목록
deactivate DB
MeetingRepo --> Service: List<Meeting>
deactivate MeetingRepo
' 진행 중 Todo 조회
Service -> TodoRepo: findActiveTodos(userId)
activate TodoRepo
TodoRepo -> DB: 진행 중 Todo 조회
activate DB
DB --> TodoRepo: 진행 중 Todo 목록
deactivate DB
TodoRepo --> Service: List<Todo>
deactivate TodoRepo
' 최근 회의록 조회
Service -> MinutesRepo: findRecentMinutes(userId)
activate MinutesRepo
MinutesRepo -> DB: 최근 회의록 조회
activate DB
DB --> MinutesRepo: 최근 회의록 목록
deactivate DB
MinutesRepo --> Service: List<Minutes>
deactivate MinutesRepo
' 통계 정보 조회
Service -> MeetingRepo: countUpcomingMeetings(userId)
activate MeetingRepo
MeetingRepo -> DB: 예정된 회의 개수 조회
activate DB
DB --> MeetingRepo: 예정된 회의 개수
deactivate DB
MeetingRepo --> Service: int count
deactivate MeetingRepo
Service -> TodoRepo: countActiveTodos(userId)
activate TodoRepo
TodoRepo -> DB: 진행 중 Todo 개수 조회
activate DB
DB --> TodoRepo: 진행 중 Todo 개수
deactivate DB
TodoRepo --> Service: int count
deactivate TodoRepo
Service -> TodoRepo: calculateTodoCompletionRate(userId)
activate TodoRepo
TodoRepo -> DB: Todo 완료율 조회
activate DB
DB --> TodoRepo: Todo 완료율
deactivate DB
TodoRepo --> Service: double rate
deactivate TodoRepo
note over Service
비즈니스 로직:
- 데이터 조합 및 정제
- DTO 변환
- 통계 계산
end note
Service -> Service: 대시보드 데이터 조합
' 캐시 저장
Service -> Cache: SET dashboard:{userId}\n(TTL: 5분)
activate Cache
Cache --> Service: 캐시 저장 완료
deactivate Cache
end
Service --> Controller: DashboardResponse
deactivate Service
note over Controller
응답 데이터 구조:
{
"upcomingMeetings": [...],
"activeTodos": [...],
"recentMinutes": [...],
"statistics": {
"upcomingMeetingsCount": n,
"activeTodosCount": n,
"todoCompletionRate": n
}
}
end note
return 200 OK\nDashboardResponse
deactivate Controller
@enduml

View File

@ -1,118 +0,0 @@
@startuml
!theme mono
title 최종 회의록 확정 내부 시퀀스
participant "API Gateway<<E>>" as Gateway
participant "MinutesController" as Controller
participant "MeetingService" as Service
participant "Meeting" as Domain
participant "TranscriptService" as TranscriptService
participant "MeetingRepository" as Repository
database "PostgreSQL<<E>>" as DB
database "Redis Cache<<E>>" as Cache
queue "Event Hub<<E>>" as EventHub
Gateway -> Controller: POST /api/minutes/{minutesId}/finalize
activate Controller
Controller -> Service: confirmTranscript(meetingId)
activate Service
Service -> Repository: findById(meetingId)
activate Repository
Repository -> DB: 회의 정보 조회\n(회의ID 기준)
activate DB
DB --> Repository: meeting_row
deactivate DB
Repository --> Service: Meeting entity
deactivate Repository
Service -> Domain: confirmTranscript()
activate Domain
Domain -> Domain: validateCanConfirm()
note right of Domain
회의록 확정 검증 규칙:
1. 상태 검증:
- 회의 상태 = COMPLETED (종료됨)
- 회의록 상태 = DRAFT (작성중)
✗ IN_PROGRESS → 400 Bad Request
2. 권한 검증:
- 확정 요청자 = 회의 생성자 OR
- 확정 요청자 ∈ 참석자 목록
✗ 권한 없음 → 403 Forbidden
3. 필수 항목 검증:
- 회의록 제목: NOT NULL, 길이 >= 5
- 참석자 목록: 최소 1명 이상
- 주요 논의 내용: NOT NULL, 길이 >= 20
- 결정 사항: 최소 1개 이상 OR
"결정사항 없음" 명시적 표시
✗ 누락 → 400 Bad Request
(누락 항목 목록 반환)
4. 데이터 무결성:
- 섹션별 내용 완결성 확인
- 참조 링크 유효성 확인
- 첨부 파일 접근 가능 여부
✗ 무결성 위반 → 400 Bad Request
5. 이력 검증:
- 마지막 수정 후 24시간 경과 경고
- 미승인 AI 제안 존재 시 알림
⚠️ 경고만 표시, 진행 가능
end note
Domain -> Domain: changeStatus(CONFIRMED)
Domain --> Service: updated Meeting
deactivate Domain
Service -> TranscriptService: lockTranscript(meetingId)
activate TranscriptService
note right of TranscriptService
회의록 잠금:
- 더 이상 수정 불가
- 버전 고정
end note
TranscriptService --> Service: lockedTranscript
deactivate TranscriptService
Service -> Repository: save(meeting)
activate Repository
Repository -> DB: 회의 상태 업데이트\n(상태='확정', 확정일시)
activate DB
DB --> Repository: affected_rows
deactivate DB
Repository --> Service: savedMeeting
deactivate Repository
Service -> Cache: SET meeting:{id}\n(TTL: 10분)
activate Cache
note right of Cache
회의 정보 캐싱:
- TTL: 10분
- 자동 만료
end note
Cache --> Service: OK
deactivate Cache
Service ->> EventHub: publish(MinutesFinalized)
activate EventHub
note right of EventHub
비동기 이벤트:
- 참석자에게 최종본 알림
- 공유 서비스로 전송
end note
deactivate EventHub
Service --> Controller: MeetingResponse
deactivate Service
Controller --> Gateway: 200 OK
deactivate Controller
@enduml

View File

@ -1,74 +0,0 @@
@startuml Todo완료및회의록반영
!theme mono
title Todo 완료 및 회의록 반영 플로우
actor "담당자" as User
participant "Web App" as Web
participant "API Gateway" as Gateway
participant "Meeting Service" as Meeting
participant "Redis Cache" as Redis
participant "Notification Service" as Notification
note over Gateway
라우팅 규칙:
/api/meetings/** → Meeting Service
/api/minutes/** → Meeting Service
/api/dashboard → User Service
/api/notifications/** → Notification Service
/api/auth/** → User Service
/api/todos/** → Meeting Service
end note
autonumber
== Todo 완료 처리 ==
User -> Web: Todo 완료 버튼 클릭
activate Web
Web -> Gateway: PATCH /api/todos/{todoId}/complete\n(userId, userName, completedAt)
activate Gateway
Gateway -> Meeting: PATCH /todos/{todoId}/complete\n(userId, userName, completedAt)
activate Meeting
Meeting -> Meeting: Todo 상태 업데이트\n- 완료 시간 기록\n- 완료자 정보 저장\n- 상태: COMPLETED
Meeting -> Meeting: 관련 회의록에 완료 상태 반영\n- 회의록 섹션 업데이트\n- 완료 표시 (체크 아이콘)\n- 완료 시간 및 완료자 기록
Meeting -> Meeting: DB에 저장
== 캐시 무효화 ==
Meeting --> Redis: DELETE dashboard:{assigneeId}
note right
대시보드 캐시 무효화
end note
Meeting --> Redis: DELETE minutes:detail:{minutesId}
note right
회의록 상세 캐시 무효화
end note
== 이벤트 발행 ==
Meeting -> Notification: NotificationRequest 이벤트 발행
activate Notification
note right
이벤트 데이터:
- 발송수단: EMAIL
- 대상자: 회의록 작성자
- 메시지: Todo 완료 안내
- 메타데이터: todoId, 완료자, 완료 시간
end note
Meeting --> Gateway: 200 OK\n{todoId, status: COMPLETED,\ncompletedAt, completedBy}
deactivate Meeting
Gateway --> Web: 200 OK\n(Todo 완료 정보)
deactivate Gateway
Web --> User: Todo 완료 표시
deactivate Web
== 알림 발송 ==
Notification -> Notification: NotificationRequest 이벤트 구독
Notification -> Notification: 알림 메시지 생성\n- 수신자: 회의록 작성자\n- 내용: "Todo 완료됨"
Notification --> Notification: 이메일 발송\n(회의록 작성자에게)
note right
외부 Email Service 연동
end note
deactivate Notification
@enduml

View File

@ -1,113 +0,0 @@
@startuml 대시보드조회
!theme mono
title 대시보드조회 외부 시퀀스
actor "사용자" as User
participant "Web App" as Frontend
participant "API Gateway" as Gateway
participant "User Service" as UserService
participant "Meeting Service" as MeetingService
database "Redis Cache" as Cache
database "User DB" as UserDB
database "Meeting DB" as MeetingDB
note over Gateway
라우팅 규칙:
/api/meetings/** → Meeting Service
/api/minutes/** → Meeting Service
/api/dashboard → User Service
/api/notifications/** → Notification Service
/api/auth/** → User Service
/api/todos/** → Meeting Service
end note
User -> Frontend: 대시보드 접근
activate Frontend
Frontend -> Gateway: GET /api/dashboard?\npage=1&size=10&sort=createdAt,desc
note right
페이지네이션 파라미터:
- page: 페이지 번호 (기본값: 1)
- size: 페이지 크기 (기본값: 10)
- sort: 정렬 기준 (기본값: createdAt,desc)
end note
activate Gateway
Gateway -> UserService: GET /dashboard?\npage=1&size=10&sort=createdAt,desc
activate UserService
' 캐시 조회
UserService -> Cache: GET dashboard:{userId}
activate Cache
Cache --> UserService: 캐시 조회 결과
deactivate Cache
alt Cache Hit
UserService -> UserService: 캐시 데이터 반환
else Cache Miss
par 병렬 데이터 조회
' Meeting Service 호출
UserService -> MeetingService: GET /api/v1/dashboard
note right
Meeting Service에서 조회:
- 예정된 회의 목록
- 진행 중 Todo 목록
- 최근 회의록 목록
- 공유받은 회의록
- 통계 정보
end note
activate MeetingService
MeetingService -> Cache: GET dashboard:{userId}
activate Cache
Cache --> MeetingService: 캐시 조회 결과
deactivate Cache
alt Meeting Service 캐시 미존재
MeetingService -> MeetingDB: 회의/Todo/회의록 데이터 조회
activate MeetingDB
MeetingDB --> MeetingService: 조회 결과
deactivate MeetingDB
MeetingService -> Cache: SET dashboard:{userId}\n(TTL: 5분)
activate Cache
Cache --> MeetingService: 캐시 저장
deactivate Cache
end
MeetingService --> UserService: 회의 관련 데이터 응답\n{\n "upcomingMeetings": [...],\n "activeTodos": [...],\n "recentMinutes": [...],\n "sharedMinutes": [...],\n "statistics": {...}\n}
deactivate MeetingService
else
' User Service 자체 데이터 조회
UserService -> UserDB: 최근 활동 내역 조회
activate UserDB
UserDB --> UserService: 활동 내역
deactivate UserDB
end
UserService -> UserService: 데이터 통합 및 조합
note right
대시보드 데이터 구성:
- Meeting Service 데이터
- User Service 활동 내역
- 통합 통계 정보
end note
UserService -> Cache: SET dashboard:{userId}\n(TTL: 5분)
activate Cache
Cache --> UserService: 캐시 저장 완료
deactivate Cache
end
UserService --> Gateway: 대시보드 데이터 응답\n{\n "upcomingMeetings": [...],\n "activeTodos": [...],\n "recentMinutes": [...],\n "recentActivities": [...],\n "statistics": {...},\n "pagination": {\n "page": 1,\n "size": 10,\n "totalElements": 45,\n "totalPages": 5,\n "hasNext": true\n }\n}
deactivate UserService
Gateway --> Frontend: 200 OK\n대시보드 데이터 + 페이지네이션 정보
deactivate Gateway
Frontend -> Frontend: 대시보드 화면 렌더링\n- 예정된 회의 표시\n- Todo 목록 표시\n- 최근 회의록 표시\n- 통계 차트 표시
Frontend --> User: 대시보드 화면 표시
deactivate Frontend
@enduml

View File

@ -33,7 +33,7 @@ activate Gateway
Gateway -> Meeting: POST /meetings/{meetingId}/start
activate Meeting
Meeting -> Meeting: 회의 세션 생성
Meeting -> Meeting: 회의 세션 생성\n- Session 객체 생성\n- Minutes 초기화 (DRAFT)\n- WebSocket URL 생성
Meeting -> STT: POST /stt/recording/start\n(meetingId, participantIds)
activate STT
@ -47,7 +47,7 @@ STT -> STT: 음성 녹음 준비 및 시작
STT --> Meeting: 200 OK (recordingId)
deactivate STT
Meeting --> Gateway: 201 Created
Meeting --> Gateway: 201 Created\n- sessionId\n- meetingId\n- minutesId\n- status (IN_PROGRESS)\n- websocketUrl\n- sessionToken\n- startedAt\n- expiresAt
deactivate Meeting
Gateway --> Frontend: 201 Created

View File

@ -27,7 +27,7 @@ end note
User -> WebApp: 회의 정보 입력\n(제목, 날짜/시간, 장소, 참석자)
activate WebApp
WebApp -> Gateway: POST /api/meetings\n+ JWT 토큰\n+ 사용자 정보 (userId, userName, email)
WebApp -> Gateway: POST /api/meetings/reserve\n+ JWT 토큰\n+ 사용자 정보 (userId, userName, email)
activate Gateway
Gateway -> Meeting: 회의 생성 요청
@ -69,6 +69,30 @@ deactivate Gateway
WebApp --> User: 회의 예약 완료 표시\n캘린더에 자동 등록
deactivate WebApp
== 템플릿 선택 (선택 사항) ==
User -> WebApp: 템플릿 선택\n(일반, 스크럼, 킥오프, 주간)
activate WebApp
WebApp -> Gateway: PUT /api/meetings/{meetingId}/template\n+ JWT 토큰\n+ templateId
activate Gateway
Gateway -> Meeting: 템플릿 적용 요청
activate Meeting
Meeting -> MeetingDB: 템플릿 정보 저장
activate MeetingDB
MeetingDB --> Meeting: 저장 완료
deactivate MeetingDB
Meeting --> Gateway: 200 OK\n템플릿 적용 완료
deactivate Meeting
Gateway --> WebApp: 템플릿 적용 응답
deactivate Gateway
WebApp --> User: 템플릿 적용 완료
deactivate WebApp
== 참석자 초대 알림 (비동기) ==
EventHub -> Notification: NotificationRequest 이벤트 수신\n(Consumer Group: notification-service-group)

View File

@ -32,8 +32,15 @@ note right
end note
Gateway -> Meeting: 회의 종료 요청
Meeting -> Meeting: 회의 종료 처리\n- 종료 시간 기록\n- 회의 통계 생성\n (총 시간, 참석자 수, 발언 횟수 등)
Meeting -> Meeting: DB 저장
Meeting -> Meeting: 회의 종료 처리\n- 종료 시간 기록\n- 회의록 생성 (DRAFT 상태)\n- 회의 통계 생성\n (총 시간, 참석자 수 등)
Meeting -> AI: AI 분석 요청\n- 키워드 추출\n- 안건별 요약 생성\n- Todo 항목 추출
activate AI
AI -> AI: AI 분석 수행
AI --> Meeting: AI 분석 결과\n(keywords, agendaSummaries, todos)
deactivate AI
Meeting -> Meeting: DB 저장\n- Meeting 종료 상태\n- Minutes 생성 (DRAFT)\n- AgendaSection 저장\n- Todo 저장
Meeting -> Meeting: Redis 캐시 무효화\n(meeting:info:{meetingId})
Meeting -> EventHub: MeetingEnded 이벤트 발행\n(meetingId, userId, endTime)
@ -55,9 +62,9 @@ end note
EventHub --> Meeting: 발행 완료
deactivate EventHub
Meeting -> Gateway: 202 Accepted\n(회의 종료 완료)
Gateway -> WebApp: 회의 종료 완료 응답
WebApp -> User: 회의 통계 표시\n(총 시간, 참석자, 발언 횟수 등)
Meeting -> Gateway: 200 OK\n- minutesId\n- 회의 통계 (참석자 수, 시간, 안건 수, Todo 수)\n- 키워드 목록\n- 안건별 AI 요약 (한줄 요약, 상세 요약)\n- Todo 목록
Gateway -> WebApp: 회의 종료 완료 응답\n(MeetingEndResponse)
WebApp -> User: 회의 종료 화면 표시\n- 통계 카드\n- 키워드 태그\n- 안건 아코디언 (AI 요약 + Todo)
== 비동기 처리 - STT 종료 ==
EventHub --> STT: MeetingEnded 이벤트 수신
@ -76,43 +83,62 @@ note right
end note
== 최종 회의록 확정 ==
User -> WebApp: 최종 회의록 확정 버튼 클릭
WebApp -> Gateway: POST /api/minutes/{minutesId}/finalize
User -> WebApp: 최종 회의록 확정 버튼 클릭\n(회의록 수정 화면 또는 회의 종료 화면)
WebApp -> Gateway: POST /api/meetings/minutes/{minutesId}/finalize
note right
요청 헤더에 JWT 토큰 포함
요청 바디에 사용자 정보 포함
X-User-Id, X-User-Name, X-User-Email
end note
Gateway -> Meeting: 회의록 확정 요청
Meeting -> Meeting: 필수 항목 검사\n- 회의 제목\n- 참석자 목록\n- 주요 논의 내용\n- 결정 사항
Meeting -> Meeting: 회의록 상태 변경\n- DRAFT → FINALIZED\n- finalizedAt 기록\n- finalizedBy 기록
alt 필수 항목 미작성
Meeting -> Gateway: 400 Bad Request\n(누락된 항목 정보)
Gateway -> WebApp: 검증 실패 응답
WebApp -> User: 누락된 항목 안내\n(해당 섹션으로 자동 이동)
else 필수 항목 작성 완료
Meeting -> Meeting: 회의록 최종 확정\n- 확정 버전 생성\n- 확정 시간 기록
Meeting -> Meeting: DB 저장 (MinutesVersion)
Meeting -> Meeting: Redis 캐시 무효화
Meeting -> Meeting: DB 저장\n- Minutes 상태 업데이트
Meeting -> Meeting: Redis 캐시 저장\n(확정된 회의록, TTL: 10분)
Meeting -> Meeting: Redis 목록 캐시 무효화\n(사용자별 회의록 목록)
Meeting -> EventHub: NotificationRequest 이벤트 발행\n(회의록 확정 알림)
activate EventHub
note right
이벤트 데이터:
- 발송수단: EMAIL
- 대상자: 참석자 전원
- 메시지: 회의록 확정 안내
- 메타데이터: 버전 번호, 확정 시간
end note
EventHub --> Meeting: 발행 완료
deactivate EventHub
Meeting -> EventHub: MinutesFinalizedEvent 발행\n(알림용 - 기존)
activate EventHub
note right
간단한 이벤트 데이터:
- minutesId
- title
- userId
- userName
end note
EventHub --> Meeting: 발행 완료
deactivate EventHub
Meeting -> Gateway: 200 OK\n(확정 버전 정보)
Gateway -> WebApp: 회의록 확정 완료
WebApp -> User: 확정 완료 안내\n(버전 번호, 확정 시간)
Meeting -> EventHub: MinutesFinalizedEvent 발행\n(RAG용 - 완전한 데이터)
activate EventHub
note right
완전한 이벤트 데이터:
- Meeting 정보 (meetingId, title, purpose, scheduledAt 등)
- Minutes 정보 (minutesId, status, version 등)
- Sections 정보 (모든 안건 섹션)
- 참석자 정보
end note
EventHub --> Meeting: 발행 완료
deactivate EventHub
EventHub --> Notification: NotificationRequest 이벤트 수신
Notification -> Notification: 회의록 확정 알림 발송\n(참석자 전원)
end
Meeting -> Gateway: 200 OK\n(확정된 회의록 상세 정보)
Gateway -> WebApp: 회의록 확정 완료
WebApp -> User: 확정 완료 토스트 표시\n회의록 상세 조회 화면으로 이동
== 비동기 처리 - 알림 발송 ==
EventHub --> Notification: MinutesFinalizedEvent 수신\n(알림용)
Notification -> Notification: 회의록 확정 알림 발송\n(참석자 전원)
note right
알림 내용:
- 회의 제목
- 확정 시간
- 회의록 링크
end note
== 비동기 처리 - RAG 저장 ==
EventHub --> AI: MinutesFinalizedEvent 수신\n(RAG용)
activate AI
AI -> AI: RAG 서비스 연동\n- 벡터 DB 저장\n- 관련 회의록 검색 준비
deactivate AI
@enduml

Binary file not shown.

Binary file not shown.

BIN
docs/aiHGZero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
docs/prototype.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because it is too large Load Diff

203
meeting/section_id.md Normal file
View File

@ -0,0 +1,203 @@
# Meeting 서비스 section_id 이슈 해결 기록
## 문제 상황
### 발생한 에러
```
Caused by: org.postgresql.util.PSQLException: ERROR: column "section_id" of relation "minutes_sections" contains null values
```
### 에러 원인
- JPA Entity인 `MinutesSectionEntity``section_id`를 Primary Key로 정의
- 실제 데이터베이스 테이블에는 `section_id` 컬럼이 존재하지 않음
- JPA가 `ddl-auto: update` 모드로 NOT NULL 제약조건을 추가하려고 시도했으나 실패
## 해결 과정
### 1단계: 문제 분석
- 마이그레이션 파일 확인: V1이 없고 V2, V3만 존재
- JPA 설정 확인: `ddl-auto: update` 모드 사용 중
- Entity 분석: `MinutesSectionEntity.section_id`는 Primary Key로 정의됨
### 2단계: 해결 방안 선택
사용자 선택:
- **방안 1**: 기존 데이터 정리 후 스키마 재구성
- **옵션 B**: Foreign Key 제약조건 제거하여 기존 데이터 보존
### 3단계: Flyway 마이그레이션 설정
#### build.gradle 수정
```gradle
// Flyway 의존성 추가
implementation 'org.flywaydb:flyway-core'
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
```
#### application.yml 수정
```yaml
jpa:
hibernate:
ddl-auto: validate # update → validate로 변경
# Flyway 설정 추가
flyway:
enabled: true
baseline-on-migrate: true
baseline-version: 0
validate-on-migrate: true
```
### 4단계: 마이그레이션 파일 생성
#### V1__create_initial_schema.sql
- 모든 초기 테이블 생성
- Foreign Key 제약조건 주석 처리 (옵션 B 선택)
- `fk_todos_minutes`: todos 테이블의 minutes_id 외래키
- `fk_sessions_meetings`: sessions 테이블의 meeting_id 외래키
```sql
-- 외래키 제약조건 비활성화 예시
-- ALTER TABLE todos DROP CONSTRAINT IF EXISTS fk_todos_minutes;
-- ALTER TABLE todos ADD CONSTRAINT fk_todos_minutes
-- FOREIGN KEY (minutes_id) REFERENCES minutes(minutes_id);
```
#### V2__create_meeting_participants_table.sql
- V1에 통합되어 no-op 처리
```sql
SELECT 1; -- No-op statement
```
#### V4__fix_missing_columns.sql
- `minutes_sections` 테이블에 `section_id` 컬럼 추가
- 시퀀스 생성 후 기존 데이터에 자동 ID 할당
```sql
-- 1. 시퀀스 생성
CREATE SEQUENCE IF NOT EXISTS minutes_sections_temp_seq;
-- 2. section_id 컬럼 추가
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'minutes_sections' AND column_name = 'section_id') THEN
-- 임시 컬럼 추가
ALTER TABLE minutes_sections ADD COLUMN temp_section_id VARCHAR(50);
-- 기존 데이터에 ID 생성
UPDATE minutes_sections
SET temp_section_id = 'section-' || nextval('minutes_sections_temp_seq'::regclass)
WHERE temp_section_id IS NULL;
-- 기존 Primary Key 제거
ALTER TABLE minutes_sections DROP CONSTRAINT IF EXISTS minutes_sections_pkey;
-- 컬럼명 변경
ALTER TABLE minutes_sections RENAME COLUMN temp_section_id TO section_id;
ALTER TABLE minutes_sections ALTER COLUMN section_id SET NOT NULL;
-- 새로운 Primary Key 설정
ALTER TABLE minutes_sections ADD PRIMARY KEY (section_id);
END IF;
END $$;
```
### 5단계: 실행 프로파일 수정
#### .run/meeting-service.run.xml
```xml
<entry key="JPA_DDL_AUTO" value="validate" />
```
## 발생한 에러들과 해결
### 에러 1: PostgreSQL 인증 실패
- 증상: psql 직접 연결 실패
- 해결: Flyway 마이그레이션 사용으로 전환
### 에러 2: 컬럼 존재하지 않음 (42703)
- 증상: V1 마이그레이션의 DELETE 문에서 존재하지 않는 컬럼 참조
- 해결: DELETE 문 제거, CREATE TABLE IF NOT EXISTS 활용
### 에러 3: Foreign Key 제약조건 위반 (23503)
- 증상: `ERROR: insert or update on table "todos" violates foreign key constraint "fk_todos_minutes"`
- 해결: 사용자 선택(옵션 B)에 따라 Foreign Key 제약조건 주석 처리
### 에러 4: 인덱스 중복 (42P07)
- 증상: V2 마이그레이션이 이미 존재하는 테이블/인덱스 생성 시도
- 해결: V2를 no-op으로 변경 (SELECT 1)
### 에러 5: section_id 컬럼 누락
- 증상: `Schema-validation: missing column [section_id] in table [minutes_sections]`
- 해결: V4 마이그레이션 생성하여 컬럼 추가
### 에러 6: 시퀀스 존재하지 않음 (42P01)
- 증상: V4에서 시퀀스를 생성 전에 사용 시도
- 해결: 시퀀스 생성을 DO 블록 앞으로 이동
### 에러 7: 포트 사용 중
- 증상: 8082 포트가 이미 사용 중
- 해결: `lsof -ti:8082 | xargs -r kill -9`로 프로세스 종료
## 최종 결과
### 서비스 정상 실행 확인
```bash
curl http://localhost:8082/actuator/health
```
응답:
```json
{
"status": "UP",
"components": {
"db": {
"status": "UP",
"details": {
"database": "PostgreSQL",
"validationQuery": "isValid()"
}
},
"diskSpace": {"status": "UP"},
"ping": {"status": "UP"},
"redis": {"status": "UP"}
}
}
```
### 마이그레이션 적용 결과
- V1: 초기 스키마 생성 (FK 제약조건 제외)
- V2: no-op (V1에 통합)
- V3: 기존 유지
- V4: section_id 컬럼 추가
## 주요 변경 파일
1. **meeting/src/main/resources/db/migration/V1__create_initial_schema.sql** (생성)
2. **meeting/src/main/resources/db/migration/V2__create_meeting_participants_table.sql** (수정)
3. **meeting/src/main/resources/db/migration/V4__fix_missing_columns.sql** (생성)
4. **meeting/src/main/resources/application.yml** (수정)
5. **meeting/.run/meeting-service.run.xml** (수정)
6. **build.gradle** (수정)
## 교훈
1. **Flyway를 초기부터 사용**: JPA의 `ddl-auto: update`는 프로덕션 환경에서 위험
2. **Baseline 마이그레이션 전략**: 기존 스키마를 버전 0으로 인정하고 점진적 수정
3. **데이터 보존 vs 무결성**: 비즈니스 요구사항에 따라 Foreign Key 제약조건 선택적 적용
4. **마이그레이션 테스트**: 각 마이그레이션은 독립적으로 실행 가능해야 함
5. **환경 변수 관리**: `ddl-auto` 설정은 환경별로 다르게 관리 필요
## 향후 개선 사항
1. Foreign Key 제약조건 재검토
- 데이터 정리 후 제약조건 활성화 검토
- 참조 무결성 확보 방안 논의
2. 마이그레이션 전략 수립
- 개발/스테이징/프로덕션 환경별 마이그레이션 절차
- 롤백 계획 수립
3. 모니터링 강화
- 데이터베이스 스키마 변경 추적
- 마이그레이션 실패 알림 설정

View File

@ -103,8 +103,8 @@ public class DashboardDTO {
.meetingId(meeting.getMeetingId())
.title(meeting.getTitle())
.startTime(meeting.getScheduledAt())
.endTime(null) // Meeting 도메인에 endTime이 없음
.location(null) // Meeting 도메인에 location이 없음
.endTime(meeting.getEndTime())
.location(meeting.getLocation())
.participantCount(meeting.getParticipants() != null ? meeting.getParticipants().size() : 0)
.status(meeting.getStatus())
.userRole(currentUserId.equals(meeting.getOrganizerId()) ? "CREATOR" : "PARTICIPANT")

View File

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

View File

@ -125,6 +125,12 @@ public class MinutesDTO {
* 참석자
*/
private final Integer participantCount;
/**
* 검증완료율 (작성중 상태일 때만 유효)
* 0-100 사이의
*/
private final Integer verificationRate;
/**
* 회의 정보

View File

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

View File

@ -240,20 +240,21 @@ public class MinutesService implements
/**
* 사용자 ID로 회의록 목록 조회 (페이징)
* 사용자가 생성했거나 참여한 회의의 회의록을 모두 조회
*/
@Transactional(readOnly = true)
public Page<MinutesDTO> getMinutesListByUserId(String userId, Pageable pageable) {
log.debug("Getting minutes list by userId: {}", userId);
// 여기서는 임시로 작성자 기준으로 조회 (실제로는 참석자나 권한 기반으로 조회해야 )
List<Minutes> minutesList = minutesReader.findByCreatedBy(userId);
// 사용자가 생성했거나 참여한 회의의 회의록 조회
List<Minutes> minutesList = minutesReader.findByParticipantUserId(userId);
// Minutes를 MinutesDTO로 변환
List<MinutesDTO> minutesDTOList = minutesList.stream()
.map(this::convertToMinutesDTO)
.collect(Collectors.toList());
// 페이징 처리 (임시로 전체 목록 반환)
// 페이징 처리
int start = (int) pageable.getOffset();
int end = Math.min((start + pageable.getPageSize()), minutesDTOList.size());
List<MinutesDTO> pageContent = minutesDTOList.subList(start, end);
@ -371,6 +372,15 @@ public class MinutesService implements
log.warn("섹션 정보 변환 실패 - minutesId: {}", minutes.getMinutesId(), e);
}
// 검증완료율 계산 (작성중 상태일 때만)
Integer verificationRate = null;
if ("DRAFT".equals(minutes.getStatus()) && sectionDTOs != null && !sectionDTOs.isEmpty()) {
long verifiedCount = sectionDTOs.stream()
.filter(section -> Boolean.TRUE.equals(section.getIsVerified()))
.count();
verificationRate = (int) ((verifiedCount * 100) / sectionDTOs.size());
}
// decisions 로깅
log.info("Minutes decisions 값 확인 - minutesId: {}, decisions: {}",
minutes.getMinutesId(), minutes.getDecisions());
@ -389,6 +399,7 @@ public class MinutesService implements
.todoCount(todoCount)
.completedTodoCount(completedTodoCount)
.participantCount(participantCount)
.verificationRate(verificationRate)
.memo("") // 메모 필드는 추후 구현
.sections(sectionDTOs) // 섹션 정보 추가
.decisions(minutes.getDecisions()) // decisions 필드 추가

View File

@ -56,4 +56,13 @@ public interface MinutesReader {
* @return AI 통합 회의록
*/
Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId);
/**
* 사용자가 참여한 회의의 회의록 목록 조회
* 사용자가 생성했거나 참여한 회의의 회의록을 모두 조회
*
* @param userId 사용자 ID
* @return 회의록 목록
*/
List<Minutes> findByParticipantUserId(String userId);
}

View File

@ -3,15 +3,12 @@ package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.common.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;
@ -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.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;
@ -40,17 +36,10 @@ 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;
/**
@ -69,7 +58,6 @@ 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;
@ -110,14 +98,18 @@ public class MinutesController {
// DTO를 Response 형식으로 변환
List<MinutesListResponse.MinutesItem> minutesList = minutesPage.getContent().stream()
.map(this::convertToMinutesItem)
.map(dto -> convertToMinutesItem(dto, userId))
.collect(Collectors.toList());
// 필터링 적용 (상태별)
// 필터링 적용 (상태별, 참여 유형별, 검색어)
List<MinutesListResponse.MinutesItem> filteredMinutes = minutesList.stream()
.filter(item -> filterByStatus(item, status))
.filter(item -> filterByParticipationType(item, participationType))
.filter(item -> filterBySearch(item, search))
.collect(Collectors.toList());
// 필터링 정렬 적용 (프론트엔드 정렬과 일치)
applySorting(filteredMinutes, sortBy, sortDir);
// 통계 계산 (전체 데이터 기준)
MinutesListResponse.Statistics stats = calculateRealStatistics(userId, participationType);
@ -477,7 +469,8 @@ public class MinutesController {
case "title":
return Sort.by(direction, "title");
case "meeting":
return Sort.by(direction, "createdAt"); // 회의 일시로 정렬 (임시로 생성일시 사용)
// 회의 일시로 정렬 - DB에 meetingDate 필드가 없으므로 createdAt 사용
return Sort.by(direction, "createdAt");
case "modified":
default:
return Sort.by(direction, "lastModifiedAt");
@ -489,8 +482,21 @@ public class MinutesController {
*/
private MinutesListResponse.Statistics calculateRealStatistics(String userId, String participationType) {
try {
// 전체 회의록 조회 (작성자 기준)
List<Minutes> allMinutes = minutesService.getMinutesByCreator(userId);
// 전체 회의록 조회 (참여자 기준)
List<MinutesDTO> allMinutes = minutesService.getMinutesListByUserId(userId, PageRequest.of(0, Integer.MAX_VALUE)).getContent();
// 참여 유형 필터링
if (participationType != null && !participationType.isEmpty()) {
if ("created".equals(participationType)) {
allMinutes = allMinutes.stream()
.filter(m -> userId.equals(m.getCreatedBy()))
.collect(Collectors.toList());
} else if ("attended".equals(participationType)) {
allMinutes = allMinutes.stream()
.filter(m -> !userId.equals(m.getCreatedBy()))
.collect(Collectors.toList());
}
}
long totalCount = allMinutes.size();
long draftCount = allMinutes.stream()
@ -515,10 +521,39 @@ public class MinutesController {
}
}
private MinutesListResponse.MinutesItem convertToMinutesItem(MinutesDTO minutesDTO) {
// 완료율 계산
int completionRate = minutesDTO.getTodoCount() > 0 ?
(minutesDTO.getCompletedTodoCount() * 100) / minutesDTO.getTodoCount() : 100;
private MinutesListResponse.MinutesItem convertToMinutesItem(MinutesDTO minutesDTO, String userId) {
// 검증완료율 계산 (작성중 상태일 때는 verificationRate 사용, 확정완료시 100%)
int verificationRate;
if ("DRAFT".equals(minutesDTO.getStatus()) && minutesDTO.getVerificationRate() != null) {
verificationRate = minutesDTO.getVerificationRate();
} else if ("FINALIZED".equals(minutesDTO.getStatus())) {
verificationRate = 100;
} else {
// 기본값 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()
.minutesId(minutesDTO.getMinutesId())
@ -528,14 +563,16 @@ public class MinutesController {
.version(minutesDTO.getVersion())
.createdAt(minutesDTO.getCreatedAt())
.lastModifiedAt(minutesDTO.getLastModifiedAt())
.meetingDate(minutesDTO.getCreatedAt()) // 임시로 생성일시 사용
.meetingDate(meetingDateTime)
.meetingTime(meetingTime)
.createdBy(minutesDTO.getCreatedBy())
.createdByName(creatorName)
.lastModifiedBy(minutesDTO.getLastModifiedBy())
.participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 0)
.todoCount(minutesDTO.getTodoCount())
.completedTodoCount(minutesDTO.getCompletedTodoCount())
.completionRate(completionRate)
.isCreatedByUser(true) // 현재는 작성자 기준으로만 조회하므로 true
.verificationRate(verificationRate)
.isCreator(minutesDTO.getCreatedBy().equals(userId)) // 현재 사용자가 생성자인지 확인
.build();
}
@ -556,10 +593,19 @@ public class MinutesController {
}
/**
* 참여 유형별 필터링 - 현재는 사용하지 않음 (작성자 기준으로만 조회)
* 참여 유형별 필터링
*/
private boolean filterByParticipationType(MinutesListResponse.MinutesItem item, String participationType, String userId) {
// 현재는 작성자 기준으로만 조회하므로 항상 true 반환
private boolean filterByParticipationType(MinutesListResponse.MinutesItem item, String participationType) {
if (participationType == null || participationType.isEmpty()) {
return true; // 필터 미적용시 모두 표시
}
if ("created".equals(participationType)) {
return item.isCreator(); // 사용자가 생성한 회의록만
} else if ("attended".equals(participationType)) {
return !item.isCreator(); // 사용자가 참여만 회의록
}
return true;
}
@ -576,7 +622,7 @@ public class MinutesController {
}
/**
* 정렬 적용
* 정렬 적용 (필터링 )
*/
private void applySorting(List<MinutesListResponse.MinutesItem> items, String sortBy, String sortDir) {
boolean ascending = "asc".equalsIgnoreCase(sortDir);
@ -588,78 +634,20 @@ 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, userId))
.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 {
@ -921,11 +909,6 @@ 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)) {
@ -959,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) {
// 안건별 AI 요약에서 핵심내용 추출
@ -1014,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) {
// TODO: AI를 통한 키워드 추출 로직 구현
@ -1231,41 +1137,6 @@ 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);
}
}
/**
* 실제 회의 시간 계산

View File

@ -6,7 +6,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* AI 추출 Todo DTO (제목만)
* AI 추출 Todo DTO
*/
@Getter
@Builder
@ -18,4 +18,9 @@ public class ExtractedTodoDTO {
* Todo 제목
*/
private String title;
/**
* 담당자 이름 (있는 경우에만)
*/
private String assignee;
}

View File

@ -46,12 +46,32 @@ 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 completionRate; // 검증완료율
private boolean isCreatedByUser; // 사용자가 생성한 회의록 여부
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 "";
}
}
}

View File

@ -16,6 +16,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
/**
@ -23,6 +24,7 @@ import org.springframework.stereotype.Component;
* Azure EventHub를 통한 이벤트 발행
*/
@Component
@Primary
@Slf4j
@org.springframework.boot.autoconfigure.condition.ConditionalOnBean(name = "eventProducer")
public class EventHubPublisher implements EventPublisher {

View File

@ -1,5 +1,6 @@
package com.unicorn.hgzero.meeting.infra.event.publisher;
import com.azure.messaging.eventhubs.EventHubProducerClient;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingStartedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingEndedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.TodoAssignedEvent;
@ -8,7 +9,6 @@ import com.unicorn.hgzero.meeting.infra.event.dto.MinutesAnalysisRequestEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesFinalizedEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@ -20,8 +20,7 @@ import java.util.List;
* EventHub가 설정되지 않은 경우 사용되는 더미 구현체
*/
@Component
@Primary
@ConditionalOnMissingBean(name = "eventProducer")
@ConditionalOnMissingBean(EventHubProducerClient.class)
@Slf4j
public class NoOpEventPublisher implements EventPublisher {

View File

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

View File

@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@ -75,9 +76,32 @@ public class MinutesGateway implements MinutesReader, MinutesWriter {
@Override
public Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId) {
log.debug("회의 ID로 AI 통합 회의록 조회: {}", meetingId);
return minutesJpaRepository.findByMeetingIdAndUserIdIsNull(meetingId)
List<MinutesEntity> consolidatedMinutes = minutesJpaRepository.findByMeetingIdAndUserIdIsNull(meetingId);
if (consolidatedMinutes.isEmpty()) {
return Optional.empty();
}
// 여러 개가 있을 경우 가장 최신 것을 반환 (updatedAt 또는 createdAt 기준)
return consolidatedMinutes.stream()
.sorted((m1, m2) -> {
LocalDateTime time1 = m1.getUpdatedAt() != null ? m1.getUpdatedAt() : m1.getCreatedAt();
LocalDateTime time2 = m2.getUpdatedAt() != null ? m2.getUpdatedAt() : m2.getCreatedAt();
return time2.compareTo(time1); // 최신순
})
.findFirst()
.map(MinutesEntity::toDomain);
}
@Override
public List<Minutes> findByParticipantUserId(String userId) {
log.debug("사용자가 참여한 회의의 회의록 조회: {}", userId);
// 사용자가 생성한 회의록과 사용자가 참여한 회의의 회의록을 모두 조회
// 현재는 생성자 기준으로만 조회 (추후 참석자 테이블과 조인하여 구현 필요)
return minutesJpaRepository.findByCreatedByOrParticipantUserId(userId).stream()
.map(MinutesEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Minutes save(Minutes minutes) {

View File

@ -57,6 +57,13 @@ 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

@ -37,7 +37,7 @@ public class MinutesSectionEntity extends BaseTimeEntity {
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "order")
@Column(name = "\"order\"")
private Integer order;
@Column(name = "verified")

View File

@ -50,7 +50,20 @@ public interface MinutesJpaRepository extends JpaRepository<MinutesEntity, Strin
List<MinutesEntity> findByMeetingIdAndUserIdIsNotNull(String meetingId);
/**
* 회의 ID로 AI 통합 회의록 조회 (user_id IS NULL)
* 회의 ID로 AI 통합 회의록 목록 조회 (user_id IS NULL)
*/
Optional<MinutesEntity> findByMeetingIdAndUserIdIsNull(String meetingId);
List<MinutesEntity> findByMeetingIdAndUserIdIsNull(String meetingId);
/**
* 사용자가 생성했거나 참여한 회의의 회의록 조회
* 현재는 생성자 기준으로만 조회 (추후 참석자 테이블과 조인 필요)
*
* @param userId 사용자 ID
* @return 회의록 목록
*/
default List<MinutesEntity> findByCreatedByOrParticipantUserId(String userId) {
// TODO: 참석자 테이블(participants) 조인하여 참여한 회의의 회의록도 조회하도록 구현 필요
// 현재는 임시로 생성자 기준으로만 조회
return findByCreatedBy(userId);
}
}

View File

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

View File

@ -0,0 +1,274 @@
-- ========================================
-- AI 회의록 요약 테스트 데이터
-- ========================================
-- 목적: Minutes.decisions 및 AgendaSection 저장 검증
-- 회의: 2025년 신제품 런칭 전략 회의
-- 참석자: 3명 (마케팅팀장, 개발팀장, 디자인팀장)
-- 안건: 3개 (타겟 고객 설정, 핵심 기능 정의, 런칭 일정)
-- ========================================
-- 1. 회의 생성
-- ========================================
INSERT INTO meetings (
meeting_id,
title,
purpose,
description,
location,
scheduled_at,
end_time,
started_at,
ended_at,
status,
organizer_id,
template_id,
created_at,
updated_at
) VALUES (
'ai_test_meeting',
'2025년 신제품 런칭 전략 회의',
'신제품 출시를 위한 전략 수립 및 일정 협의',
'타겟 고객층 정의, 핵심 기능 결정, 출시 일정 확정',
'본사 4층 회의실',
'2025-01-15 14:00:00',
'2025-01-15 16:00:00',
'2025-01-15 14:05:00',
NULL, -- 아직 종료 안됨
'IN_PROGRESS',
'user_organizer',
'template_general',
NOW(),
NOW()
);
-- ========================================
-- 2. 참석자별 회의록 생성 (user_id NOT NULL)
-- ========================================
-- 2-1. 마케팅팀장 회의록
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'ai_test_minutes_marketing',
'ai_test_meeting',
'user_marketing',
'2025년 신제품 런칭 전략 회의 - 마케팅팀장',
'DRAFT',
1,
'user_marketing_head',
NOW(),
NOW()
);
-- 2-2. 개발팀장 회의록
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'ai_test_minutes_dev',
'ai_test_meeting',
'user_dev',
'2025년 신제품 런칭 전략 회의 - 개발팀장',
'DRAFT',
1,
'user_dev',
NOW(),
NOW()
);
-- 2-3. 디자인팀장 회의록
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'ai_test_minutes_design',
'ai_test_meeting',
'user_design',
'2025년 신제품 런칭 전략 회의 - 디자인팀장',
'DRAFT',
1,
'user_design',
NOW(),
NOW()
);
-- ========================================
-- 3. 마케팅팀장 회의록 섹션 (MEMO 타입)
-- ========================================
INSERT INTO minutes_sections (
id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
locked_by
) VALUES (
'ai_test_section_marketing',
'ai_test_minutes_marketing',
'MEMO',
'마케팅팀장 메모',
'【안건 1: 타겟 고객 설정】
- : 20-30 , 1
- : 20 ~30
- : 40
- : 1 20-30 , SNS
2:
- AI
- :
- 1
- : IoT 2 ( )
- : AI
3:
- : 2025 6 1
- 5
- : 5
- : 3
- : 2 ',
1,
FALSE,
FALSE,
NULL
);
-- ========================================
-- 4. 개발팀장 회의록 섹션 (MEMO 타입)
-- ========================================
INSERT INTO minutes_sections (
id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
locked_by
) VALUES (
'ai_test_section_dev',
'ai_test_minutes_dev',
'MEMO',
'개발팀장 메모',
'【안건 1: 타겟 고객 설정】
- : 20-30 UX가
- 20-30
- AI
2:
- AI: OpenAI Whisper Google Speech API
- :
- : AI는
- : IoT 2 ( )
- : 1 AI
- : 2
3:
- 6 1
- : 2 , 3 , 4 , 5 QA
- : 3 , 5
- : 4
- : , ',
1,
FALSE,
FALSE,
NULL
);
-- ========================================
-- 5. 디자인팀장 회의록 섹션 (MEMO 타입)
-- ========================================
INSERT INTO minutes_sections (
id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
locked_by
) VALUES (
'ai_test_section_design',
'ai_test_minutes_design',
'MEMO',
'디자인팀장 메모',
'【안건 1: 타겟 고객 설정】
- 20-30 , MZ세대
- : "미니멀하고 감각적인 디자인으로 차별화해야 합니다"
- : HomePod, Nest Hub
- , , 3
2:
- UI가 - LED
- : "사용자가 AI가 듣고 있다는 걸 직관적으로 알 수 있어야 합니다"
-
- :
- : 2 UI/UX
3:
- 6 3
- : 2 , 3 , 4
- : "패키지 디자인도 프리미엄 느낌으로 가야 합니다"
- :
- : 3
- : 4 ',
1,
FALSE,
FALSE,
NULL
);
-- ========================================
-- 검증 쿼리
-- ========================================
-- 회의 확인
-- SELECT * FROM meetings WHERE meeting_id = 'ai_test_meeting_001';
-- 참석자별 회의록 확인
-- SELECT minutes_id, user_id, title FROM minutes WHERE meeting_id = 'ai_test_meeting_001';
-- 회의록 섹션 확인
-- SELECT id, minutes_id, title, LEFT(content, 100) as content_preview
-- FROM minutes_sections
-- WHERE minutes_id LIKE 'ai_test_minutes_%';
-- ========================================
-- 테스트 실행 가이드
-- ========================================
-- 1. 이 SQL 실행하여 테스트 데이터 생성
-- 2. POST /api/meetings/ai_test_meeting_001/end 호출
-- 3. 검증:
-- - minutes 테이블에 userId=NULL인 통합 회의록 생성 확인
-- - minutes.decisions 필드에 전체 결정사항 저장 확인
-- - agenda_sections 테이블에 3개 안건 저장 확인
-- - agenda_sections.summary에 논의+결정 내용 저장 확인

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,15 @@ import java.util.concurrent.CompletableFuture;
/**
* Azure Event Hub 이벤트 발행자 구현체
* Azure Event Hubs를 통한 실제 이벤트 발행 기능
*
* 환경변수 설정:
* - EVENTHUB_CONNECTION_STRING: Azure Event Hub 연결 문자열
* - EVENTHUB_NAME: Event Hub 이름
*
* 이벤트 발행 프로세스:
* 1. @PostConstruct에서 EventHubProducerClient 초기화
* 2. publishAsync() 호출 비동기로 이벤트 발행
* 3. AI Python 서비스에서 Event Hub를 구독하여 실시간 처리
*/
@Slf4j
@Component

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB