mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 09:06:24 +00:00
Compare commits
43 Commits
e87f916657
...
cdae3dee7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdae3dee7e | ||
|
|
0d56824fe8 | ||
|
|
aa2cbf54b4 | ||
|
|
761dddc466 | ||
|
|
f8e41309a2 | ||
|
|
3227db01cd | ||
|
|
019d6f2d98 | ||
|
|
119b9d7931 | ||
|
|
82c6873450 | ||
|
|
85ab3007c6 | ||
|
|
af53c80439 | ||
|
|
de9f88ff0c | ||
|
|
3d6742505a | ||
|
|
44f02a2cc6 | ||
|
|
de6c68d4d1 | ||
|
|
a2ef408a85 | ||
|
|
c4bd8064ec | ||
|
|
5515909206 | ||
|
|
ec73def9d1 | ||
|
|
e1741c707e | ||
|
|
599f880e81 | ||
|
|
4d4fd5cd32 | ||
|
|
1024fbd25d | ||
|
|
db16306b06 | ||
|
|
b5159ef74e | ||
|
|
d647cbc4bb | ||
|
|
7e88cdceee | ||
|
|
18d3ac8d79 | ||
|
|
1d9fa37fe7 | ||
|
|
784b48548b | ||
|
|
52b32cf978 | ||
|
|
b0fac155c6 | ||
|
|
72419c320a | ||
|
|
74c9506249 | ||
|
|
a27f4dc95d | ||
|
|
b8942c9e04 | ||
|
|
d01d4d0b5d | ||
|
|
2d096265b5 | ||
|
|
c00f1b03b9 | ||
|
|
258bef0891 | ||
|
|
7e3f7b9471 | ||
|
|
e406248572 | ||
|
|
ed9fa6f934 |
7
.github/kustomize/base/common/ingress.yaml
vendored
7
.github/kustomize/base/common/ingress.yaml
vendored
@ -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
345
Jenkinsfile
vendored
@ -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
375
README.md
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
docs/(MVP) AI 기반 회의록 작성 서비스.pdf
Normal file
BIN
docs/(MVP) AI 기반 회의록 작성 서비스.pdf
Normal file
Binary file not shown.
BIN
docs/(MVP) AI 기반 회의록 작성 서비스.pptx
Normal file
BIN
docs/(MVP) AI 기반 회의록 작성 서비스.pptx
Normal file
Binary file not shown.
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.1.pptx
Normal file
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.1.pptx
Normal file
Binary file not shown.
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.11.pdf
Normal file
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.11.pdf
Normal file
Binary file not shown.
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.11.pptx
Normal file
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.11.pptx
Normal file
Binary file not shown.
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.12.pptx
Normal file
BIN
docs/(MVP) AI 기반 회의록 작성 서비스_v1.12.pptx
Normal file
Binary file not shown.
BIN
docs/aiHGZero.png
Normal file
BIN
docs/aiHGZero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
docs/prototype.png
Normal file
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
203
meeting/section_id.md
Normal 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. 모니터링 강화
|
||||
- 데이터베이스 스키마 변경 추적
|
||||
- 마이그레이션 실패 알림 설정
|
||||
@ -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")
|
||||
|
||||
@ -41,5 +41,6 @@ public class MeetingEndDTO {
|
||||
@Builder
|
||||
public static class TodoSummaryDTO {
|
||||
private final String title;
|
||||
private final String assignee;
|
||||
}
|
||||
}
|
||||
@ -125,6 +125,12 @@ public class MinutesDTO {
|
||||
* 참석자 수
|
||||
*/
|
||||
private final Integer participantCount;
|
||||
|
||||
/**
|
||||
* 검증완료율 (작성중 상태일 때만 유효)
|
||||
* 0-100 사이의 값
|
||||
*/
|
||||
private final Integer verificationRate;
|
||||
|
||||
/**
|
||||
* 회의 정보
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 필드 추가
|
||||
|
||||
@ -56,4 +56,13 @@ public interface MinutesReader {
|
||||
* @return AI 통합 회의록
|
||||
*/
|
||||
Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId);
|
||||
|
||||
/**
|
||||
* 사용자가 참여한 회의의 회의록 목록 조회
|
||||
* 사용자가 생성했거나 참여한 회의의 회의록을 모두 조회
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @return 회의록 목록
|
||||
*/
|
||||
List<Minutes> findByParticipantUserId(String userId);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 회의 시간 계산
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,4 +156,4 @@ azure:
|
||||
ai:
|
||||
service:
|
||||
url: ${AI_SERVICE_URL:http://localhost:8087}
|
||||
timeout: ${AI_SERVICE_TIMEOUT:30000}
|
||||
timeout: ${AI_SERVICE_TIMEOUT:60000}
|
||||
|
||||
274
meeting/src/test/resources/test-data-ai-minutes.sql
Normal file
274
meeting/src/test/resources/test-data-ai-minutes.sql
Normal file
@ -0,0 +1,274 @@
|
||||
-- ========================================
|
||||
-- AI 회의록 요약 테스트 데이터
|
||||
-- ========================================
|
||||
-- 목적: Minutes.decisions 및 AgendaSection 저장 검증
|
||||
-- 회의: 2025년 신제품 런칭 전략 회의
|
||||
-- 참석자: 3명 (마케팅팀장, 개발팀장, 디자인팀장)
|
||||
-- 안건: 3개 (타겟 고객 설정, 핵심 기능 정의, 런칭 일정)
|
||||
|
||||
-- ========================================
|
||||
-- 1. 회의 생성
|
||||
-- ========================================
|
||||
INSERT INTO meetings (
|
||||
meeting_id,
|
||||
title,
|
||||
purpose,
|
||||
description,
|
||||
location,
|
||||
scheduled_at,
|
||||
end_time,
|
||||
started_at,
|
||||
ended_at,
|
||||
status,
|
||||
organizer_id,
|
||||
template_id,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'ai_test_meeting',
|
||||
'2025년 신제품 런칭 전략 회의',
|
||||
'신제품 출시를 위한 전략 수립 및 일정 협의',
|
||||
'타겟 고객층 정의, 핵심 기능 결정, 출시 일정 확정',
|
||||
'본사 4층 회의실',
|
||||
'2025-01-15 14:00:00',
|
||||
'2025-01-15 16:00:00',
|
||||
'2025-01-15 14:05:00',
|
||||
NULL, -- 아직 종료 안됨
|
||||
'IN_PROGRESS',
|
||||
'user_organizer',
|
||||
'template_general',
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
|
||||
-- ========================================
|
||||
-- 2. 참석자별 회의록 생성 (user_id NOT NULL)
|
||||
-- ========================================
|
||||
|
||||
-- 2-1. 마케팅팀장 회의록
|
||||
INSERT INTO minutes (
|
||||
minutes_id,
|
||||
meeting_id,
|
||||
user_id,
|
||||
title,
|
||||
status,
|
||||
version,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'ai_test_minutes_marketing',
|
||||
'ai_test_meeting',
|
||||
'user_marketing',
|
||||
'2025년 신제품 런칭 전략 회의 - 마케팅팀장',
|
||||
'DRAFT',
|
||||
1,
|
||||
'user_marketing_head',
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
|
||||
-- 2-2. 개발팀장 회의록
|
||||
INSERT INTO minutes (
|
||||
minutes_id,
|
||||
meeting_id,
|
||||
user_id,
|
||||
title,
|
||||
status,
|
||||
version,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'ai_test_minutes_dev',
|
||||
'ai_test_meeting',
|
||||
'user_dev',
|
||||
'2025년 신제품 런칭 전략 회의 - 개발팀장',
|
||||
'DRAFT',
|
||||
1,
|
||||
'user_dev',
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
|
||||
-- 2-3. 디자인팀장 회의록
|
||||
INSERT INTO minutes (
|
||||
minutes_id,
|
||||
meeting_id,
|
||||
user_id,
|
||||
title,
|
||||
status,
|
||||
version,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'ai_test_minutes_design',
|
||||
'ai_test_meeting',
|
||||
'user_design',
|
||||
'2025년 신제품 런칭 전략 회의 - 디자인팀장',
|
||||
'DRAFT',
|
||||
1,
|
||||
'user_design',
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
|
||||
-- ========================================
|
||||
-- 3. 마케팅팀장 회의록 섹션 (MEMO 타입)
|
||||
-- ========================================
|
||||
|
||||
INSERT INTO minutes_sections (
|
||||
id,
|
||||
minutes_id,
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
"order",
|
||||
verified,
|
||||
locked,
|
||||
locked_by
|
||||
) VALUES (
|
||||
'ai_test_section_marketing',
|
||||
'ai_test_minutes_marketing',
|
||||
'MEMO',
|
||||
'마케팅팀장 메모',
|
||||
'【안건 1: 타겟 고객 설정】
|
||||
- 주요 타겟: 20-30대 직장인, 특히 재택근무자와 1인 가구
|
||||
- 김마케팅팀장 의견: 최근 시장조사 결과 20대 후반~30대 초반 직장인들의 스마트홈 제품 관심도가 가장 높았습니다
|
||||
- 부타겟: 40대 맞벌이 부부
|
||||
- 결정사항: 1차 타겟을 20-30대 직장인으로 확정, SNS 마케팅 집중
|
||||
|
||||
【안건 2: 핵심 기능 정의】
|
||||
- 음성인식 AI 비서 기능은 필수로 포함하기로 결정
|
||||
- 김마케팅팀장: 경쟁사 제품 대비 차별화를 위해 멀티 디바이스 연동 기능이 핵심입니다
|
||||
- 스마트폰 앱 연동은 1차 출시에 포함
|
||||
- 보류: IoT 센서 연동은 2차 업데이트로 연기 (기술 검증 필요)
|
||||
- 담당: 이개발팀장이 AI 음성인식 기술 파트너 컨택 예정
|
||||
|
||||
【안건 3: 런칭 일정】
|
||||
- 목표 출시일: 2025년 6월 1일
|
||||
- 마케팅 캠페인은 5월 초부터 티저 광고 시작
|
||||
- 김마케팅팀장: 5월 첫째 주에 인플루언서 협업 콘텐츠 제작 필요
|
||||
- 결정: 박디자인팀장이 3월 말까지 프로토타입 디자인 완료
|
||||
- 할일: 김마케팅팀장이 2월 중 타겟 인플루언서 리스트 작성',
|
||||
1,
|
||||
FALSE,
|
||||
FALSE,
|
||||
NULL
|
||||
);
|
||||
|
||||
-- ========================================
|
||||
-- 4. 개발팀장 회의록 섹션 (MEMO 타입)
|
||||
-- ========================================
|
||||
|
||||
INSERT INTO minutes_sections (
|
||||
id,
|
||||
minutes_id,
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
"order",
|
||||
verified,
|
||||
locked,
|
||||
locked_by
|
||||
) VALUES (
|
||||
'ai_test_section_dev',
|
||||
'ai_test_minutes_dev',
|
||||
'MEMO',
|
||||
'개발팀장 메모',
|
||||
'【안건 1: 타겟 고객 설정】
|
||||
- 이개발팀장: 타겟 연령층이 20-30대라면 모바일 앱 UX가 정말 중요합니다
|
||||
- 20-30대 직장인 타겟 동의
|
||||
- 기술적으로 이 연령층은 새로운 기술 수용도가 높아서 AI 기능 적극 활용 예상
|
||||
|
||||
【안건 2: 핵심 기능 정의】
|
||||
- 음성인식 AI: OpenAI Whisper 또는 Google Speech API 검토 중
|
||||
- 이개발팀장: 멀티 디바이스 연동을 위해서는 클라우드 기반 아키텍처 필수입니다
|
||||
- 결정: 음성인식 AI는 한국어 특화 모델 사용하기로 확정
|
||||
- 보류사항: IoT 센서는 기술 검증 후 2차 업데이트 (전력 소비 이슈)
|
||||
- 할일: 이개발팀장이 1월 말까지 AI 음성인식 기술 파트너 미팅 잡기
|
||||
- 할일: 최개발자가 2월 중 클라우드 인프라 설계서 작성
|
||||
|
||||
【안건 3: 런칭 일정】
|
||||
- 6월 1일 출시 목표는 타이트하지만 가능할 것으로 판단
|
||||
- 개발 일정: 2월 프로토타입, 3월 알파, 4월 베타, 5월 최종 QA
|
||||
- 이개발팀장: 베타 테스트는 최소 3주 필요, 5월 초에 시작해야 합니다
|
||||
- 결정: 개발팀은 4월 중순까지 베타 버전 완성
|
||||
- 보류: 다국어 지원은 일단 한국어만, 영어는 추후 검토',
|
||||
1,
|
||||
FALSE,
|
||||
FALSE,
|
||||
NULL
|
||||
);
|
||||
|
||||
-- ========================================
|
||||
-- 5. 디자인팀장 회의록 섹션 (MEMO 타입)
|
||||
-- ========================================
|
||||
|
||||
INSERT INTO minutes_sections (
|
||||
id,
|
||||
minutes_id,
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
"order",
|
||||
verified,
|
||||
locked,
|
||||
locked_by
|
||||
) VALUES (
|
||||
'ai_test_section_design',
|
||||
'ai_test_minutes_design',
|
||||
'MEMO',
|
||||
'디자인팀장 메모',
|
||||
'【안건 1: 타겟 고객 설정】
|
||||
- 20-30대 타겟에 동의, 디자인 방향도 MZ세대 취향 고려 필요
|
||||
- 박디자인팀장: "미니멀하고 감각적인 디자인으로 차별화해야 합니다"
|
||||
- 레퍼런스: 애플 HomePod, 구글 Nest Hub
|
||||
- 색상은 화이트, 그레이, 블랙 3종으로 결정
|
||||
|
||||
【안건 2: 핵심 기능 정의】
|
||||
- 음성인식 피드백 UI가 중요 - LED 링 또는 디스플레이 활용
|
||||
- 박디자인팀장: "사용자가 AI가 듣고 있다는 걸 직관적으로 알 수 있어야 합니다"
|
||||
- 멀티 디바이스 연동 시 화면 전환 애니메이션 필요
|
||||
- 결정: 터치 인터페이스와 음성 인터페이스 병행
|
||||
- 할일: 김디자이너가 2월 말까지 UI/UX 목업 완성
|
||||
|
||||
【안건 3: 런칭 일정】
|
||||
- 6월 출시 위해 3월 말 프로토타입 디자인 완료 확약
|
||||
- 디자인 일정: 2월 컨셉 확정, 3월 프로토타입, 4월 최종 디자인
|
||||
- 박디자인팀장: "패키지 디자인도 프리미엄 느낌으로 가야 합니다"
|
||||
- 결정: 제품 패키지는 친환경 소재 사용
|
||||
- 할일: 박디자인팀장이 3월 말까지 프로토타입 디자인 완료
|
||||
- 할일: 박디자인팀장이 4월 중 패키지 디자인 최종안 제출',
|
||||
1,
|
||||
FALSE,
|
||||
FALSE,
|
||||
NULL
|
||||
);
|
||||
|
||||
-- ========================================
|
||||
-- 검증 쿼리
|
||||
-- ========================================
|
||||
|
||||
-- 회의 확인
|
||||
-- SELECT * FROM meetings WHERE meeting_id = 'ai_test_meeting_001';
|
||||
|
||||
-- 참석자별 회의록 확인
|
||||
-- SELECT minutes_id, user_id, title FROM minutes WHERE meeting_id = 'ai_test_meeting_001';
|
||||
|
||||
-- 회의록 섹션 확인
|
||||
-- SELECT id, minutes_id, title, LEFT(content, 100) as content_preview
|
||||
-- FROM minutes_sections
|
||||
-- WHERE minutes_id LIKE 'ai_test_minutes_%';
|
||||
|
||||
-- ========================================
|
||||
-- 테스트 실행 가이드
|
||||
-- ========================================
|
||||
-- 1. 이 SQL 실행하여 테스트 데이터 생성
|
||||
-- 2. POST /api/meetings/ai_test_meeting_001/end 호출
|
||||
-- 3. 검증:
|
||||
-- - minutes 테이블에 userId=NULL인 통합 회의록 생성 확인
|
||||
-- - minutes.decisions 필드에 전체 결정사항 저장 확인
|
||||
-- - agenda_sections 테이블에 3개 안건 저장 확인
|
||||
-- - agenda_sections.summary에 논의+결정 내용 저장 확인
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
BIN
think/솔루션우선순위평가.png
Normal file
BIN
think/솔루션우선순위평가.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 566 KiB |
BIN
think/솔루션우선순위평가_crop.png
Normal file
BIN
think/솔루션우선순위평가_crop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
Loading…
x
Reference in New Issue
Block a user