mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 11:26:25 +00:00
Merge branch 'main' of https://github.com/hwanny1128/HGZero into feat/meeting
This commit is contained in:
commit
b302076e24
176
.github/PULL_REQUEST_TEMPLATE_meeting_ai_db.md
vendored
Normal file
176
.github/PULL_REQUEST_TEMPLATE_meeting_ai_db.md
vendored
Normal file
@ -0,0 +1,176 @@
|
||||
# [DB] 회의종료 기능을 위한 스키마 추가
|
||||
|
||||
## 📋 요약
|
||||
회의 종료 시 참석자별 회의록을 AI가 통합하고 Todo를 자동 추출하기 위한 데이터베이스 스키마 추가
|
||||
|
||||
## 🎯 목적
|
||||
- 참석자별 회의록 저장 지원
|
||||
- AI 통합 회의록 생성 및 저장
|
||||
- 안건별 구조화된 회의록 관리
|
||||
- AI 요약 결과 캐싱 (성능 최적화)
|
||||
- Todo 자동 추출 정보 관리
|
||||
|
||||
## 📊 변경 내용
|
||||
|
||||
### 1. minutes 테이블 확장
|
||||
```sql
|
||||
ALTER TABLE minutes ADD COLUMN user_id VARCHAR(100);
|
||||
```
|
||||
- **목적**: 참석자별 회의록과 AI 통합 회의록 구분
|
||||
- **구분 방법**:
|
||||
- `user_id IS NULL` → AI 통합 회의록
|
||||
- `user_id IS NOT NULL` → 참석자별 회의록
|
||||
- **설계 개선**: `is_consolidated` 컬럼 불필요 (중복 정보 제거)
|
||||
|
||||
### 2. agenda_sections 테이블 생성 (신규)
|
||||
```sql
|
||||
CREATE TABLE agenda_sections (
|
||||
id, minutes_id, meeting_id,
|
||||
agenda_number, agenda_title,
|
||||
ai_summary_short, discussions,
|
||||
decisions (JSON), pending_items (JSON), opinions (JSON)
|
||||
);
|
||||
```
|
||||
- **목적**: 안건별 AI 요약 결과 저장
|
||||
- **JSON 필드**:
|
||||
- `decisions`: 결정 사항 배열
|
||||
- `pending_items`: 보류 사항 배열
|
||||
- `opinions`: 참석자별 의견 [{speaker, opinion}]
|
||||
|
||||
### 3. ai_summaries 테이블 생성 (신규)
|
||||
```sql
|
||||
CREATE TABLE ai_summaries (
|
||||
id, meeting_id, summary_type,
|
||||
source_minutes_ids (JSON), result (JSON),
|
||||
processing_time_ms, model_version,
|
||||
keywords (JSON), statistics (JSON)
|
||||
);
|
||||
```
|
||||
- **목적**: AI 요약 결과 캐싱 및 성능 최적화
|
||||
- **summary_type**:
|
||||
- `CONSOLIDATED`: 통합 회의록 요약
|
||||
- `TODO_EXTRACTION`: Todo 자동 추출
|
||||
- **캐싱 효과**: 재조회 시 3-5초 → 0.1초
|
||||
|
||||
### 4. todos 테이블 확장
|
||||
```sql
|
||||
ALTER TABLE todos
|
||||
ADD COLUMN extracted_by VARCHAR(50) DEFAULT 'AI',
|
||||
ADD COLUMN section_reference VARCHAR(200),
|
||||
ADD COLUMN extraction_confidence DECIMAL(3,2);
|
||||
```
|
||||
- **extracted_by**: `AI` (자동 추출) / `MANUAL` (수동 작성)
|
||||
- **section_reference**: 관련 안건 참조 (예: "안건 1")
|
||||
- **extraction_confidence**: AI 추출 신뢰도 (0.00~1.00)
|
||||
|
||||
## 🔄 데이터 플로우
|
||||
|
||||
```
|
||||
1. 회의 진행 중
|
||||
└─ 각 참석자가 회의록 작성
|
||||
└─ minutes 테이블 저장 (user_id: user@example.com)
|
||||
|
||||
2. 회의 종료
|
||||
└─ AI Service 호출
|
||||
└─ 참석자별 회의록 조회 (user_id IS NOT NULL)
|
||||
└─ Claude AI 통합 요약 생성
|
||||
└─ minutes 테이블 저장 (user_id: NULL)
|
||||
└─ agenda_sections 테이블 저장 (안건별 섹션)
|
||||
└─ ai_summaries 테이블 저장 (캐시)
|
||||
└─ todos 테이블 저장 (extracted_by: AI)
|
||||
|
||||
3. 회의록 조회
|
||||
└─ ai_summaries 캐시 조회 (빠름!)
|
||||
└─ agenda_sections 조회
|
||||
└─ 화면 렌더링
|
||||
```
|
||||
|
||||
## 📁 관련 파일
|
||||
|
||||
### 마이그레이션
|
||||
- `meeting/src/main/resources/db/migration/V3__add_meeting_end_support.sql`
|
||||
|
||||
### 문서
|
||||
- `docs/DB-Schema-회의종료.md` - 상세 스키마 문서
|
||||
- `docs/ERD-회의종료.puml` - ERD 다이어그램
|
||||
- `docs/회의종료-개발계획.md` - 전체 개발 계획
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
### 마이그레이션
|
||||
- [x] V3 마이그레이션 스크립트 작성
|
||||
- [x] 인덱스 추가 (성능 최적화)
|
||||
- [x] 외래키 제약조건 설정
|
||||
- [x] 트리거 생성 (updated_at 자동 업데이트)
|
||||
- [x] 코멘트 추가 (문서화)
|
||||
|
||||
### 문서
|
||||
- [x] DB 스키마 상세 문서
|
||||
- [x] ERD 다이어그램
|
||||
- [x] JSON 필드 구조 예시
|
||||
- [x] 쿼리 예시 작성
|
||||
- [x] 개발 계획서
|
||||
|
||||
### 설계 검증
|
||||
- [x] 중복 컬럼 제거 (is_consolidated)
|
||||
- [x] NULL 활용 (user_id로 구분)
|
||||
- [x] JSON 필드 구조 정의
|
||||
- [x] 인덱스 전략 수립
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 마이그레이션 테스트
|
||||
1. 로컬 환경에서 마이그레이션 실행
|
||||
2. 테이블 생성 확인
|
||||
3. 인덱스 생성 확인
|
||||
4. 외래키 제약조건 확인
|
||||
|
||||
### 성능 테스트
|
||||
1. 참석자별 회의록 조회 성능
|
||||
2. 안건별 섹션 조회 성능
|
||||
3. JSON 필드 쿼리 성능
|
||||
4. ai_summaries 캐시 조회 성능
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
### Meeting Service API 개발 (병렬 진행 가능)
|
||||
1. `GET /meetings/{meetingId}/minutes/by-participants` - 참석자별 회의록 조회
|
||||
2. `GET /meetings/{meetingId}/agenda-sections` - 안건별 섹션 조회
|
||||
3. `GET /meetings/{meetingId}/statistics` - 회의 통계 조회
|
||||
4. `POST /internal/ai-summaries` - AI 결과 저장 (내부 API)
|
||||
|
||||
### AI Service 개발 (병렬 진행 가능)
|
||||
1. Claude AI 프롬프트 설계
|
||||
2. `POST /transcripts/consolidate` - 통합 회의록 생성
|
||||
3. `POST /todos/extract` - Todo 자동 추출
|
||||
4. Meeting Service API 호출 통합
|
||||
|
||||
## 💬 리뷰 포인트
|
||||
|
||||
1. **DB 스키마 설계**
|
||||
- user_id만으로 참석자/통합 구분이 명확한가?
|
||||
- JSON 필드 구조가 적절한가?
|
||||
- 인덱스 전략이 최적인가?
|
||||
|
||||
2. **성능**
|
||||
- 인덱스가 충분한가?
|
||||
- JSON 필드 쿼리 성능이 괜찮은가?
|
||||
- 추가 인덱스가 필요한가?
|
||||
|
||||
3. **확장성**
|
||||
- 향후 필드 추가가 용이한가?
|
||||
- 다른 AI 모델 지원이 가능한가?
|
||||
|
||||
## 📌 참고 사항
|
||||
|
||||
- PostgreSQL 기준으로 작성됨
|
||||
- Flyway 자동 마이그레이션 지원
|
||||
- 샘플 데이터는 주석 처리 (운영 환경 고려)
|
||||
- 트리거 함수 포함 (updated_at 자동 업데이트)
|
||||
|
||||
## 🔗 관련 이슈
|
||||
<!-- 관련 이슈 번호가 있다면 링크 -->
|
||||
|
||||
---
|
||||
|
||||
**Merge 후 Meeting Service API 개발을 시작할 수 있습니다!**
|
||||
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/ai/suggestions
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ai-service
|
||||
port:
|
||||
number: 8087
|
||||
- path: /api/ai
|
||||
pathType: Prefix
|
||||
backend:
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -7,6 +7,7 @@ build/*/*/*
|
||||
**/.gradle/
|
||||
.vscode/
|
||||
**/.vscode/
|
||||
rag/venv/*
|
||||
|
||||
# Serena
|
||||
serena/
|
||||
@ -25,6 +26,7 @@ serena/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
ai-python/app/config.py
|
||||
|
||||
# Playwright
|
||||
.playwright-mcp/
|
||||
@ -50,3 +52,9 @@ design/*/*back*
|
||||
design/*back*
|
||||
backup/
|
||||
claudedocs/*back*
|
||||
|
||||
# Log files
|
||||
logs/
|
||||
**/logs/
|
||||
*.log
|
||||
**/*.log
|
||||
|
||||
10
CLAUDE.md
10
CLAUDE.md
@ -562,4 +562,14 @@ Product Designer (UI/UX 전문가)
|
||||
- "@design-help": "설계실행프롬프트 내용을 터미널에 출력"
|
||||
- "@develop-help": "개발실행프롬프트 내용을 터미널에 출력"
|
||||
- "@deploy-help": "배포실행프롬프트 내용을 터미널에 출력"
|
||||
|
||||
### Spring Boot 설정 관리
|
||||
- **설정 파일 구조**: `application.yml` + IntelliJ 실행 프로파일(`.run/*.run.xml`)로 관리
|
||||
- **금지 사항**: `application-{profile}.yml` 같은 프로파일별 설정 파일 생성 금지
|
||||
- **환경 변수 관리**: IntelliJ 실행 프로파일의 `<option name="env">` 섹션에서 관리
|
||||
- **application.yml 작성**: 환경 변수 플레이스홀더 사용 (`${DB_HOST:default}` 형식)
|
||||
- **실행 방법**:
|
||||
- IntelliJ: 실행 프로파일 선택 후 실행 (환경 변수 자동 적용)
|
||||
- 명령줄: 환경 변수 또는 `--args` 옵션으로 전달 (`--spring.profiles.active` 불필요)
|
||||
|
||||
```
|
||||
|
||||
35
ai-python/.dockerignore
Normal file
35
ai-python/.dockerignore
Normal file
@ -0,0 +1,35 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
API-DOCUMENTATION.md
|
||||
|
||||
# Test
|
||||
tests/
|
||||
test_*.py
|
||||
26
ai-python/.env.example
Normal file
26
ai-python/.env.example
Normal file
@ -0,0 +1,26 @@
|
||||
# 서버 설정
|
||||
PORT=8086
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Claude API
|
||||
CLAUDE_API_KEY=your-api-key-here
|
||||
CLAUDE_MODEL=claude-3-5-sonnet-20241022
|
||||
CLAUDE_MAX_TOKENS=2000
|
||||
CLAUDE_TEMPERATURE=0.3
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=20.249.177.114
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=Hi5Jessica!
|
||||
REDIS_DB=4
|
||||
|
||||
# Azure Event Hub
|
||||
EVENTHUB_CONNECTION_STRING=Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=
|
||||
EVENTHUB_NAME=hgzero-eventhub-name
|
||||
EVENTHUB_CONSUMER_GROUP=ai-transcript-group
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=["http://localhost:*","http://127.0.0.1:*"]
|
||||
|
||||
# 로깅
|
||||
LOG_LEVEL=INFO
|
||||
37
ai-python/.gitignore
vendored
Normal file
37
ai-python/.gitignore
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Distribution
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
250
ai-python/API-DOCUMENTATION.md
Normal file
250
ai-python/API-DOCUMENTATION.md
Normal file
@ -0,0 +1,250 @@
|
||||
# AI Service API Documentation
|
||||
|
||||
## 서비스 정보
|
||||
- **Base URL**: `http://localhost:8087`
|
||||
- **프로덕션 URL**: `http://{AKS-IP}:8087` (배포 후)
|
||||
- **포트**: 8087
|
||||
- **프로토콜**: HTTP
|
||||
- **CORS**: 모든 origin 허용 (개발 환경)
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### 1. 실시간 AI 제안사항 스트리밍 (SSE)
|
||||
|
||||
**엔드포인트**: `GET /api/ai/suggestions/meetings/{meeting_id}/stream`
|
||||
|
||||
**설명**: 회의 중 실시간으로 AI 제안사항을 Server-Sent Events로 스트리밍합니다.
|
||||
|
||||
**파라미터**:
|
||||
| 이름 | 위치 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|------|
|
||||
| meeting_id | path | string | O | 회의 ID |
|
||||
|
||||
**응답 형식**: `text/event-stream`
|
||||
|
||||
**SSE 이벤트 구조**:
|
||||
```
|
||||
event: ai-suggestion
|
||||
id: 15
|
||||
data: {"suggestions":[{"id":"uuid","content":"제안 내용","timestamp":"14:23:45","confidence":0.92}]}
|
||||
```
|
||||
|
||||
**응답 데이터 스키마**:
|
||||
```typescript
|
||||
interface SimpleSuggestion {
|
||||
id: string; // 제안 ID (UUID)
|
||||
content: string; // 제안 내용 (1-2문장)
|
||||
timestamp: string; // 타임스탬프 (HH:MM:SS)
|
||||
confidence: number; // 신뢰도 (0.0 ~ 1.0)
|
||||
}
|
||||
|
||||
interface RealtimeSuggestionsResponse {
|
||||
suggestions: SimpleSuggestion[];
|
||||
}
|
||||
```
|
||||
|
||||
**프론트엔드 연동 예시 (JavaScript/TypeScript)**:
|
||||
|
||||
```javascript
|
||||
// EventSource 연결
|
||||
const meetingId = 'meeting-123';
|
||||
const eventSource = new EventSource(
|
||||
`http://localhost:8087/api/ai/suggestions/meetings/${meetingId}/stream`
|
||||
);
|
||||
|
||||
// AI 제안사항 수신
|
||||
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
data.suggestions.forEach(suggestion => {
|
||||
console.log('새 제안:', suggestion.content);
|
||||
console.log('신뢰도:', suggestion.confidence);
|
||||
console.log('시간:', suggestion.timestamp);
|
||||
|
||||
// UI 업데이트
|
||||
addSuggestionToUI(suggestion);
|
||||
});
|
||||
});
|
||||
|
||||
// 에러 핸들링
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE 연결 오류:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
// 연결 종료 (회의 종료 시)
|
||||
function closeSuggestions() {
|
||||
eventSource.close();
|
||||
}
|
||||
```
|
||||
|
||||
**React 예시**:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Suggestion {
|
||||
id: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
function MeetingRoom({ meetingId }: { meetingId: string }) {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource(
|
||||
`http://localhost:8087/api/ai/suggestions/meetings/${meetingId}/stream`
|
||||
);
|
||||
|
||||
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
setSuggestions(prev => [...prev, ...data.suggestions]);
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.error('SSE 연결 오류');
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [meetingId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>AI 제안사항</h2>
|
||||
{suggestions.map(s => (
|
||||
<div key={s.id}>
|
||||
<span>{s.timestamp}</span>
|
||||
<p>{s.content}</p>
|
||||
<small>신뢰도: {(s.confidence * 100).toFixed(0)}%</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 헬스 체크
|
||||
|
||||
**엔드포인트**: `GET /health`
|
||||
|
||||
**설명**: 서비스 상태 확인 (Kubernetes probe용)
|
||||
|
||||
**응답 예시**:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"service": "AI Service",
|
||||
"port": 8087
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 서비스 정보
|
||||
|
||||
**엔드포인트**: `GET /`
|
||||
|
||||
**설명**: 서비스 기본 정보 조회
|
||||
|
||||
**응답 예시**:
|
||||
```json
|
||||
{
|
||||
"service": "AI Service",
|
||||
"version": "1.0.0",
|
||||
"status": "running",
|
||||
"endpoints": {
|
||||
"test": "/api/ai/suggestions/test",
|
||||
"stream": "/api/ai/suggestions/meetings/{meeting_id}/stream"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 동작 흐름
|
||||
|
||||
```
|
||||
1. 회의 시작
|
||||
└─> 프론트엔드가 SSE 연결 시작
|
||||
|
||||
2. 음성 녹음
|
||||
└─> STT 서비스가 텍스트 변환
|
||||
└─> Event Hub 발행
|
||||
└─> AI 서비스가 Redis에 축적
|
||||
|
||||
3. 실시간 분석 (5초마다)
|
||||
└─> Redis에서 텍스트 조회
|
||||
└─> 임계값(10개 세그먼트) 도달 시
|
||||
└─> Claude API 분석
|
||||
└─> SSE로 제안사항 전송
|
||||
└─> 프론트엔드 UI 업데이트
|
||||
|
||||
4. 회의 종료
|
||||
└─> SSE 연결 종료
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **연결 유지**:
|
||||
- SSE 연결은 장시간 유지되므로 네트워크 타임아웃 설정 필요
|
||||
- 브라우저는 연결 끊김 시 자동 재연결 시도
|
||||
|
||||
2. **CORS**:
|
||||
- 개발 환경: 모든 origin 허용
|
||||
- 프로덕션: 특정 도메인만 허용하도록 설정 필요
|
||||
|
||||
3. **에러 처리**:
|
||||
- SSE 연결 실패 시 재시도 로직 구현 권장
|
||||
- 네트워크 오류 시 사용자에게 알림
|
||||
|
||||
4. **성능**:
|
||||
- 한 회의당 하나의 SSE 연결만 유지
|
||||
- 불필요한 재연결 방지
|
||||
|
||||
## 테스트
|
||||
|
||||
### curl 테스트:
|
||||
```bash
|
||||
# 헬스 체크
|
||||
curl http://localhost:8087/health
|
||||
|
||||
# SSE 스트리밍 테스트
|
||||
curl -N http://localhost:8087/api/ai/suggestions/meetings/test-meeting/stream
|
||||
```
|
||||
|
||||
### 브라우저 테스트:
|
||||
1. 서비스 실행: `python3 main.py`
|
||||
2. Swagger UI 접속: http://localhost:8087/docs
|
||||
3. `/api/ai/suggestions/meetings/{meeting_id}/stream` 엔드포인트 테스트
|
||||
|
||||
## 환경 변수
|
||||
|
||||
프론트엔드에서 API URL을 환경 변수로 관리:
|
||||
|
||||
```env
|
||||
# .env.local
|
||||
NEXT_PUBLIC_AI_SERVICE_URL=http://localhost:8087
|
||||
```
|
||||
|
||||
```typescript
|
||||
const AI_SERVICE_URL = process.env.NEXT_PUBLIC_AI_SERVICE_URL || 'http://localhost:8087';
|
||||
|
||||
const eventSource = new EventSource(
|
||||
`${AI_SERVICE_URL}/api/ai/suggestions/meetings/${meetingId}/stream`
|
||||
);
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: SSE vs WebSocket?**
|
||||
A: SSE는 서버→클라이언트 단방향 통신에 최적화되어 있습니다. 이 서비스는 AI 제안사항을 프론트엔드로 전송만 하므로 SSE가 적합합니다.
|
||||
|
||||
**Q: 재연결은 어떻게?**
|
||||
A: 브라우저의 EventSource는 자동으로 재연결을 시도합니다. 추가 로직 불필요.
|
||||
|
||||
**Q: 여러 클라이언트가 동시 연결 가능?**
|
||||
A: 네, 각 클라이언트는 독립적으로 SSE 연결을 유지합니다.
|
||||
|
||||
**Q: 제안사항이 오지 않으면?**
|
||||
A: Redis에 충분한 텍스트(10개 세그먼트)가 축적되어야 분석이 시작됩니다. 5초마다 체크합니다.
|
||||
318
ai-python/DEPLOYMENT.md
Normal file
318
ai-python/DEPLOYMENT.md
Normal file
@ -0,0 +1,318 @@
|
||||
# AI Service (Python) - AKS 배포 가이드
|
||||
|
||||
## 📋 사전 준비
|
||||
|
||||
### 1. Azure Container Registry (ACR) 접근 권한
|
||||
```bash
|
||||
# ACR 로그인
|
||||
az acr login --name acrdigitalgarage02
|
||||
```
|
||||
|
||||
### 2. Kubernetes 클러스터 접근
|
||||
```bash
|
||||
# AKS 자격 증명 가져오기
|
||||
az aks get-credentials --resource-group <리소스그룹> --name <클러스터명>
|
||||
|
||||
# 네임스페이스 확인
|
||||
kubectl get namespace hgzero
|
||||
```
|
||||
|
||||
## 🐳 1단계: Docker 이미지 빌드 및 푸시
|
||||
|
||||
### 이미지 빌드
|
||||
```bash
|
||||
cd ai-python
|
||||
|
||||
# 이미지 빌드
|
||||
docker build -t acrdigitalgarage02.azurecr.io/hgzero/ai-service:latest .
|
||||
|
||||
# 특정 버전 태그도 함께 생성 (권장)
|
||||
docker tag acrdigitalgarage02.azurecr.io/hgzero/ai-service:latest \
|
||||
acrdigitalgarage02.azurecr.io/hgzero/ai-service:v1.0.0
|
||||
```
|
||||
|
||||
### ACR에 푸시
|
||||
```bash
|
||||
# latest 태그 푸시
|
||||
docker push acrdigitalgarage02.azurecr.io/hgzero/ai-service:latest
|
||||
|
||||
# 버전 태그 푸시
|
||||
docker push acrdigitalgarage02.azurecr.io/hgzero/ai-service:v1.0.0
|
||||
```
|
||||
|
||||
### 로컬 테스트 (선택)
|
||||
```bash
|
||||
# 이미지 실행 테스트
|
||||
docker run -p 8087:8087 \
|
||||
-e CLAUDE_API_KEY="your-api-key" \
|
||||
-e REDIS_HOST="20.249.177.114" \
|
||||
-e REDIS_PASSWORD="Hi5Jessica!" \
|
||||
acrdigitalgarage02.azurecr.io/hgzero/ai-service:latest
|
||||
|
||||
# 헬스 체크
|
||||
curl http://localhost:8087/health
|
||||
```
|
||||
|
||||
## 🔐 2단계: Kubernetes Secret 생성
|
||||
|
||||
### Claude API Key Secret 생성
|
||||
```bash
|
||||
# Claude API Key를 base64로 인코딩
|
||||
echo -n "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" | base64
|
||||
|
||||
# Secret 생성
|
||||
kubectl create secret generic ai-secret \
|
||||
--from-literal=claude-api-key="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" \
|
||||
-n hgzero
|
||||
```
|
||||
|
||||
### Event Hub Secret 생성 (기존에 없는 경우)
|
||||
```bash
|
||||
# Event Hub Connection String (AI Listen Policy)
|
||||
kubectl create secret generic azure-secret \
|
||||
--from-literal=eventhub-ai-connection-string="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=ai-listen-policy;SharedAccessKey=wqcbVIXlOMyn/C562lx6DD75AyjHQ87xo+AEhJ7js9Q=" \
|
||||
-n hgzero --dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
### Redis Secret 확인/생성
|
||||
```bash
|
||||
# 기존 Redis Secret 확인
|
||||
kubectl get secret redis-secret -n hgzero
|
||||
|
||||
# 없으면 생성
|
||||
kubectl create secret generic redis-secret \
|
||||
--from-literal=password="Hi5Jessica!" \
|
||||
-n hgzero
|
||||
```
|
||||
|
||||
### ConfigMap 확인
|
||||
```bash
|
||||
# Redis ConfigMap 확인
|
||||
kubectl get configmap redis-config -n hgzero
|
||||
|
||||
# 없으면 생성
|
||||
kubectl create configmap redis-config \
|
||||
--from-literal=host="20.249.177.114" \
|
||||
--from-literal=port="6379" \
|
||||
-n hgzero
|
||||
```
|
||||
|
||||
## 🚀 3단계: AI 서비스 배포
|
||||
|
||||
### 배포 실행
|
||||
```bash
|
||||
# 배포 매니페스트 적용
|
||||
kubectl apply -f deploy/k8s/backend/ai-service.yaml
|
||||
|
||||
# 배포 상태 확인
|
||||
kubectl get deployment ai-service -n hgzero
|
||||
kubectl get pods -n hgzero -l app=ai-service
|
||||
```
|
||||
|
||||
### 로그 확인
|
||||
```bash
|
||||
# Pod 이름 가져오기
|
||||
POD_NAME=$(kubectl get pods -n hgzero -l app=ai-service -o jsonpath='{.items[0].metadata.name}')
|
||||
|
||||
# 실시간 로그 확인
|
||||
kubectl logs -f $POD_NAME -n hgzero
|
||||
|
||||
# Event Hub 연결 로그 확인
|
||||
kubectl logs $POD_NAME -n hgzero | grep "Event Hub"
|
||||
```
|
||||
|
||||
## 🌐 4단계: Ingress 업데이트
|
||||
|
||||
### Ingress 적용
|
||||
```bash
|
||||
# Ingress 설정 적용
|
||||
kubectl apply -f .github/kustomize/base/common/ingress.yaml
|
||||
|
||||
# Ingress 확인
|
||||
kubectl get ingress -n hgzero
|
||||
kubectl describe ingress hgzero -n hgzero
|
||||
```
|
||||
|
||||
### 접속 테스트
|
||||
```bash
|
||||
# 서비스 URL
|
||||
AI_SERVICE_URL="http://hgzero-api.20.214.196.128.nip.io"
|
||||
|
||||
# 헬스 체크
|
||||
curl $AI_SERVICE_URL/api/ai/suggestions/test
|
||||
|
||||
# Swagger UI
|
||||
echo "Swagger UI: $AI_SERVICE_URL/swagger-ui.html"
|
||||
```
|
||||
|
||||
## ✅ 5단계: 배포 검증
|
||||
|
||||
### Pod 상태 확인
|
||||
```bash
|
||||
# Pod 상태
|
||||
kubectl get pods -n hgzero -l app=ai-service
|
||||
|
||||
# Pod 상세 정보
|
||||
kubectl describe pod -n hgzero -l app=ai-service
|
||||
|
||||
# Pod 리소스 사용량
|
||||
kubectl top pod -n hgzero -l app=ai-service
|
||||
```
|
||||
|
||||
### 서비스 확인
|
||||
```bash
|
||||
# Service 확인
|
||||
kubectl get svc ai-service -n hgzero
|
||||
|
||||
# Service 상세 정보
|
||||
kubectl describe svc ai-service -n hgzero
|
||||
```
|
||||
|
||||
### 엔드포인트 테스트
|
||||
```bash
|
||||
# 내부 테스트 (Pod에서)
|
||||
kubectl exec -it $POD_NAME -n hgzero -- curl http://localhost:8087/health
|
||||
|
||||
# 외부 테스트 (Ingress 통해)
|
||||
curl http://hgzero-api.20.214.196.128.nip.io/api/ai/suggestions/test
|
||||
|
||||
# SSE 스트리밍 테스트
|
||||
curl -N http://hgzero-api.20.214.196.128.nip.io/api/ai/suggestions/meetings/test-meeting/stream
|
||||
```
|
||||
|
||||
## 🔄 업데이트 배포
|
||||
|
||||
### 새 버전 배포
|
||||
```bash
|
||||
# 1. 새 이미지 빌드 및 푸시
|
||||
docker build -t acrdigitalgarage02.azurecr.io/hgzero/ai-service:v1.0.1 .
|
||||
docker push acrdigitalgarage02.azurecr.io/hgzero/ai-service:v1.0.1
|
||||
|
||||
# 2. Deployment 이미지 업데이트
|
||||
kubectl set image deployment/ai-service \
|
||||
ai-service=acrdigitalgarage02.azurecr.io/hgzero/ai-service:v1.0.1 \
|
||||
-n hgzero
|
||||
|
||||
# 3. 롤아웃 상태 확인
|
||||
kubectl rollout status deployment/ai-service -n hgzero
|
||||
|
||||
# 4. 롤아웃 히스토리
|
||||
kubectl rollout history deployment/ai-service -n hgzero
|
||||
```
|
||||
|
||||
### 롤백 (문제 발생 시)
|
||||
```bash
|
||||
# 이전 버전으로 롤백
|
||||
kubectl rollout undo deployment/ai-service -n hgzero
|
||||
|
||||
# 특정 리비전으로 롤백
|
||||
kubectl rollout undo deployment/ai-service -n hgzero --to-revision=2
|
||||
```
|
||||
|
||||
## 🐛 트러블슈팅
|
||||
|
||||
### Pod가 Running 상태가 아닌 경우
|
||||
```bash
|
||||
# Pod 상태 확인
|
||||
kubectl get pods -n hgzero -l app=ai-service
|
||||
|
||||
# Pod 이벤트 확인
|
||||
kubectl describe pod -n hgzero -l app=ai-service
|
||||
|
||||
# 로그 확인
|
||||
kubectl logs -n hgzero -l app=ai-service
|
||||
```
|
||||
|
||||
### ImagePullBackOff 에러
|
||||
```bash
|
||||
# ACR 접근 권한 확인
|
||||
kubectl get secret -n hgzero
|
||||
|
||||
# ACR Pull Secret 생성 (필요시)
|
||||
kubectl create secret docker-registry acr-secret \
|
||||
--docker-server=acrdigitalgarage02.azurecr.io \
|
||||
--docker-username=<ACR_USERNAME> \
|
||||
--docker-password=<ACR_PASSWORD> \
|
||||
-n hgzero
|
||||
```
|
||||
|
||||
### CrashLoopBackOff 에러
|
||||
```bash
|
||||
# 로그 확인
|
||||
kubectl logs -n hgzero -l app=ai-service --previous
|
||||
|
||||
# 환경 변수 확인
|
||||
kubectl exec -it $POD_NAME -n hgzero -- env | grep -E "CLAUDE|REDIS|EVENTHUB"
|
||||
```
|
||||
|
||||
### Redis 연결 실패
|
||||
```bash
|
||||
# Redis 접속 테스트 (Pod에서)
|
||||
kubectl exec -it $POD_NAME -n hgzero -- \
|
||||
python -c "import redis; r=redis.Redis(host='20.249.177.114', port=6379, password='Hi5Jessica!', db=4); print(r.ping())"
|
||||
```
|
||||
|
||||
### Event Hub 연결 실패
|
||||
```bash
|
||||
# Event Hub 연결 문자열 확인
|
||||
kubectl get secret azure-secret -n hgzero -o jsonpath='{.data.eventhub-ai-connection-string}' | base64 -d
|
||||
|
||||
# 로그에서 Event Hub 오류 확인
|
||||
kubectl logs $POD_NAME -n hgzero | grep -i "eventhub\|error"
|
||||
```
|
||||
|
||||
## 📊 모니터링
|
||||
|
||||
### 실시간 로그 모니터링
|
||||
```bash
|
||||
# 실시간 로그
|
||||
kubectl logs -f -n hgzero -l app=ai-service
|
||||
|
||||
# 특정 키워드 필터링
|
||||
kubectl logs -f -n hgzero -l app=ai-service | grep -E "SSE|Claude|AI"
|
||||
```
|
||||
|
||||
### 리소스 사용량
|
||||
```bash
|
||||
# CPU/메모리 사용량
|
||||
kubectl top pod -n hgzero -l app=ai-service
|
||||
|
||||
# 상세 리소스 정보
|
||||
kubectl describe pod -n hgzero -l app=ai-service | grep -A 5 "Resources"
|
||||
```
|
||||
|
||||
## 🗑️ 삭제
|
||||
|
||||
### AI 서비스 삭제
|
||||
```bash
|
||||
# Deployment와 Service 삭제
|
||||
kubectl delete -f deploy/k8s/backend/ai-service.yaml
|
||||
|
||||
# Secret 삭제
|
||||
kubectl delete secret ai-secret -n hgzero
|
||||
|
||||
# ConfigMap 유지 (다른 서비스에서 사용 중)
|
||||
# kubectl delete configmap redis-config -n hgzero
|
||||
```
|
||||
|
||||
## 📝 주의사항
|
||||
|
||||
1. **Secret 관리**
|
||||
- Claude API Key는 민감 정보이므로 Git에 커밋하지 마세요
|
||||
- 프로덕션 환경에서는 Azure Key Vault 사용 권장
|
||||
|
||||
2. **리소스 제한**
|
||||
- 초기 설정: CPU 250m-1000m, Memory 512Mi-1024Mi
|
||||
- 트래픽에 따라 조정 필요
|
||||
|
||||
3. **Event Hub**
|
||||
- AI Listen Policy 사용 (Listen 권한만)
|
||||
- EntityPath는 연결 문자열에서 제거
|
||||
|
||||
4. **Redis**
|
||||
- DB 4번 사용 (다른 서비스와 분리)
|
||||
- TTL 5분 설정 (슬라이딩 윈도우)
|
||||
|
||||
5. **Ingress 경로**
|
||||
- `/api/ai/suggestions`가 `/api/ai`보다 먼저 정의되어야 함
|
||||
- 더 구체적인 경로를 위에 배치
|
||||
27
ai-python/Dockerfile
Normal file
27
ai-python/Dockerfile
Normal file
@ -0,0 +1,27 @@
|
||||
# Python 3.11 slim 이미지 사용
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /app
|
||||
|
||||
# 시스템 패키지 업데이트 및 필요한 도구 설치
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python 의존성 파일 복사 및 설치
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 애플리케이션 코드 복사
|
||||
COPY . .
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 8087
|
||||
|
||||
# 헬스 체크
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8087/health')" || exit 1
|
||||
|
||||
# 애플리케이션 실행
|
||||
CMD ["python", "main.py"]
|
||||
167
ai-python/README.md
Normal file
167
ai-python/README.md
Normal file
@ -0,0 +1,167 @@
|
||||
# AI Service (Python)
|
||||
|
||||
실시간 AI 제안사항 서비스 - FastAPI 기반
|
||||
|
||||
## 📋 개요
|
||||
|
||||
STT 서비스에서 실시간으로 변환된 텍스트를 받아 Claude API로 분석하여 회의 제안사항을 생성하고, SSE(Server-Sent Events)로 프론트엔드에 스트리밍합니다.
|
||||
|
||||
## 🏗️ 아키텍처
|
||||
|
||||
```
|
||||
Frontend (회의록 작성 화면)
|
||||
↓ (SSE 연결)
|
||||
AI Service (Python)
|
||||
↓ (Redis 조회)
|
||||
Redis (실시간 텍스트 축적)
|
||||
↑ (Event Hub)
|
||||
STT Service (음성 → 텍스트)
|
||||
```
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
### 1. 환경 설정
|
||||
|
||||
```bash
|
||||
# .env 파일 생성
|
||||
cp .env.example .env
|
||||
|
||||
# .env에서 아래 값 설정
|
||||
CLAUDE_API_KEY=sk-ant-... # 실제 Claude API 키
|
||||
```
|
||||
|
||||
### 2. 의존성 설치
|
||||
|
||||
```bash
|
||||
# 가상환경 생성 (권장)
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Mac/Linux
|
||||
# venv\Scripts\activate # Windows
|
||||
|
||||
# 패키지 설치
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. 서비스 시작
|
||||
|
||||
```bash
|
||||
# 방법 1: 스크립트 실행
|
||||
./start.sh
|
||||
|
||||
# 방법 2: 직접 실행
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
### 4. 서비스 확인
|
||||
|
||||
```bash
|
||||
# 헬스 체크
|
||||
curl http://localhost:8086/health
|
||||
|
||||
# SSE 스트림 테스트
|
||||
curl -N http://localhost:8086/api/v1/ai/suggestions/meetings/test-meeting/stream
|
||||
```
|
||||
|
||||
## 📡 API 엔드포인트
|
||||
|
||||
### SSE 스트리밍
|
||||
|
||||
```
|
||||
GET /api/v1/ai/suggestions/meetings/{meeting_id}/stream
|
||||
```
|
||||
|
||||
**응답 형식 (SSE)**:
|
||||
```json
|
||||
event: ai-suggestion
|
||||
data: {
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"content": "신제품의 타겟 고객층을 20-30대로 설정...",
|
||||
"timestamp": "00:05:23",
|
||||
"confidence": 0.92
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 개발 환경
|
||||
|
||||
- **Python**: 3.9+
|
||||
- **Framework**: FastAPI
|
||||
- **AI**: Anthropic Claude API
|
||||
- **Cache**: Redis
|
||||
- **Event**: Azure Event Hub
|
||||
|
||||
## 📂 프로젝트 구조
|
||||
|
||||
```
|
||||
ai-python/
|
||||
├── main.py # FastAPI 진입점
|
||||
├── requirements.txt # 의존성
|
||||
├── .env.example # 환경 변수 예시
|
||||
├── start.sh # 시작 스크립트
|
||||
└── app/
|
||||
├── config.py # 환경 설정
|
||||
├── models/
|
||||
│ └── response.py # 응답 모델
|
||||
├── services/
|
||||
│ ├── claude_service.py # Claude API 서비스
|
||||
│ ├── redis_service.py # Redis 서비스
|
||||
│ └── eventhub_service.py # Event Hub 리스너
|
||||
└── api/
|
||||
└── v1/
|
||||
└── suggestions.py # SSE 엔드포인트
|
||||
```
|
||||
|
||||
## ⚙️ 환경 변수
|
||||
|
||||
| 변수 | 설명 | 기본값 |
|
||||
|------|------|--------|
|
||||
| `CLAUDE_API_KEY` | Claude API 키 | (필수) |
|
||||
| `CLAUDE_MODEL` | Claude 모델 | claude-3-5-sonnet-20241022 |
|
||||
| `REDIS_HOST` | Redis 호스트 | 20.249.177.114 |
|
||||
| `REDIS_PORT` | Redis 포트 | 6379 |
|
||||
| `EVENTHUB_CONNECTION_STRING` | Event Hub 연결 문자열 | (선택) |
|
||||
| `PORT` | 서비스 포트 | 8086 |
|
||||
|
||||
## 🔍 동작 원리
|
||||
|
||||
1. **STT → Event Hub**: STT 서비스가 음성을 텍스트로 변환하여 Event Hub에 발행
|
||||
2. **Event Hub → Redis**: AI 서비스가 Event Hub에서 텍스트를 받아 Redis에 축적 (슬라이딩 윈도우: 최근 5분)
|
||||
3. **Redis → Claude API**: 임계값(10개 세그먼트) 이상이면 Claude API로 분석
|
||||
4. **Claude API → Frontend**: 분석 결과를 SSE로 프론트엔드에 스트리밍
|
||||
|
||||
## 🧪 테스트
|
||||
|
||||
```bash
|
||||
# Event Hub 없이 SSE만 테스트 (Mock 데이터)
|
||||
curl -N http://localhost:8086/api/v1/ai/suggestions/meetings/test-meeting/stream
|
||||
|
||||
# 5초마다 샘플 제안사항이 발행됩니다
|
||||
```
|
||||
|
||||
## 📝 개발 가이드
|
||||
|
||||
### Claude API 키 발급
|
||||
1. https://console.anthropic.com/ 접속
|
||||
2. API Keys 메뉴에서 새 키 생성
|
||||
3. `.env` 파일에 설정
|
||||
|
||||
### Redis 연결 확인
|
||||
```bash
|
||||
redis-cli -h 20.249.177.114 -p 6379 -a Hi5Jessica! ping
|
||||
# 응답: PONG
|
||||
```
|
||||
|
||||
### Event Hub 설정 (선택)
|
||||
- Event Hub가 없어도 SSE 스트리밍은 동작합니다
|
||||
- STT 연동 시 필요
|
||||
|
||||
## 🚧 TODO
|
||||
|
||||
- [ ] Event Hub 연동 테스트
|
||||
- [ ] 프론트엔드 연동 테스트
|
||||
- [ ] 에러 핸들링 강화
|
||||
- [ ] 로깅 개선
|
||||
- [ ] 성능 모니터링
|
||||
BIN
ai-python/__pycache__/main.cpython-313.pyc
Normal file
BIN
ai-python/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
2
ai-python/app/__init__.py
Normal file
2
ai-python/app/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""AI Service - Python FastAPI"""
|
||||
__version__ = "1.0.0"
|
||||
BIN
ai-python/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ai-python/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ai-python/app/__pycache__/config.cpython-313.pyc
Normal file
BIN
ai-python/app/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
1
ai-python/app/api/__init__.py
Normal file
1
ai-python/app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""API 레이어"""
|
||||
BIN
ai-python/app/api/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ai-python/app/api/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
10
ai-python/app/api/v1/__init__.py
Normal file
10
ai-python/app/api/v1/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""API v1 Router"""
|
||||
from fastapi import APIRouter
|
||||
from .transcripts import router as transcripts_router
|
||||
from .suggestions import router as suggestions_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 라우터 등록
|
||||
router.include_router(transcripts_router, prefix="/transcripts", tags=["Transcripts"])
|
||||
router.include_router(suggestions_router, prefix="/ai/suggestions", tags=["AI Suggestions"])
|
||||
BIN
ai-python/app/api/v1/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ai-python/app/api/v1/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ai-python/app/api/v1/__pycache__/suggestions.cpython-313.pyc
Normal file
BIN
ai-python/app/api/v1/__pycache__/suggestions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ai-python/app/api/v1/__pycache__/transcripts.cpython-313.pyc
Normal file
BIN
ai-python/app/api/v1/__pycache__/transcripts.cpython-313.pyc
Normal file
Binary file not shown.
147
ai-python/app/api/v1/suggestions.py
Normal file
147
ai-python/app/api/v1/suggestions.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""AI 제안사항 SSE 엔드포인트"""
|
||||
from fastapi import APIRouter
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
from app.models import RealtimeSuggestionsResponse
|
||||
from app.services.claude_service import ClaudeService
|
||||
from app.services.redis_service import RedisService
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
# 서비스 인스턴스
|
||||
claude_service = ClaudeService()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/meetings/{meeting_id}/stream",
|
||||
summary="실시간 AI 제안사항 스트리밍",
|
||||
description="""
|
||||
회의 중 실시간으로 AI 제안사항을 Server-Sent Events(SSE)로 스트리밍합니다.
|
||||
|
||||
### 동작 방식
|
||||
1. Redis에서 누적된 회의 텍스트 조회 (5초마다)
|
||||
2. 임계값(10개 세그먼트) 이상이면 Claude API로 분석
|
||||
3. 분석 결과를 SSE 이벤트로 전송
|
||||
|
||||
### SSE 이벤트 형식
|
||||
```
|
||||
event: ai-suggestion
|
||||
id: {segment_count}
|
||||
data: {"suggestions": [...]}
|
||||
```
|
||||
|
||||
### 클라이언트 연결 예시 (JavaScript)
|
||||
```javascript
|
||||
const eventSource = new EventSource(
|
||||
'http://localhost:8087/api/v1/ai/suggestions/meetings/{meeting_id}/stream'
|
||||
);
|
||||
|
||||
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('새로운 제안사항:', data.suggestions);
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE 오류:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
- 연결은 클라이언트가 종료할 때까지 유지됩니다
|
||||
- 네트워크 타임아웃 설정이 충분히 길어야 합니다
|
||||
- 브라우저는 자동으로 재연결을 시도합니다
|
||||
""",
|
||||
responses={
|
||||
200: {
|
||||
"description": "SSE 스트림 연결 성공",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"example": """event: ai-suggestion
|
||||
id: 15
|
||||
data: {"suggestions":[{"id":"550e8400-e29b-41d4-a716-446655440000","content":"신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.","timestamp":"14:23:45","confidence":0.92}]}
|
||||
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def stream_ai_suggestions(meeting_id: str):
|
||||
"""
|
||||
실시간 AI 제안사항 SSE 스트리밍
|
||||
|
||||
Args:
|
||||
meeting_id: 회의 ID
|
||||
|
||||
Returns:
|
||||
Server-Sent Events 스트림
|
||||
"""
|
||||
logger.info(f"SSE 스트림 시작 - meetingId: {meeting_id}")
|
||||
|
||||
async def event_generator() -> AsyncGenerator:
|
||||
"""SSE 이벤트 생성기"""
|
||||
redis_service = RedisService()
|
||||
|
||||
try:
|
||||
# Redis 연결
|
||||
await redis_service.connect()
|
||||
|
||||
previous_count = 0
|
||||
|
||||
while True:
|
||||
# 현재 세그먼트 개수 확인
|
||||
current_count = await redis_service.get_segment_count(meeting_id)
|
||||
|
||||
# 임계값 이상이고, 이전보다 증가했으면 분석
|
||||
if (current_count >= settings.min_segments_for_analysis
|
||||
and current_count > previous_count):
|
||||
|
||||
# 누적된 텍스트 조회
|
||||
accumulated_text = await redis_service.get_accumulated_text(meeting_id)
|
||||
|
||||
if accumulated_text:
|
||||
# Claude API로 분석
|
||||
suggestions = await claude_service.analyze_suggestions(accumulated_text)
|
||||
|
||||
if suggestions.suggestions:
|
||||
# SSE 이벤트 전송
|
||||
yield {
|
||||
"event": "ai-suggestion",
|
||||
"id": str(current_count),
|
||||
"data": suggestions.json()
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"AI 제안사항 발행 - meetingId: {meeting_id}, "
|
||||
f"개수: {len(suggestions.suggestions)}"
|
||||
)
|
||||
|
||||
previous_count = current_count
|
||||
|
||||
# 5초마다 체크
|
||||
await asyncio.sleep(5)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"SSE 스트림 종료 - meetingId: {meeting_id}")
|
||||
# 회의 종료 시 데이터 정리는 선택사항 (나중에 조회 필요할 수도)
|
||||
# await redis_service.cleanup_meeting_data(meeting_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SSE 스트림 오류 - meetingId: {meeting_id}", exc_info=e)
|
||||
|
||||
finally:
|
||||
await redis_service.disconnect()
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
|
||||
|
||||
@router.get("/test")
|
||||
async def test_endpoint():
|
||||
"""테스트 엔드포인트"""
|
||||
return {"message": "AI Suggestions API is working", "port": settings.port}
|
||||
53
ai-python/app/api/v1/transcripts.py
Normal file
53
ai-python/app/api/v1/transcripts.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Transcripts API Router"""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
import logging
|
||||
from app.models.transcript import ConsolidateRequest, ConsolidateResponse
|
||||
from app.services.transcript_service import transcript_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/consolidate", response_model=ConsolidateResponse, status_code=status.HTTP_200_OK)
|
||||
async def consolidate_minutes(request: ConsolidateRequest):
|
||||
"""
|
||||
회의록 통합 요약
|
||||
|
||||
참석자별로 작성한 회의록을 AI가 통합하여 요약합니다.
|
||||
|
||||
- **meeting_id**: 회의 ID
|
||||
- **participant_minutes**: 참석자별 회의록 목록
|
||||
- **agendas**: 안건 목록 (선택)
|
||||
- **duration_minutes**: 회의 시간(분) (선택)
|
||||
|
||||
Returns:
|
||||
- 통합 요약, 키워드, 안건별 분석, Todo 자동 추출
|
||||
"""
|
||||
try:
|
||||
logger.info(f"POST /transcripts/consolidate - Meeting ID: {request.meeting_id}")
|
||||
|
||||
# 입력 검증
|
||||
if not request.participant_minutes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="참석자 회의록이 비어있습니다"
|
||||
)
|
||||
|
||||
# 회의록 통합 처리
|
||||
response = await transcript_service.consolidate_minutes(request)
|
||||
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"입력 값 오류: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"회의록 통합 실패: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"회의록 통합 처리 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
57
ai-python/app/config.py
Normal file
57
ai-python/app/config.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""환경 설정"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""환경 설정 클래스"""
|
||||
|
||||
# 서버 설정
|
||||
app_name: str = "AI Service (Python)"
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8087 # feature/stt-ai 브랜치 AI Service(8086)와 충돌 방지
|
||||
|
||||
# Claude API
|
||||
claude_api_key: str = "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"
|
||||
claude_model: str = "claude-3-5-sonnet-20240620"
|
||||
claude_max_tokens: int = 250000
|
||||
claude_temperature: float = 0.7
|
||||
|
||||
# Redis
|
||||
redis_host: str = "20.249.177.114"
|
||||
redis_port: int = 6379
|
||||
redis_password: str = ""
|
||||
redis_db: int = 4
|
||||
|
||||
# Azure Event Hub
|
||||
eventhub_connection_string: str = "Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo="
|
||||
eventhub_name: str = "hgzero-eventhub-name"
|
||||
eventhub_consumer_group: str = "ai-transcript-group"
|
||||
|
||||
# CORS
|
||||
cors_origins: List[str] = [
|
||||
"http://localhost:8888",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:8888",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1:3000"
|
||||
]
|
||||
|
||||
# 로깅
|
||||
log_level: str = "INFO"
|
||||
|
||||
# 분석 임계값
|
||||
min_segments_for_analysis: int = 10
|
||||
text_retention_seconds: int = 300 # 5분
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""싱글톤 설정 인스턴스"""
|
||||
return Settings()
|
||||
22
ai-python/app/models/__init__.py
Normal file
22
ai-python/app/models/__init__.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Data Models"""
|
||||
from .transcript import (
|
||||
ConsolidateRequest,
|
||||
ConsolidateResponse,
|
||||
AgendaSummary,
|
||||
ParticipantMinutes,
|
||||
ExtractedTodo
|
||||
)
|
||||
from .response import (
|
||||
SimpleSuggestion,
|
||||
RealtimeSuggestionsResponse
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConsolidateRequest",
|
||||
"ConsolidateResponse",
|
||||
"AgendaSummary",
|
||||
"ParticipantMinutes",
|
||||
"ExtractedTodo",
|
||||
"SimpleSuggestion",
|
||||
"RealtimeSuggestionsResponse",
|
||||
]
|
||||
BIN
ai-python/app/models/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ai-python/app/models/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ai-python/app/models/__pycache__/response.cpython-313.pyc
Normal file
BIN
ai-python/app/models/__pycache__/response.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ai-python/app/models/__pycache__/transcript.cpython-313.pyc
Normal file
BIN
ai-python/app/models/__pycache__/transcript.cpython-313.pyc
Normal file
Binary file not shown.
69
ai-python/app/models/keyword.py
Normal file
69
ai-python/app/models/keyword.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Keyword Models"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class KeywordExtractRequest(BaseModel):
|
||||
"""주요 키워드 추출 요청"""
|
||||
meeting_id: str = Field(..., description="회의 ID")
|
||||
transcript_text: str = Field(..., description="전체 회의록 텍스트")
|
||||
max_keywords: int = Field(default=10, ge=1, le=20, description="최대 키워드 개수")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"meeting_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"transcript_text": "안건 1: 신제품 기획...\n타겟 고객을 20-30대로 설정...",
|
||||
"max_keywords": 10
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ExtractedKeyword(BaseModel):
|
||||
"""추출된 키워드"""
|
||||
keyword: str = Field(..., description="키워드")
|
||||
relevance_score: float = Field(..., ge=0.0, le=1.0, description="관련성 점수")
|
||||
frequency: int = Field(..., description="출현 빈도")
|
||||
category: str = Field(..., description="카테고리 (예: 기술, 전략, 일정 등)")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"keyword": "신제품기획",
|
||||
"relevance_score": 0.95,
|
||||
"frequency": 15,
|
||||
"category": "전략"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class KeywordExtractResponse(BaseModel):
|
||||
"""주요 키워드 추출 응답"""
|
||||
meeting_id: str = Field(..., description="회의 ID")
|
||||
keywords: List[ExtractedKeyword] = Field(..., description="추출된 키워드 목록")
|
||||
total_count: int = Field(..., description="전체 키워드 개수")
|
||||
extracted_at: datetime = Field(default_factory=datetime.utcnow, description="추출 시각")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"meeting_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "신제품기획",
|
||||
"relevance_score": 0.95,
|
||||
"frequency": 15,
|
||||
"category": "전략"
|
||||
},
|
||||
{
|
||||
"keyword": "예산편성",
|
||||
"relevance_score": 0.88,
|
||||
"frequency": 12,
|
||||
"category": "재무"
|
||||
}
|
||||
],
|
||||
"total_count": 10,
|
||||
"extracted_at": "2025-01-23T10:30:00Z"
|
||||
}
|
||||
}
|
||||
45
ai-python/app/models/response.py
Normal file
45
ai-python/app/models/response.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""응답 모델"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
|
||||
class SimpleSuggestion(BaseModel):
|
||||
"""간소화된 AI 제안사항"""
|
||||
|
||||
id: str = Field(..., description="제안 ID")
|
||||
content: str = Field(..., description="제안 내용 (1-2문장)")
|
||||
timestamp: str = Field(..., description="타임스탬프 (HH:MM:SS)")
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="신뢰도 (0-1)")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"id": "sugg-001",
|
||||
"content": "신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.",
|
||||
"timestamp": "00:05:23",
|
||||
"confidence": 0.92
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RealtimeSuggestionsResponse(BaseModel):
|
||||
"""실시간 AI 제안사항 응답"""
|
||||
|
||||
suggestions: List[SimpleSuggestion] = Field(
|
||||
default_factory=list,
|
||||
description="AI 제안사항 목록"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "sugg-001",
|
||||
"content": "신제품의 타겟 고객층을 20-30대로 설정하고...",
|
||||
"timestamp": "00:05:23",
|
||||
"confidence": 0.92
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
80
ai-python/app/models/todo.py
Normal file
80
ai-python/app/models/todo.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""Todo Models"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PriorityLevel(str, Enum):
|
||||
"""우선순위"""
|
||||
HIGH = "HIGH"
|
||||
MEDIUM = "MEDIUM"
|
||||
LOW = "LOW"
|
||||
|
||||
|
||||
class TodoExtractRequest(BaseModel):
|
||||
"""Todo 자동 추출 요청"""
|
||||
meeting_id: str = Field(..., description="회의 ID")
|
||||
transcript_text: str = Field(..., description="전체 회의록 텍스트")
|
||||
participants: List[str] = Field(..., description="참석자 목록")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"meeting_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"transcript_text": "안건 1: 신제품 기획...\n결정사항: API 설계서는 박민수님이 1월 30일까지 작성...",
|
||||
"participants": ["김민준", "박서연", "이준호", "박민수"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ExtractedTodo(BaseModel):
|
||||
"""추출된 Todo"""
|
||||
title: str = Field(..., description="Todo 제목")
|
||||
description: Optional[str] = Field(None, description="상세 설명")
|
||||
assignee: str = Field(..., description="담당자 이름")
|
||||
due_date: Optional[date] = Field(None, description="마감일")
|
||||
priority: PriorityLevel = Field(default=PriorityLevel.MEDIUM, description="우선순위")
|
||||
section_reference: str = Field(..., description="섹션 참조 (예: '결정사항 #1')")
|
||||
confidence_score: float = Field(..., ge=0.0, le=1.0, description="신뢰도 점수")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"title": "API 설계서 작성",
|
||||
"description": "신규 프로젝트 API 설계서 작성 완료",
|
||||
"assignee": "박민수",
|
||||
"due_date": "2025-01-30",
|
||||
"priority": "HIGH",
|
||||
"section_reference": "결정사항 #1",
|
||||
"confidence_score": 0.92
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TodoExtractResponse(BaseModel):
|
||||
"""Todo 자동 추출 응답"""
|
||||
meeting_id: str = Field(..., description="회의 ID")
|
||||
todos: List[ExtractedTodo] = Field(..., description="추출된 Todo 목록")
|
||||
total_count: int = Field(..., description="전체 Todo 개수")
|
||||
extracted_at: datetime = Field(default_factory=datetime.utcnow, description="추출 시각")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"meeting_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"todos": [
|
||||
{
|
||||
"title": "API 설계서 작성",
|
||||
"description": "신규 프로젝트 API 설계서 작성 완료",
|
||||
"assignee": "박민수",
|
||||
"due_date": "2025-01-30",
|
||||
"priority": "HIGH",
|
||||
"section_reference": "결정사항 #1",
|
||||
"confidence_score": 0.92
|
||||
}
|
||||
],
|
||||
"total_count": 5,
|
||||
"extracted_at": "2025-01-23T10:30:00Z"
|
||||
}
|
||||
}
|
||||
44
ai-python/app/models/transcript.py
Normal file
44
ai-python/app/models/transcript.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Transcript Models"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ParticipantMinutes(BaseModel):
|
||||
"""참석자별 회의록"""
|
||||
user_id: str = Field(..., description="사용자 ID")
|
||||
user_name: str = Field(..., description="사용자 이름")
|
||||
content: str = Field(..., description="회의록 전체 내용 (MEMO 섹션)")
|
||||
|
||||
|
||||
class ConsolidateRequest(BaseModel):
|
||||
"""회의록 통합 요약 요청"""
|
||||
meeting_id: str = Field(..., description="회의 ID")
|
||||
participant_minutes: List[ParticipantMinutes] = Field(..., description="참석자별 회의록 목록")
|
||||
agendas: Optional[List[str]] = Field(None, description="안건 목록")
|
||||
duration_minutes: Optional[int] = Field(None, description="회의 시간(분)")
|
||||
|
||||
|
||||
class ExtractedTodo(BaseModel):
|
||||
"""추출된 Todo (제목만)"""
|
||||
title: str = Field(..., description="Todo 제목")
|
||||
|
||||
|
||||
class AgendaSummary(BaseModel):
|
||||
"""안건별 요약"""
|
||||
agenda_number: int = Field(..., description="안건 번호")
|
||||
agenda_title: str = Field(..., description="안건 제목")
|
||||
summary_short: str = Field(..., description="AI 생성 짧은 요약 (1줄, 20자 이내)")
|
||||
summary: str = Field(..., description="안건별 회의록 요약 (논의사항+결정사항, 사용자 수정 가능)")
|
||||
pending: List[str] = Field(default_factory=list, description="보류 사항")
|
||||
todos: List[ExtractedTodo] = Field(default_factory=list, description="Todo 목록 (제목만)")
|
||||
|
||||
|
||||
class ConsolidateResponse(BaseModel):
|
||||
"""회의록 통합 요약 응답"""
|
||||
meeting_id: str = Field(..., description="회의 ID")
|
||||
keywords: List[str] = Field(..., description="주요 키워드")
|
||||
statistics: Dict[str, int] = Field(..., description="통계 정보")
|
||||
decisions: str = Field(..., description="회의 전체 결정사항 (TEXT 형식)")
|
||||
agenda_summaries: List[AgendaSummary] = Field(..., description="안건별 요약")
|
||||
generated_at: datetime = Field(default_factory=datetime.utcnow, description="생성 시각")
|
||||
Binary file not shown.
111
ai-python/app/prompts/consolidate_prompt.py
Normal file
111
ai-python/app/prompts/consolidate_prompt.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""회의록 통합 요약 프롬프트"""
|
||||
|
||||
|
||||
def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> str:
|
||||
"""
|
||||
참석자별 회의록을 통합하여 요약하는 프롬프트 생성
|
||||
"""
|
||||
|
||||
# 참석자 회의록 결합
|
||||
participants_content = "\n\n".join([
|
||||
f"## {p['user_name']}님의 회의록:\n{p['content']}"
|
||||
for p in participant_minutes
|
||||
])
|
||||
|
||||
# 안건 정보 (있는 경우)
|
||||
agendas_info = ""
|
||||
if agendas:
|
||||
agendas_info = "\n\n**사전 정의된 안건**:\n" + "\n".join([
|
||||
f"{i+1}. {agenda}" for i, agenda in enumerate(agendas)
|
||||
])
|
||||
|
||||
prompt = f"""당신은 회의록 작성 전문가입니다. 여러 참석자가 작성한 회의록을 통합하여 정확하고 체계적인 회의록을 생성해주세요.
|
||||
|
||||
# 입력 데이터
|
||||
|
||||
{participants_content}{agendas_info}
|
||||
|
||||
---
|
||||
|
||||
# 작업 지침
|
||||
|
||||
1. **주요 키워드 (keywords)**:
|
||||
- 회의에서 자주 언급된 핵심 키워드 5-10개 추출
|
||||
- 단어 또는 짧은 구문 (예: "신제품기획", "예산편성")
|
||||
|
||||
2. **통계 정보 (statistics)**:
|
||||
- agendas_count: 안건 개수 (내용 기반 추정)
|
||||
- todos_count: 추출된 Todo 총 개수
|
||||
|
||||
3. **회의 전체 결정사항 (decisions)**:
|
||||
- 회의 전체에서 최종 결정된 사항들을 TEXT 형식으로 정리
|
||||
- 안건별 결정사항을 모두 포함하여 회의록 수정 페이지에서 사용자가 확인 및 수정할 수 있도록 작성
|
||||
- 형식: "**안건1 결정사항:**\n- 결정1\n- 결정2\n\n**안건2 결정사항:**\n- 결정3"
|
||||
|
||||
4. **안건별 요약 (agenda_summaries)**:
|
||||
회의 내용을 분석하여 안건별로 구조화:
|
||||
|
||||
각 안건마다:
|
||||
- **agenda_number**: 안건 번호 (1, 2, 3...)
|
||||
- **agenda_title**: 안건 제목 (간결하게)
|
||||
- **summary_short**: AI가 생성한 1줄 요약 (20자 이내, 사용자 수정 불가)
|
||||
- **summary**: 안건별 회의록 요약 (논의사항과 결정사항을 포함한 전체 요약)
|
||||
* 회의록 수정 페이지에서 사용자가 수정할 수 있는 입력 필드
|
||||
* 형식: "**논의 사항:**\n- 논의내용1\n- 논의내용2\n\n**결정 사항:**\n- 결정1\n- 결정2"
|
||||
* 사용자가 자유롭게 편집할 수 있도록 구조화된 텍스트로 작성
|
||||
- **pending**: 보류 사항 배열 (추가 논의 필요 사항)
|
||||
- **todos**: Todo 배열 (제목만, 담당자/마감일/우선순위 없음)
|
||||
- title: Todo 제목만 추출 (예: "시장 조사 보고서 작성")
|
||||
|
||||
---
|
||||
|
||||
# 출력 형식
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요.
|
||||
|
||||
```json
|
||||
{{
|
||||
"keywords": ["키워드1", "키워드2", "키워드3"],
|
||||
"statistics": {{
|
||||
"agendas_count": 숫자,
|
||||
"todos_count": 숫자
|
||||
}},
|
||||
"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",
|
||||
"pending": ["보류사항"],
|
||||
"todos": [
|
||||
{{
|
||||
"title": "Todo 제목"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 중요 규칙
|
||||
|
||||
1. **정확성**: 참석자 회의록에 명시된 내용만 사용
|
||||
2. **객관성**: 추측이나 가정 없이 사실만 기록
|
||||
3. **완전성**: 모든 필드를 빠짐없이 작성
|
||||
4. **구조화**: 안건별로 명확히 분리
|
||||
5. **결정사항 추출**:
|
||||
- 회의 전체 결정사항(decisions)은 모든 안건의 결정사항을 포함
|
||||
- 안건별 summary에도 결정사항을 포함하여 사용자가 수정 가능하도록 작성
|
||||
6. **summary 작성**:
|
||||
- summary_short: AI가 자동 생성한 1줄 요약 (사용자 수정 불가)
|
||||
- summary: 논의사항과 결정사항을 포함한 전체 요약 (사용자 수정 가능)
|
||||
7. **Todo 추출**: 제목만 추출 (담당자나 마감일 없어도 됨)
|
||||
8. **JSON만 출력**: 추가 설명 없이 JSON만 반환
|
||||
|
||||
이제 위 회의록들을 분석하여 통합 요약을 JSON 형식으로 생성해주세요.
|
||||
"""
|
||||
|
||||
return prompt
|
||||
72
ai-python/app/prompts/suggestions_prompt.py
Normal file
72
ai-python/app/prompts/suggestions_prompt.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""AI 제안사항 추출 프롬프트"""
|
||||
|
||||
|
||||
def get_suggestions_prompt(transcript_text: str) -> tuple[str, str]:
|
||||
"""
|
||||
회의 텍스트에서 AI 제안사항을 추출하는 프롬프트 생성
|
||||
|
||||
Returns:
|
||||
(system_prompt, user_prompt) 튜플
|
||||
"""
|
||||
|
||||
system_prompt = """당신은 회의 내용 분석 전문가입니다.
|
||||
회의 텍스트를 분석하여 실행 가능한 제안사항을 추출해주세요."""
|
||||
|
||||
user_prompt = f"""다음 회의 내용을 분석하여 **구체적이고 실행 가능한 제안사항**을 추출해주세요.
|
||||
|
||||
# 회의 내용
|
||||
{transcript_text}
|
||||
|
||||
---
|
||||
|
||||
# 제안사항 추출 기준
|
||||
1. **실행 가능성**: 바로 실행할 수 있는 구체적인 액션 아이템
|
||||
2. **명확성**: 누가, 무엇을, 언제까지 해야 하는지 명확한 내용
|
||||
3. **중요도**: 회의 목표 달성에 중요한 사항
|
||||
4. **완결성**: 하나의 제안사항이 독립적으로 완결된 내용
|
||||
|
||||
# 제안사항 유형 예시
|
||||
- **후속 작업**: "시장 조사 보고서를 다음 주까지 작성하여 공유"
|
||||
- **의사결정 필요**: "예산안 3안 중 최종안을 이번 주 금요일까지 결정"
|
||||
- **리스크 대응**: "법률 검토를 위해 법무팀과 사전 협의 필요"
|
||||
- **일정 조율**: "다음 회의를 3월 15일로 확정하고 참석자에게 공지"
|
||||
- **자료 준비**: "경쟁사 분석 자료를 회의 전까지 준비"
|
||||
- **검토 요청**: "초안에 대한 팀원들의 피드백 수집 필요"
|
||||
- **승인 필요**: "최종 기획안을 경영진에게 보고하여 승인 받기"
|
||||
|
||||
# 제안사항 작성 가이드
|
||||
- **구체적으로**: "검토 필요" (X) → "법무팀과 계약서 조항 검토 미팅 잡기" (O)
|
||||
- **명확하게**: "나중에 하기" (X) → "다음 주 화요일까지 완료" (O)
|
||||
- **실행 가능하게**: "잘 되길 바람" (X) → "주간 진행상황 공유 미팅 설정" (O)
|
||||
|
||||
---
|
||||
|
||||
# 출력 형식
|
||||
반드시 아래 JSON 형식으로만 응답하세요:
|
||||
|
||||
```json
|
||||
{{
|
||||
"suggestions": [
|
||||
{{
|
||||
"content": "제안사항 내용 (구체적이고 실행 가능하게, 50자 이상 작성)",
|
||||
"confidence": 0.85 (이 제안사항의 중요도/확실성, 0.7-1.0 사이)
|
||||
}},
|
||||
{{
|
||||
"content": "또 다른 제안사항",
|
||||
"confidence": 0.92
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
# 중요 규칙
|
||||
1. **회의 내용에 명시된 사항만** 추출 (추측하지 않기)
|
||||
2. **최소 3개, 최대 7개**의 제안사항 추출
|
||||
3. 중요도가 높은 순서로 정렬
|
||||
4. confidence는 **0.7 이상**만 포함
|
||||
5. 각 제안사항은 **50자 이상** 구체적으로 작성
|
||||
6. JSON만 출력 (```json이나 다른 텍스트 포함 금지)
|
||||
|
||||
이제 위 회의 내용에서 제안사항을 JSON 형식으로 추출해주세요."""
|
||||
|
||||
return system_prompt, user_prompt
|
||||
1
ai-python/app/services/__init__.py
Normal file
1
ai-python/app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""서비스 레이어"""
|
||||
BIN
ai-python/app/services/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ai-python/app/services/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
ai-python/app/services/__pycache__/redis_service.cpython-313.pyc
Normal file
BIN
ai-python/app/services/__pycache__/redis_service.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
139
ai-python/app/services/claude_service.py
Normal file
139
ai-python/app/services/claude_service.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""Claude API Service"""
|
||||
import anthropic
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class ClaudeService:
|
||||
"""Claude API 호출 서비스"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = anthropic.Anthropic(api_key=settings.claude_api_key)
|
||||
self.model = settings.claude_model
|
||||
self.max_tokens = settings.claude_max_tokens
|
||||
self.temperature = settings.claude_temperature
|
||||
|
||||
async def generate_completion(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Claude API 호출하여 응답 생성
|
||||
|
||||
Args:
|
||||
prompt: 사용자 프롬프트
|
||||
system_prompt: 시스템 프롬프트 (선택)
|
||||
|
||||
Returns:
|
||||
Claude API 응답 (JSON 파싱)
|
||||
"""
|
||||
try:
|
||||
# 메시지 구성
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
|
||||
# API 호출
|
||||
logger.info(f"Claude API 호출 시작 - Model: {self.model}")
|
||||
|
||||
if system_prompt:
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
system=system_prompt,
|
||||
messages=messages
|
||||
)
|
||||
else:
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
messages=messages
|
||||
)
|
||||
|
||||
# 응답 텍스트 추출
|
||||
response_text = response.content[0].text
|
||||
logger.info(f"Claude API 응답 수신 완료 - Tokens: {response.usage.input_tokens + response.usage.output_tokens}")
|
||||
|
||||
# JSON 파싱
|
||||
# ```json ... ``` 블록 제거
|
||||
if "```json" in response_text:
|
||||
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in response_text:
|
||||
response_text = response_text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
result = json.loads(response_text)
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON 파싱 실패: {e}")
|
||||
logger.error(f"응답 텍스트: {response_text[:500]}...")
|
||||
raise ValueError(f"Claude API 응답을 JSON으로 파싱할 수 없습니다: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Claude API 호출 실패: {e}")
|
||||
raise
|
||||
|
||||
async def analyze_suggestions(self, transcript_text: str):
|
||||
"""
|
||||
회의 텍스트에서 AI 제안사항 추출
|
||||
|
||||
Args:
|
||||
transcript_text: 회의 텍스트
|
||||
|
||||
Returns:
|
||||
RealtimeSuggestionsResponse 객체
|
||||
"""
|
||||
from app.models import RealtimeSuggestionsResponse, SimpleSuggestion
|
||||
from app.prompts.suggestions_prompt import get_suggestions_prompt
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
try:
|
||||
# 프롬프트 생성
|
||||
system_prompt, user_prompt = get_suggestions_prompt(transcript_text)
|
||||
|
||||
# Claude API 호출
|
||||
result = await self.generate_completion(
|
||||
prompt=user_prompt,
|
||||
system_prompt=system_prompt
|
||||
)
|
||||
|
||||
# 응답 파싱
|
||||
suggestions_data = result.get("suggestions", [])
|
||||
|
||||
# SimpleSuggestion 객체로 변환
|
||||
suggestions = [
|
||||
SimpleSuggestion(
|
||||
id=str(uuid.uuid4()),
|
||||
content=s["content"],
|
||||
timestamp=datetime.now().strftime("%H:%M:%S"),
|
||||
confidence=s.get("confidence", 0.85)
|
||||
)
|
||||
for s in suggestions_data
|
||||
if s.get("confidence", 0) >= 0.7 # 신뢰도 0.7 이상만
|
||||
]
|
||||
|
||||
logger.info(f"AI 제안사항 {len(suggestions)}개 추출 완료")
|
||||
|
||||
return RealtimeSuggestionsResponse(suggestions=suggestions)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"제안사항 분석 실패: {e}", exc_info=True)
|
||||
# 빈 응답 반환
|
||||
return RealtimeSuggestionsResponse(suggestions=[])
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
claude_service = ClaudeService()
|
||||
113
ai-python/app/services/eventhub_service.py
Normal file
113
ai-python/app/services/eventhub_service.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""Azure Event Hub 서비스 - STT 텍스트 수신"""
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
from azure.eventhub.aio import EventHubConsumerClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.redis_service import RedisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class EventHubService:
|
||||
"""Event Hub 리스너 - STT 텍스트 실시간 수신"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self.redis_service = RedisService()
|
||||
|
||||
async def start(self):
|
||||
"""Event Hub 리스닝 시작"""
|
||||
if not settings.eventhub_connection_string:
|
||||
logger.warning("Event Hub 연결 문자열이 설정되지 않음 - Event Hub 리스너 비활성화")
|
||||
return
|
||||
|
||||
logger.info("Event Hub 리스너 시작")
|
||||
|
||||
try:
|
||||
# Redis 연결
|
||||
await self.redis_service.connect()
|
||||
|
||||
# Event Hub 클라이언트 생성
|
||||
self.client = EventHubConsumerClient.from_connection_string(
|
||||
conn_str=settings.eventhub_connection_string,
|
||||
consumer_group=settings.eventhub_consumer_group,
|
||||
eventhub_name=settings.eventhub_name,
|
||||
)
|
||||
|
||||
# 이벤트 수신 시작
|
||||
async with self.client:
|
||||
await self.client.receive(
|
||||
on_event=self.on_event,
|
||||
on_error=self.on_error,
|
||||
starting_position="-1", # 최신 이벤트부터
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event Hub 리스너 오류: {e}")
|
||||
finally:
|
||||
await self.redis_service.disconnect()
|
||||
|
||||
async def on_event(self, partition_context, event):
|
||||
"""
|
||||
이벤트 수신 핸들러
|
||||
|
||||
이벤트 형식 (STT Service에서 발행):
|
||||
{
|
||||
"eventType": "TranscriptSegmentReady",
|
||||
"meetingId": "meeting-123",
|
||||
"text": "변환된 텍스트",
|
||||
"timestamp": 1234567890000
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 이벤트 데이터 파싱
|
||||
event_data = json.loads(event.body_as_str())
|
||||
|
||||
event_type = event_data.get("eventType")
|
||||
meeting_id = event_data.get("meetingId")
|
||||
text = event_data.get("text")
|
||||
timestamp = event_data.get("timestamp")
|
||||
|
||||
if event_type == "TranscriptSegmentReady" and meeting_id and text:
|
||||
logger.info(
|
||||
f"STT 텍스트 수신 - meetingId: {meeting_id}, "
|
||||
f"텍스트 길이: {len(text)}"
|
||||
)
|
||||
|
||||
# Redis에 텍스트 축적 (슬라이딩 윈도우)
|
||||
await self.redis_service.add_transcript_segment(
|
||||
meeting_id=meeting_id,
|
||||
text=text,
|
||||
timestamp=timestamp
|
||||
)
|
||||
|
||||
logger.debug(f"Redis 저장 완료 - meetingId: {meeting_id}")
|
||||
|
||||
# MVP 개발: checkpoint 업데이트 제거 (InMemory 모드)
|
||||
# await partition_context.update_checkpoint(event)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"이벤트 처리 오류: {e}", exc_info=True)
|
||||
|
||||
async def on_error(self, partition_context, error):
|
||||
"""에러 핸들러"""
|
||||
logger.error(
|
||||
f"Event Hub 에러 - Partition: {partition_context.partition_id}, "
|
||||
f"Error: {error}"
|
||||
)
|
||||
|
||||
async def stop(self):
|
||||
"""Event Hub 리스너 종료"""
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
logger.info("Event Hub 리스너 종료")
|
||||
|
||||
|
||||
# 백그라운드 태스크로 실행할 함수
|
||||
async def start_eventhub_listener():
|
||||
"""Event Hub 리스너 백그라운드 실행"""
|
||||
service = EventHubService()
|
||||
await service.start()
|
||||
117
ai-python/app/services/redis_service.py
Normal file
117
ai-python/app/services/redis_service.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""Redis 서비스 - 실시간 텍스트 축적"""
|
||||
import redis.asyncio as redis
|
||||
import logging
|
||||
from typing import List
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class RedisService:
|
||||
"""Redis 서비스 (슬라이딩 윈도우 방식)"""
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client = None
|
||||
|
||||
async def connect(self):
|
||||
"""Redis 연결"""
|
||||
try:
|
||||
self.redis_client = await redis.Redis(
|
||||
host=settings.redis_host,
|
||||
port=settings.redis_port,
|
||||
password=settings.redis_password,
|
||||
db=settings.redis_db,
|
||||
decode_responses=True
|
||||
)
|
||||
await self.redis_client.ping()
|
||||
logger.info("Redis 연결 성공")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 연결 실패: {e}")
|
||||
raise
|
||||
|
||||
async def disconnect(self):
|
||||
"""Redis 연결 종료"""
|
||||
if self.redis_client:
|
||||
await self.redis_client.close()
|
||||
logger.info("Redis 연결 종료")
|
||||
|
||||
async def add_transcript_segment(
|
||||
self,
|
||||
meeting_id: str,
|
||||
text: str,
|
||||
timestamp: int
|
||||
):
|
||||
"""
|
||||
실시간 텍스트 세그먼트 추가 (슬라이딩 윈도우)
|
||||
|
||||
Args:
|
||||
meeting_id: 회의 ID
|
||||
text: 텍스트 세그먼트
|
||||
timestamp: 타임스탬프 (밀리초)
|
||||
"""
|
||||
key = f"meeting:{meeting_id}:transcript"
|
||||
value = f"{timestamp}:{text}"
|
||||
|
||||
# Sorted Set에 추가 (타임스탬프를 스코어로)
|
||||
await self.redis_client.zadd(key, {value: timestamp})
|
||||
|
||||
# 설정된 시간 이전 데이터 제거 (기본 5분)
|
||||
retention_ms = settings.text_retention_seconds * 1000
|
||||
cutoff_time = timestamp - retention_ms
|
||||
await self.redis_client.zremrangebyscore(key, 0, cutoff_time)
|
||||
|
||||
logger.debug(f"텍스트 세그먼트 추가 - meetingId: {meeting_id}")
|
||||
|
||||
async def get_accumulated_text(self, meeting_id: str) -> str:
|
||||
"""
|
||||
누적된 텍스트 조회 (최근 5분)
|
||||
|
||||
Args:
|
||||
meeting_id: 회의 ID
|
||||
|
||||
Returns:
|
||||
누적된 텍스트 (시간순)
|
||||
"""
|
||||
key = f"meeting:{meeting_id}:transcript"
|
||||
|
||||
# 최신순으로 모든 세그먼트 조회
|
||||
segments = await self.redis_client.zrevrange(key, 0, -1)
|
||||
|
||||
if not segments:
|
||||
return ""
|
||||
|
||||
# 타임스탬프 제거하고 텍스트만 추출
|
||||
texts = []
|
||||
for seg in segments:
|
||||
parts = seg.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
texts.append(parts[1])
|
||||
|
||||
# 시간순으로 정렬 (역순으로 조회했으므로 다시 뒤집기)
|
||||
return "\n".join(reversed(texts))
|
||||
|
||||
async def get_segment_count(self, meeting_id: str) -> int:
|
||||
"""
|
||||
누적된 세그먼트 개수
|
||||
|
||||
Args:
|
||||
meeting_id: 회의 ID
|
||||
|
||||
Returns:
|
||||
세그먼트 개수
|
||||
"""
|
||||
key = f"meeting:{meeting_id}:transcript"
|
||||
count = await self.redis_client.zcard(key)
|
||||
return count if count else 0
|
||||
|
||||
async def cleanup_meeting_data(self, meeting_id: str):
|
||||
"""
|
||||
회의 종료 시 데이터 정리
|
||||
|
||||
Args:
|
||||
meeting_id: 회의 ID
|
||||
"""
|
||||
key = f"meeting:{meeting_id}:transcript"
|
||||
await self.redis_client.delete(key)
|
||||
logger.info(f"회의 데이터 정리 완료 - meetingId: {meeting_id}")
|
||||
122
ai-python/app/services/transcript_service.py
Normal file
122
ai-python/app/services/transcript_service.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""Transcript Service - 회의록 통합 처리"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from app.models.transcript import (
|
||||
ConsolidateRequest,
|
||||
ConsolidateResponse,
|
||||
AgendaSummary,
|
||||
ExtractedTodo
|
||||
)
|
||||
from app.services.claude_service import claude_service
|
||||
from app.prompts.consolidate_prompt import get_consolidate_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TranscriptService:
|
||||
"""회의록 통합 서비스"""
|
||||
|
||||
async def consolidate_minutes(
|
||||
self,
|
||||
request: ConsolidateRequest
|
||||
) -> ConsolidateResponse:
|
||||
"""
|
||||
참석자별 회의록을 통합하여 AI 요약 생성
|
||||
"""
|
||||
logger.info(f"회의록 통합 시작 - Meeting ID: {request.meeting_id}")
|
||||
logger.info(f"참석자 수: {len(request.participant_minutes)}")
|
||||
|
||||
try:
|
||||
# 1. 프롬프트 생성
|
||||
participant_data = [
|
||||
{
|
||||
"user_name": pm.user_name,
|
||||
"content": pm.content
|
||||
}
|
||||
for pm in request.participant_minutes
|
||||
]
|
||||
|
||||
prompt = get_consolidate_prompt(
|
||||
participant_minutes=participant_data,
|
||||
agendas=request.agendas
|
||||
)
|
||||
|
||||
# 입력 데이터 로깅
|
||||
logger.info("=" * 80)
|
||||
logger.info("INPUT - 참석자별 회의록:")
|
||||
for pm in participant_data:
|
||||
logger.info(f"\n[{pm['user_name']}]")
|
||||
logger.info(f"{pm['content'][:500]}..." if len(pm['content']) > 500 else pm['content'])
|
||||
logger.info("=" * 80)
|
||||
|
||||
# 2. Claude API 호출
|
||||
start_time = datetime.utcnow()
|
||||
ai_result = await claude_service.generate_completion(prompt)
|
||||
processing_time = (datetime.utcnow() - start_time).total_seconds() * 1000
|
||||
|
||||
logger.info(f"AI 처리 완료 - {processing_time:.0f}ms")
|
||||
|
||||
# 3. 응답 구성
|
||||
response = self._build_response(
|
||||
meeting_id=request.meeting_id,
|
||||
ai_result=ai_result,
|
||||
participants_count=len(request.participant_minutes),
|
||||
duration_minutes=request.duration_minutes
|
||||
)
|
||||
|
||||
logger.info(f"통합 요약 완료 - 안건 수: {len(response.agenda_summaries)}, Todo 수: {response.statistics['todos_count']}")
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"회의록 통합 실패: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _build_response(
|
||||
self,
|
||||
meeting_id: str,
|
||||
ai_result: dict,
|
||||
participants_count: int,
|
||||
duration_minutes: int = None
|
||||
) -> ConsolidateResponse:
|
||||
"""AI 응답을 ConsolidateResponse로 변환"""
|
||||
|
||||
# 안건별 요약 변환
|
||||
agenda_summaries = []
|
||||
for agenda_data in ai_result.get("agenda_summaries", []):
|
||||
# Todo 변환 (제목만)
|
||||
todos = [
|
||||
ExtractedTodo(title=todo.get("title", ""))
|
||||
for todo in agenda_data.get("todos", [])
|
||||
]
|
||||
|
||||
agenda_summaries.append(
|
||||
AgendaSummary(
|
||||
agenda_number=agenda_data.get("agenda_number", 0),
|
||||
agenda_title=agenda_data.get("agenda_title", ""),
|
||||
summary_short=agenda_data.get("summary_short", ""),
|
||||
summary=agenda_data.get("summary", ""),
|
||||
pending=agenda_data.get("pending", []),
|
||||
todos=todos
|
||||
)
|
||||
)
|
||||
|
||||
# 통계 정보
|
||||
statistics = ai_result.get("statistics", {})
|
||||
statistics["participants_count"] = participants_count
|
||||
if duration_minutes:
|
||||
statistics["duration_minutes"] = duration_minutes
|
||||
|
||||
# 응답 생성
|
||||
return ConsolidateResponse(
|
||||
meeting_id=meeting_id,
|
||||
keywords=ai_result.get("keywords", []),
|
||||
statistics=statistics,
|
||||
decisions=ai_result.get("decisions", ""),
|
||||
agenda_summaries=agenda_summaries,
|
||||
generated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
transcript_service = TranscriptService()
|
||||
2
ai-python/logs/ai-python.log
Normal file
2
ai-python/logs/ai-python.log
Normal file
@ -0,0 +1,2 @@
|
||||
INFO: Will watch for changes in these directories: ['/Users/jominseo/HGZero/ai-python']
|
||||
ERROR: [Errno 48] Address already in use
|
||||
1577
ai-python/logs/ai-service.log
Normal file
1577
ai-python/logs/ai-service.log
Normal file
File diff suppressed because it is too large
Load Diff
58
ai-python/main.py
Normal file
58
ai-python/main.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""AI Service FastAPI Application"""
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import get_settings
|
||||
from app.api.v1 import router as api_v1_router
|
||||
import logging
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Settings
|
||||
settings = get_settings()
|
||||
|
||||
# FastAPI 앱 생성
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
description="AI-powered meeting minutes analysis service",
|
||||
version="1.0.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json"
|
||||
)
|
||||
|
||||
# CORS 미들웨어 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API 라우터 등록
|
||||
app.include_router(api_v1_router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""헬스 체크"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": settings.app_name
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=True,
|
||||
log_level=settings.log_level.lower()
|
||||
)
|
||||
11
ai-python/requirements.txt
Normal file
11
ai-python/requirements.txt
Normal file
@ -0,0 +1,11 @@
|
||||
# FastAPI
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.6.0
|
||||
|
||||
# Claude API
|
||||
anthropic==0.39.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.1
|
||||
115
ai-python/restart.sh
Executable file
115
ai-python/restart.sh
Executable file
@ -0,0 +1,115 @@
|
||||
#!/bin/bash
|
||||
|
||||
# AI Python 서비스 재시작 스크립트
|
||||
# 8086 포트로 깔끔하게 재시작
|
||||
|
||||
echo "=================================="
|
||||
echo "AI Python 서비스 재시작"
|
||||
echo "=================================="
|
||||
|
||||
# 1. 기존 프로세스 종료
|
||||
echo "1️⃣ 기존 프로세스 정리 중..."
|
||||
pkill -9 -f "python.*main.py" 2>/dev/null
|
||||
pkill -9 -f "uvicorn.*8086" 2>/dev/null
|
||||
pkill -9 -f "uvicorn.*8087" 2>/dev/null
|
||||
|
||||
# 잠시 대기 (포트 해제 대기)
|
||||
sleep 2
|
||||
|
||||
# 2. 포트 확인
|
||||
echo "2️⃣ 포트 상태 확인..."
|
||||
if lsof -i:8086 > /dev/null 2>&1; then
|
||||
echo " ⚠️ 8086 포트가 아직 사용 중입니다."
|
||||
echo " 강제 종료 시도..."
|
||||
PID=$(lsof -ti:8086)
|
||||
if [ ! -z "$PID" ]; then
|
||||
kill -9 $PID
|
||||
sleep 2
|
||||
fi
|
||||
fi
|
||||
|
||||
if lsof -i:8086 > /dev/null 2>&1; then
|
||||
echo " ❌ 8086 포트를 해제할 수 없습니다."
|
||||
echo " 시스템 재부팅 후 다시 시도하거나,"
|
||||
echo " 다른 포트를 사용하세요."
|
||||
exit 1
|
||||
else
|
||||
echo " ✅ 8086 포트 사용 가능"
|
||||
fi
|
||||
|
||||
# 3. 가상환경 활성화
|
||||
echo "3️⃣ 가상환경 활성화..."
|
||||
if [ ! -d "venv" ]; then
|
||||
echo " ❌ 가상환경이 없습니다. venv 디렉토리를 생성하세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source venv/bin/activate
|
||||
echo " ✅ 가상환경 활성화 완료"
|
||||
|
||||
# 4. 로그 디렉토리 확인
|
||||
mkdir -p ../logs
|
||||
|
||||
# 5. 서비스 시작
|
||||
echo "4️⃣ AI Python 서비스 시작 (포트: 8086)..."
|
||||
nohup python3 main.py > ../logs/ai-python.log 2>&1 &
|
||||
PID=$!
|
||||
|
||||
echo " PID: $PID"
|
||||
echo " 로그: ../logs/ai-python.log"
|
||||
|
||||
# 6. 시작 대기
|
||||
echo "5️⃣ 서비스 시작 대기 (7초)..."
|
||||
sleep 7
|
||||
|
||||
# 7. 상태 확인
|
||||
echo "6️⃣ 서비스 상태 확인..."
|
||||
|
||||
# 프로세스 확인
|
||||
if ps -p $PID > /dev/null; then
|
||||
echo " ✅ 프로세스 실행 중 (PID: $PID)"
|
||||
else
|
||||
echo " ❌ 프로세스 종료됨"
|
||||
echo " 로그 확인:"
|
||||
tail -20 ../logs/ai-python.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 포트 확인
|
||||
if lsof -i:8086 > /dev/null 2>&1; then
|
||||
echo " ✅ 8086 포트 리스닝 중"
|
||||
else
|
||||
echo " ⚠️ 8086 포트 아직 준비 중..."
|
||||
fi
|
||||
|
||||
# Health 체크
|
||||
echo "7️⃣ Health Check..."
|
||||
sleep 2
|
||||
HEALTH=$(curl -s http://localhost:8086/health 2>/dev/null)
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo " ✅ Health Check 성공"
|
||||
echo " $HEALTH"
|
||||
else
|
||||
echo " ⚠️ Health Check 실패 (서버가 아직 시작 중일 수 있습니다)"
|
||||
echo ""
|
||||
echo " 최근 로그:"
|
||||
tail -10 ../logs/ai-python.log
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=================================="
|
||||
echo "✅ AI Python 서비스 시작 완료"
|
||||
echo "=================================="
|
||||
echo "📊 서비스 정보:"
|
||||
echo " - PID: $PID"
|
||||
echo " - 포트: 8086"
|
||||
echo " - 로그: tail -f ../logs/ai-python.log"
|
||||
echo ""
|
||||
echo "📡 엔드포인트:"
|
||||
echo " - Health: http://localhost:8086/health"
|
||||
echo " - Root: http://localhost:8086/"
|
||||
echo " - Swagger: http://localhost:8086/swagger-ui.html"
|
||||
echo ""
|
||||
echo "🛑 서비스 중지: pkill -f 'python.*main.py'"
|
||||
echo "=================================="
|
||||
@ -57,7 +57,7 @@
|
||||
<entry key="AZURE_AI_SEARCH_INDEX" value="meeting-transcripts" />
|
||||
|
||||
<!-- Azure Event Hubs Configuration -->
|
||||
<entry key="AZURE_EVENTHUB_CONNECTION_STRING" value="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=" />
|
||||
<entry key="AZURE_EVENTHUB_CONNECTION_STRING" value="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=ai-listen-policy;SharedAccessKey=wqcbVIXlOMyn/C562lx6DD75AyjHQ87xo+AEhJ7js9Q=;EntityPath=hgzero-eventhub-name" />
|
||||
<entry key="AZURE_EVENTHUB_NAMESPACE" value="hgzero-eventhub-ns" />
|
||||
<entry key="AZURE_EVENTHUB_NAME" value="hgzero-eventhub-name" />
|
||||
<entry key="AZURE_CHECKPOINT_STORAGE_CONNECTION_STRING" value="" />
|
||||
|
||||
@ -3,16 +3,38 @@ bootJar {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Common module
|
||||
implementation project(':common')
|
||||
|
||||
// Redis
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
// PostgreSQL
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
|
||||
// OpenAI
|
||||
implementation "com.theokanning.openai-gpt3-java:service:${openaiVersion}"
|
||||
|
||||
// Anthropic Claude SDK
|
||||
implementation 'com.anthropic:anthropic-java:2.1.0'
|
||||
|
||||
// Azure AI Search
|
||||
implementation "com.azure:azure-search-documents:${azureAiSearchVersion}"
|
||||
|
||||
// Azure Event Hubs
|
||||
implementation "com.azure:azure-messaging-eventhubs:${azureEventHubsVersion}"
|
||||
implementation "com.azure:azure-messaging-eventhubs-checkpointstore-blob:${azureEventHubsCheckpointVersion}"
|
||||
|
||||
// Feign (for external API calls)
|
||||
implementation "io.github.openfeign:feign-jackson:${feignJacksonVersion}"
|
||||
implementation "io.github.openfeign:feign-okhttp:${feignJacksonVersion}"
|
||||
|
||||
// Spring WebFlux for SSE streaming
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
|
||||
// Springdoc OpenAPI
|
||||
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
|
||||
|
||||
// H2 Database for local development
|
||||
runtimeOnly 'com.h2database:h2'
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
BIN
ai/logs/ai-service.log.2025-10-24.0.gz
Normal file
BIN
ai/logs/ai-service.log.2025-10-24.0.gz
Normal file
Binary file not shown.
780
ai/logs/ai.log
780
ai/logs/ai.log
@ -1,780 +0,0 @@
|
||||
2025-10-23 16:24:30 [main] INFO com.unicorn.hgzero.ai.AiApplication - Starting AiApplication using Java 23.0.2 with PID 33322 (/Users/jominseo/HGZero/ai/build/classes/java/main started by jominseo in /Users/jominseo/HGZero/ai)
|
||||
2025-10-23 16:24:30 [main] DEBUG com.unicorn.hgzero.ai.AiApplication - Running with Spring Boot v3.3.0, Spring v6.1.8
|
||||
2025-10-23 16:24:30 [main] INFO com.unicorn.hgzero.ai.AiApplication - The following 1 profile is active: "local"
|
||||
2025-10-23 16:24:31 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
|
||||
2025-10-23 16:24:31 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data JPA repositories in DEFAULT mode.
|
||||
2025-10-23 16:24:31 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 4 ms. Found 0 JPA repository interfaces.
|
||||
2025-10-23 16:24:31 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
|
||||
2025-10-23 16:24:31 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode.
|
||||
2025-10-23 16:24:31 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 0 ms. Found 0 Redis repository interfaces.
|
||||
2025-10-23 16:24:31 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8084 (http)
|
||||
2025-10-23 16:24:31 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
|
||||
2025-10-23 16:24:31 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.24]
|
||||
2025-10-23 16:24:31 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
|
||||
2025-10-23 16:24:31 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 694 ms
|
||||
2025-10-23 16:24:31 [main] INFO o.h.jpa.internal.util.LogHelper - HHH000204: Processing PersistenceUnitInfo [name: default]
|
||||
2025-10-23 16:24:31 [main] INFO org.hibernate.Version - HHH000412: Hibernate ORM core version 6.5.2.Final
|
||||
2025-10-23 16:24:31 [main] INFO o.h.c.i.RegionFactoryInitiator - HHH000026: Second-level cache disabled
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@5c5f12e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@5c5f12e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Boolean -> org.hibernate.type.BasicTypeReference@5c5f12e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration numeric_boolean -> org.hibernate.type.BasicTypeReference@23f8036d
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.NumericBooleanConverter -> org.hibernate.type.BasicTypeReference@23f8036d
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration true_false -> org.hibernate.type.BasicTypeReference@68f69ca3
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.TrueFalseConverter -> org.hibernate.type.BasicTypeReference@68f69ca3
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration yes_no -> org.hibernate.type.BasicTypeReference@1e3566e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.YesNoConverter -> org.hibernate.type.BasicTypeReference@1e3566e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@2b058bfd
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@2b058bfd
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Byte -> org.hibernate.type.BasicTypeReference@2b058bfd
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary -> org.hibernate.type.BasicTypeReference@4805069b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte[] -> org.hibernate.type.BasicTypeReference@4805069b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [B -> org.hibernate.type.BasicTypeReference@4805069b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary_wrapper -> org.hibernate.type.BasicTypeReference@14ca88bc
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-binary -> org.hibernate.type.BasicTypeReference@14ca88bc
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration image -> org.hibernate.type.BasicTypeReference@f01fc6d
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration blob -> org.hibernate.type.BasicTypeReference@85cd413
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Blob -> org.hibernate.type.BasicTypeReference@85cd413
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob -> org.hibernate.type.BasicTypeReference@688d2a5d
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob_wrapper -> org.hibernate.type.BasicTypeReference@2842c098
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@2820b369
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@2820b369
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Short -> org.hibernate.type.BasicTypeReference@2820b369
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration integer -> org.hibernate.type.BasicTypeReference@46b21632
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration int -> org.hibernate.type.BasicTypeReference@46b21632
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Integer -> org.hibernate.type.BasicTypeReference@46b21632
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@476c137b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@476c137b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Long -> org.hibernate.type.BasicTypeReference@476c137b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@79144d0e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@79144d0e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Float -> org.hibernate.type.BasicTypeReference@79144d0e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@540212be
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@540212be
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Double -> org.hibernate.type.BasicTypeReference@540212be
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_integer -> org.hibernate.type.BasicTypeReference@2579d8a
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigInteger -> org.hibernate.type.BasicTypeReference@2579d8a
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_decimal -> org.hibernate.type.BasicTypeReference@2507a170
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigDecimal -> org.hibernate.type.BasicTypeReference@2507a170
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character -> org.hibernate.type.BasicTypeReference@7e20f4e3
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char -> org.hibernate.type.BasicTypeReference@7e20f4e3
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Character -> org.hibernate.type.BasicTypeReference@7e20f4e3
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character_nchar -> org.hibernate.type.BasicTypeReference@3af39e7b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration string -> org.hibernate.type.BasicTypeReference@4f6ff62
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.String -> org.hibernate.type.BasicTypeReference@4f6ff62
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nstring -> org.hibernate.type.BasicTypeReference@1c62d2ad
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration characters -> org.hibernate.type.BasicTypeReference@651caa2e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char[] -> org.hibernate.type.BasicTypeReference@651caa2e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [C -> org.hibernate.type.BasicTypeReference@651caa2e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-characters -> org.hibernate.type.BasicTypeReference@433ae0b0
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration text -> org.hibernate.type.BasicTypeReference@70840a5a
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ntext -> org.hibernate.type.BasicTypeReference@7af9595d
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration clob -> org.hibernate.type.BasicTypeReference@7a34c1f6
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Clob -> org.hibernate.type.BasicTypeReference@7a34c1f6
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nclob -> org.hibernate.type.BasicTypeReference@6e9f8160
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.NClob -> org.hibernate.type.BasicTypeReference@6e9f8160
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob -> org.hibernate.type.BasicTypeReference@3e998033
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_char_array -> org.hibernate.type.BasicTypeReference@e1a150c
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_character_array -> org.hibernate.type.BasicTypeReference@527d5e48
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob -> org.hibernate.type.BasicTypeReference@407b41e6
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_character_array -> org.hibernate.type.BasicTypeReference@3291d9c2
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_char_array -> org.hibernate.type.BasicTypeReference@6cfd08e9
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Duration -> org.hibernate.type.BasicTypeReference@54ca9420
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Duration -> org.hibernate.type.BasicTypeReference@54ca9420
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDateTime -> org.hibernate.type.BasicTypeReference@4ea48b2e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDateTime -> org.hibernate.type.BasicTypeReference@4ea48b2e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDate -> org.hibernate.type.BasicTypeReference@72c704f1
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDate -> org.hibernate.type.BasicTypeReference@72c704f1
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalTime -> org.hibernate.type.BasicTypeReference@76f9e000
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalTime -> org.hibernate.type.BasicTypeReference@76f9e000
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTime -> org.hibernate.type.BasicTypeReference@7612116b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetDateTime -> org.hibernate.type.BasicTypeReference@7612116b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@1c05097c
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@562f6681
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTime -> org.hibernate.type.BasicTypeReference@6f6f65a4
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetTime -> org.hibernate.type.BasicTypeReference@6f6f65a4
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeUtc -> org.hibernate.type.BasicTypeReference@990b86b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithTimezone -> org.hibernate.type.BasicTypeReference@3dea1ecc
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@105c6c9e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTime -> org.hibernate.type.BasicTypeReference@40a7974
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZonedDateTime -> org.hibernate.type.BasicTypeReference@40a7974
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@8d5da7e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@65a4b9d6
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration date -> org.hibernate.type.BasicTypeReference@16ef1160
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Date -> org.hibernate.type.BasicTypeReference@16ef1160
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration time -> org.hibernate.type.BasicTypeReference@41f90b10
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Time -> org.hibernate.type.BasicTypeReference@41f90b10
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timestamp -> org.hibernate.type.BasicTypeReference@67593f7b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Timestamp -> org.hibernate.type.BasicTypeReference@67593f7b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Date -> org.hibernate.type.BasicTypeReference@67593f7b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar -> org.hibernate.type.BasicTypeReference@2773504f
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Calendar -> org.hibernate.type.BasicTypeReference@2773504f
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.GregorianCalendar -> org.hibernate.type.BasicTypeReference@2773504f
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_date -> org.hibernate.type.BasicTypeReference@497921d0
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_time -> org.hibernate.type.BasicTypeReference@40d10264
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration instant -> org.hibernate.type.BasicTypeReference@6edd4fe2
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Instant -> org.hibernate.type.BasicTypeReference@6edd4fe2
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid -> org.hibernate.type.BasicTypeReference@53918b5e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.UUID -> org.hibernate.type.BasicTypeReference@53918b5e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration pg-uuid -> org.hibernate.type.BasicTypeReference@53918b5e
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-binary -> org.hibernate.type.BasicTypeReference@5366575d
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-char -> org.hibernate.type.BasicTypeReference@1b6cad77
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration class -> org.hibernate.type.BasicTypeReference@1fca53a7
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Class -> org.hibernate.type.BasicTypeReference@1fca53a7
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration currency -> org.hibernate.type.BasicTypeReference@40dee07b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Currency -> org.hibernate.type.BasicTypeReference@40dee07b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Currency -> org.hibernate.type.BasicTypeReference@40dee07b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration locale -> org.hibernate.type.BasicTypeReference@21e39b82
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Locale -> org.hibernate.type.BasicTypeReference@21e39b82
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration serializable -> org.hibernate.type.BasicTypeReference@5f9a8ddc
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.io.Serializable -> org.hibernate.type.BasicTypeReference@5f9a8ddc
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timezone -> org.hibernate.type.BasicTypeReference@1280bae3
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.TimeZone -> org.hibernate.type.BasicTypeReference@1280bae3
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZoneOffset -> org.hibernate.type.BasicTypeReference@256a5df0
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZoneOffset -> org.hibernate.type.BasicTypeReference@256a5df0
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration url -> org.hibernate.type.BasicTypeReference@1868ed54
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.net.URL -> org.hibernate.type.BasicTypeReference@1868ed54
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration vector -> org.hibernate.type.BasicTypeReference@131777e8
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration row_version -> org.hibernate.type.BasicTypeReference@45790cb
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration object -> org.hibernate.type.JavaObjectType@2bc2e022
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Object -> org.hibernate.type.JavaObjectType@2bc2e022
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration null -> org.hibernate.type.NullType@1ff81b0d
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_date -> org.hibernate.type.BasicTypeReference@1c610f
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_time -> org.hibernate.type.BasicTypeReference@5abc5854
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_timestamp -> org.hibernate.type.BasicTypeReference@5c3007d
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar -> org.hibernate.type.BasicTypeReference@66b40dd3
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_date -> org.hibernate.type.BasicTypeReference@7296fe0b
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_time -> org.hibernate.type.BasicTypeReference@4a5066f5
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_binary -> org.hibernate.type.BasicTypeReference@578d472a
|
||||
2025-10-23 16:24:31 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_serializable -> org.hibernate.type.BasicTypeReference@1191029d
|
||||
2025-10-23 16:24:31 [main] INFO o.s.o.j.p.SpringPersistenceUnitInfo - No LoadTimeWeaver setup: ignoring JPA class transformer
|
||||
2025-10-23 16:24:31 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
|
||||
2025-10-23 16:24:32 [main] WARN o.h.e.j.e.i.JdbcEnvironmentInitiator - HHH000342: Could not obtain connection to query metadata
|
||||
java.lang.NullPointerException: Cannot invoke "org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(java.sql.SQLException, String)" because the return value of "org.hibernate.resource.transaction.backend.jdbc.internal.JdbcIsolationDelegate.sqlExceptionHelper()" is null
|
||||
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcIsolationDelegate.delegateWork(JdbcIsolationDelegate.java:116)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentUsingJdbcMetadata(JdbcEnvironmentInitiator.java:290)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:123)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:77)
|
||||
at org.hibernate.boot.registry.internal.StandardServiceRegistryImpl.initiateService(StandardServiceRegistryImpl.java:130)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:263)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.initializeService(AbstractServiceRegistryImpl.java:238)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.getService(AbstractServiceRegistryImpl.java:215)
|
||||
at org.hibernate.boot.model.relational.Database.<init>(Database.java:45)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.getDatabase(InFlightMetadataCollectorImpl.java:221)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.<init>(InFlightMetadataCollectorImpl.java:189)
|
||||
at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:171)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1431)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1502)
|
||||
at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:75)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:390)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:366)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1835)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1784)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
|
||||
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:205)
|
||||
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:952)
|
||||
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:624)
|
||||
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
|
||||
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
|
||||
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
|
||||
at com.unicorn.hgzero.ai.AiApplication.main(AiApplication.java:20)
|
||||
2025-10-23 16:24:32 [main] ERROR o.s.o.j.LocalContainerEntityManagerFactoryBean - Failed to initialize JPA EntityManagerFactory: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
2025-10-23 16:24:32 [main] WARN o.s.b.w.s.c.AnnotationConfigServletWebServerApplicationContext - Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
2025-10-23 16:24:32 [main] INFO o.a.catalina.core.StandardService - Stopping service [Tomcat]
|
||||
2025-10-23 16:24:32 [main] INFO o.s.b.a.l.ConditionEvaluationReportLogger -
|
||||
|
||||
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
|
||||
2025-10-23 16:24:32 [main] ERROR o.s.boot.SpringApplication - Application run failed
|
||||
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1788)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
|
||||
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:205)
|
||||
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:952)
|
||||
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:624)
|
||||
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
|
||||
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
|
||||
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
|
||||
at com.unicorn.hgzero.ai.AiApplication.main(AiApplication.java:20)
|
||||
Caused by: org.hibernate.service.spi.ServiceException: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:276)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.initializeService(AbstractServiceRegistryImpl.java:238)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.getService(AbstractServiceRegistryImpl.java:215)
|
||||
at org.hibernate.boot.model.relational.Database.<init>(Database.java:45)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.getDatabase(InFlightMetadataCollectorImpl.java:221)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.<init>(InFlightMetadataCollectorImpl.java:189)
|
||||
at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:171)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1431)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1502)
|
||||
at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:75)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:390)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:366)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1835)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1784)
|
||||
... 15 common frames omitted
|
||||
Caused by: org.hibernate.HibernateException: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.determineDialect(DialectFactoryImpl.java:191)
|
||||
at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.buildDialect(DialectFactoryImpl.java:87)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentWithDefaults(JdbcEnvironmentInitiator.java:152)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentUsingJdbcMetadata(JdbcEnvironmentInitiator.java:362)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:123)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:77)
|
||||
at org.hibernate.boot.registry.internal.StandardServiceRegistryImpl.initiateService(StandardServiceRegistryImpl.java:130)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:263)
|
||||
... 30 common frames omitted
|
||||
2025-10-23 16:25:55 [main] INFO com.unicorn.hgzero.ai.AiApplication - Starting AiApplication using Java 23.0.2 with PID 33935 (/Users/jominseo/HGZero/ai/build/classes/java/main started by jominseo in /Users/jominseo/HGZero/ai)
|
||||
2025-10-23 16:25:55 [main] DEBUG com.unicorn.hgzero.ai.AiApplication - Running with Spring Boot v3.3.0, Spring v6.1.8
|
||||
2025-10-23 16:25:55 [main] INFO com.unicorn.hgzero.ai.AiApplication - The following 1 profile is active: "local"
|
||||
2025-10-23 16:25:55 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
|
||||
2025-10-23 16:25:55 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data JPA repositories in DEFAULT mode.
|
||||
2025-10-23 16:25:56 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 3 ms. Found 0 JPA repository interfaces.
|
||||
2025-10-23 16:25:56 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
|
||||
2025-10-23 16:25:56 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode.
|
||||
2025-10-23 16:25:56 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 0 ms. Found 0 Redis repository interfaces.
|
||||
2025-10-23 16:25:56 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8084 (http)
|
||||
2025-10-23 16:25:56 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
|
||||
2025-10-23 16:25:56 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.24]
|
||||
2025-10-23 16:25:56 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
|
||||
2025-10-23 16:25:56 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 608 ms
|
||||
2025-10-23 16:25:56 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
|
||||
2025-10-23 16:25:56 [main] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:testdb user=SA
|
||||
2025-10-23 16:25:56 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
|
||||
2025-10-23 16:25:56 [main] INFO o.s.b.a.h.H2ConsoleAutoConfiguration - H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:testdb'
|
||||
2025-10-23 16:25:56 [main] INFO o.h.jpa.internal.util.LogHelper - HHH000204: Processing PersistenceUnitInfo [name: default]
|
||||
2025-10-23 16:25:56 [main] INFO org.hibernate.Version - HHH000412: Hibernate ORM core version 6.5.2.Final
|
||||
2025-10-23 16:25:56 [main] INFO o.h.c.i.RegionFactoryInitiator - HHH000026: Second-level cache disabled
|
||||
2025-10-23 16:25:56 [main] INFO o.s.o.j.p.SpringPersistenceUnitInfo - No LoadTimeWeaver setup: ignoring JPA class transformer
|
||||
2025-10-23 16:25:56 [main] WARN org.hibernate.orm.deprecation - HHH90000025: H2Dialect does not need to be specified explicitly using 'hibernate.dialect' (remove the property setting and it will be selected by default)
|
||||
2025-10-23 16:25:56 [main] INFO o.h.e.t.j.p.i.JtaPlatformInitiator - HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
|
||||
2025-10-23 16:25:56 [main] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Initialized JPA EntityManagerFactory for persistence unit 'default'
|
||||
2025-10-23 16:25:56 [main] WARN o.s.b.a.o.j.JpaBaseConfiguration$JpaWebConfiguration - spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
|
||||
2025-10-23 16:25:56 [main] ERROR i.n.r.d.DnsServerAddressStreamProviders - Unable to load io.netty.resolver.dns.macos.MacOSDnsServerAddressStreamProvider, fallback to system defaults. This may result in incorrect DNS resolutions on MacOS. Check whether you have a dependency on 'io.netty:netty-resolver-dns-native-macos'. Use DEBUG level to see the full stack: java.lang.UnsatisfiedLinkError: failed to load the required native library
|
||||
2025-10-23 16:25:57 [main] WARN o.s.b.a.s.s.UserDetailsServiceAutoConfiguration -
|
||||
|
||||
Using generated security password: 1665e64f-a0ac-49dc-806e-846f88237e7c
|
||||
|
||||
This generated password is for development use only. Your security configuration must be updated before running your application in production.
|
||||
|
||||
2025-10-23 16:25:57 [main] INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer - Global AuthenticationManager configured with UserDetailsService bean with name inMemoryUserDetailsManager
|
||||
2025-10-23 16:25:57 [main] INFO o.s.b.a.e.web.EndpointLinksResolver - Exposing 3 endpoints beneath base path '/actuator'
|
||||
2025-10-23 16:25:57 [main] INFO o.s.s.web.DefaultSecurityFilterChain - Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@54ad9ff9, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@3b6b9981, org.springframework.security.web.context.SecurityContextHolderFilter@3ce34b92, org.springframework.security.web.header.HeaderWriterFilter@4a89722e, org.springframework.web.filter.CorsFilter@2eb6e166, org.springframework.security.web.csrf.CsrfFilter@2c3762c7, org.springframework.security.web.authentication.logout.LogoutFilter@751e7d99, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@331b0bfd, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@20894afb, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@43b1fdb7, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@61d4e070, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@f511a8e, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@1b52f723, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@7bdbf06f, org.springframework.security.web.access.ExceptionTranslationFilter@64c009b8, org.springframework.security.web.access.intercept.AuthorizationFilter@7dfbdcfe]
|
||||
2025-10-23 16:25:57 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8084 (http) with context path '/'
|
||||
2025-10-23 16:25:57 [main] INFO com.unicorn.hgzero.ai.AiApplication - Started AiApplication in 1.733 seconds (process running for 1.843)
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-1] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring DispatcherServlet 'dispatcherServlet'
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-1] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet'
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-1] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 0 ms
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-1] DEBUG o.s.security.web.FilterChainProxy - Securing GET /
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-1] DEBUG o.s.s.w.a.AnonymousAuthenticationFilter - Set SecurityContextHolder to anonymous SecurityContext
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-1] DEBUG o.s.s.w.s.HttpSessionRequestCache - Saved request http://localhost:8084/?continue to session
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-1] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@32da6cef, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-1] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@104972a0
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-1] DEBUG o.s.s.web.DefaultRedirectStrategy - Redirecting to http://localhost:8084/login
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-2] DEBUG o.s.security.web.FilterChainProxy - Securing GET /login
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-3] DEBUG o.s.security.web.FilterChainProxy - Securing GET /favicon.ico
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-3] DEBUG o.s.s.w.a.AnonymousAuthenticationFilter - Set SecurityContextHolder to anonymous SecurityContext
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-3] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@32da6cef, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-3] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@104972a0
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-3] DEBUG o.s.s.web.DefaultRedirectStrategy - Redirecting to http://localhost:8084/login
|
||||
2025-10-23 16:26:47 [http-nio-8084-exec-4] DEBUG o.s.security.web.FilterChainProxy - Securing GET /login
|
||||
2025-10-23 16:27:13 [SpringApplicationShutdownHook] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Closing JPA EntityManagerFactory for persistence unit 'default'
|
||||
2025-10-23 16:27:13 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated...
|
||||
2025-10-23 16:27:13 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed.
|
||||
2025-10-23 17:10:12 [main] INFO com.unicorn.hgzero.ai.AiApplication - Starting AiApplication using Java 23.0.2 with PID 43825 (/Users/jominseo/HGZero/ai/build/classes/java/main started by jominseo in /Users/jominseo/HGZero/ai)
|
||||
2025-10-23 17:10:12 [main] DEBUG com.unicorn.hgzero.ai.AiApplication - Running with Spring Boot v3.3.0, Spring v6.1.8
|
||||
2025-10-23 17:10:12 [main] INFO com.unicorn.hgzero.ai.AiApplication - The following 1 profile is active: "dev"
|
||||
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
|
||||
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data JPA repositories in DEFAULT mode.
|
||||
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 3 ms. Found 0 JPA repository interfaces.
|
||||
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
|
||||
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode.
|
||||
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 0 ms. Found 0 Redis repository interfaces.
|
||||
2025-10-23 17:10:13 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8083 (http)
|
||||
2025-10-23 17:10:13 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
|
||||
2025-10-23 17:10:13 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.24]
|
||||
2025-10-23 17:10:13 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
|
||||
2025-10-23 17:10:13 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 668 ms
|
||||
2025-10-23 17:10:13 [main] INFO o.h.jpa.internal.util.LogHelper - HHH000204: Processing PersistenceUnitInfo [name: default]
|
||||
2025-10-23 17:10:13 [main] INFO org.hibernate.Version - HHH000412: Hibernate ORM core version 6.5.2.Final
|
||||
2025-10-23 17:10:13 [main] INFO o.h.c.i.RegionFactoryInitiator - HHH000026: Second-level cache disabled
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@66716959
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@66716959
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Boolean -> org.hibernate.type.BasicTypeReference@66716959
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration numeric_boolean -> org.hibernate.type.BasicTypeReference@34e07e65
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.NumericBooleanConverter -> org.hibernate.type.BasicTypeReference@34e07e65
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration true_false -> org.hibernate.type.BasicTypeReference@7ca0166c
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.TrueFalseConverter -> org.hibernate.type.BasicTypeReference@7ca0166c
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration yes_no -> org.hibernate.type.BasicTypeReference@1dcad16f
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.YesNoConverter -> org.hibernate.type.BasicTypeReference@1dcad16f
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@701c482e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@701c482e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Byte -> org.hibernate.type.BasicTypeReference@701c482e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary -> org.hibernate.type.BasicTypeReference@4738131e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte[] -> org.hibernate.type.BasicTypeReference@4738131e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [B -> org.hibernate.type.BasicTypeReference@4738131e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary_wrapper -> org.hibernate.type.BasicTypeReference@3b576ee3
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-binary -> org.hibernate.type.BasicTypeReference@3b576ee3
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration image -> org.hibernate.type.BasicTypeReference@705d914f
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration blob -> org.hibernate.type.BasicTypeReference@6212ea52
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Blob -> org.hibernate.type.BasicTypeReference@6212ea52
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob -> org.hibernate.type.BasicTypeReference@65b5b5ed
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob_wrapper -> org.hibernate.type.BasicTypeReference@6595ffce
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@795eddda
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@795eddda
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Short -> org.hibernate.type.BasicTypeReference@795eddda
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration integer -> org.hibernate.type.BasicTypeReference@c6bf8d9
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration int -> org.hibernate.type.BasicTypeReference@c6bf8d9
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Integer -> org.hibernate.type.BasicTypeReference@c6bf8d9
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@44392e64
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@44392e64
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Long -> org.hibernate.type.BasicTypeReference@44392e64
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@e18d2a2
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@e18d2a2
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Float -> org.hibernate.type.BasicTypeReference@e18d2a2
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@1a77eb6
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@1a77eb6
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Double -> org.hibernate.type.BasicTypeReference@1a77eb6
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_integer -> org.hibernate.type.BasicTypeReference@52d9f36b
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigInteger -> org.hibernate.type.BasicTypeReference@52d9f36b
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_decimal -> org.hibernate.type.BasicTypeReference@5f9ebd5a
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigDecimal -> org.hibernate.type.BasicTypeReference@5f9ebd5a
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character -> org.hibernate.type.BasicTypeReference@175bf9c9
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char -> org.hibernate.type.BasicTypeReference@175bf9c9
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Character -> org.hibernate.type.BasicTypeReference@175bf9c9
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character_nchar -> org.hibernate.type.BasicTypeReference@2db3675a
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration string -> org.hibernate.type.BasicTypeReference@306c9b2c
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.String -> org.hibernate.type.BasicTypeReference@306c9b2c
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nstring -> org.hibernate.type.BasicTypeReference@1ab28416
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration characters -> org.hibernate.type.BasicTypeReference@52efb338
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char[] -> org.hibernate.type.BasicTypeReference@52efb338
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [C -> org.hibernate.type.BasicTypeReference@52efb338
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-characters -> org.hibernate.type.BasicTypeReference@64508788
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration text -> org.hibernate.type.BasicTypeReference@30b1c5d5
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ntext -> org.hibernate.type.BasicTypeReference@3e2d65e1
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration clob -> org.hibernate.type.BasicTypeReference@1174676f
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Clob -> org.hibernate.type.BasicTypeReference@1174676f
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nclob -> org.hibernate.type.BasicTypeReference@71f8ce0e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.NClob -> org.hibernate.type.BasicTypeReference@71f8ce0e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob -> org.hibernate.type.BasicTypeReference@4fd92289
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_char_array -> org.hibernate.type.BasicTypeReference@1a8e44fe
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_character_array -> org.hibernate.type.BasicTypeReference@287317df
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob -> org.hibernate.type.BasicTypeReference@1fcc3461
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_character_array -> org.hibernate.type.BasicTypeReference@1987807b
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_char_array -> org.hibernate.type.BasicTypeReference@71469e01
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Duration -> org.hibernate.type.BasicTypeReference@41bbb219
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Duration -> org.hibernate.type.BasicTypeReference@41bbb219
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDateTime -> org.hibernate.type.BasicTypeReference@3f2ae973
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDateTime -> org.hibernate.type.BasicTypeReference@3f2ae973
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDate -> org.hibernate.type.BasicTypeReference@1a8b22b5
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDate -> org.hibernate.type.BasicTypeReference@1a8b22b5
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalTime -> org.hibernate.type.BasicTypeReference@5f781173
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalTime -> org.hibernate.type.BasicTypeReference@5f781173
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTime -> org.hibernate.type.BasicTypeReference@43cf5bff
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetDateTime -> org.hibernate.type.BasicTypeReference@43cf5bff
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@2b464384
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@681b42d3
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTime -> org.hibernate.type.BasicTypeReference@77f7352a
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetTime -> org.hibernate.type.BasicTypeReference@77f7352a
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeUtc -> org.hibernate.type.BasicTypeReference@4ede8888
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithTimezone -> org.hibernate.type.BasicTypeReference@571db8b4
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@65a2755e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTime -> org.hibernate.type.BasicTypeReference@2b3242a5
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZonedDateTime -> org.hibernate.type.BasicTypeReference@2b3242a5
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@11120583
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@2bf0c70d
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration date -> org.hibernate.type.BasicTypeReference@5d8e4fa8
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Date -> org.hibernate.type.BasicTypeReference@5d8e4fa8
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration time -> org.hibernate.type.BasicTypeReference@649009d6
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Time -> org.hibernate.type.BasicTypeReference@649009d6
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timestamp -> org.hibernate.type.BasicTypeReference@652f26da
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Timestamp -> org.hibernate.type.BasicTypeReference@652f26da
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Date -> org.hibernate.type.BasicTypeReference@652f26da
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar -> org.hibernate.type.BasicTypeReference@484a5ddd
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Calendar -> org.hibernate.type.BasicTypeReference@484a5ddd
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.GregorianCalendar -> org.hibernate.type.BasicTypeReference@484a5ddd
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_date -> org.hibernate.type.BasicTypeReference@6796a873
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_time -> org.hibernate.type.BasicTypeReference@3acc3ee
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration instant -> org.hibernate.type.BasicTypeReference@1f293cb7
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Instant -> org.hibernate.type.BasicTypeReference@1f293cb7
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid -> org.hibernate.type.BasicTypeReference@5972e3a
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.UUID -> org.hibernate.type.BasicTypeReference@5972e3a
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration pg-uuid -> org.hibernate.type.BasicTypeReference@5972e3a
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-binary -> org.hibernate.type.BasicTypeReference@5790cbcb
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-char -> org.hibernate.type.BasicTypeReference@32c6d164
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration class -> org.hibernate.type.BasicTypeReference@645c9f0f
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Class -> org.hibernate.type.BasicTypeReference@645c9f0f
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration currency -> org.hibernate.type.BasicTypeReference@58068b40
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Currency -> org.hibernate.type.BasicTypeReference@58068b40
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Currency -> org.hibernate.type.BasicTypeReference@58068b40
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration locale -> org.hibernate.type.BasicTypeReference@999cd18
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Locale -> org.hibernate.type.BasicTypeReference@999cd18
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration serializable -> org.hibernate.type.BasicTypeReference@dd060be
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.io.Serializable -> org.hibernate.type.BasicTypeReference@dd060be
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timezone -> org.hibernate.type.BasicTypeReference@df432ec
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.TimeZone -> org.hibernate.type.BasicTypeReference@df432ec
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZoneOffset -> org.hibernate.type.BasicTypeReference@6144e499
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZoneOffset -> org.hibernate.type.BasicTypeReference@6144e499
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration url -> org.hibernate.type.BasicTypeReference@26f204a4
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.net.URL -> org.hibernate.type.BasicTypeReference@26f204a4
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration vector -> org.hibernate.type.BasicTypeReference@28295554
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration row_version -> org.hibernate.type.BasicTypeReference@4e671ef
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration object -> org.hibernate.type.JavaObjectType@2aac6fa7
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Object -> org.hibernate.type.JavaObjectType@2aac6fa7
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration null -> org.hibernate.type.NullType@2358443e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_date -> org.hibernate.type.BasicTypeReference@25e796fe
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_time -> org.hibernate.type.BasicTypeReference@29ba63f0
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_timestamp -> org.hibernate.type.BasicTypeReference@4822ab4d
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar -> org.hibernate.type.BasicTypeReference@516b84d1
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_date -> org.hibernate.type.BasicTypeReference@1ad1f167
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_time -> org.hibernate.type.BasicTypeReference@608eb42e
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_binary -> org.hibernate.type.BasicTypeReference@3d2b13b1
|
||||
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_serializable -> org.hibernate.type.BasicTypeReference@30eb55c9
|
||||
2025-10-23 17:10:13 [main] INFO o.s.o.j.p.SpringPersistenceUnitInfo - No LoadTimeWeaver setup: ignoring JPA class transformer
|
||||
2025-10-23 17:10:13 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
|
||||
2025-10-23 17:10:14 [main] WARN o.h.e.j.e.i.JdbcEnvironmentInitiator - HHH000342: Could not obtain connection to query metadata
|
||||
java.lang.NullPointerException: Cannot invoke "org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(java.sql.SQLException, String)" because the return value of "org.hibernate.resource.transaction.backend.jdbc.internal.JdbcIsolationDelegate.sqlExceptionHelper()" is null
|
||||
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcIsolationDelegate.delegateWork(JdbcIsolationDelegate.java:116)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentUsingJdbcMetadata(JdbcEnvironmentInitiator.java:290)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:123)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:77)
|
||||
at org.hibernate.boot.registry.internal.StandardServiceRegistryImpl.initiateService(StandardServiceRegistryImpl.java:130)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:263)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.initializeService(AbstractServiceRegistryImpl.java:238)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.getService(AbstractServiceRegistryImpl.java:215)
|
||||
at org.hibernate.boot.model.relational.Database.<init>(Database.java:45)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.getDatabase(InFlightMetadataCollectorImpl.java:221)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.<init>(InFlightMetadataCollectorImpl.java:189)
|
||||
at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:171)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1431)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1502)
|
||||
at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:75)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:390)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:366)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1835)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1784)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
|
||||
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:205)
|
||||
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:952)
|
||||
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:624)
|
||||
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
|
||||
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
|
||||
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
|
||||
at com.unicorn.hgzero.ai.AiApplication.main(AiApplication.java:20)
|
||||
2025-10-23 17:10:14 [main] ERROR o.s.o.j.LocalContainerEntityManagerFactoryBean - Failed to initialize JPA EntityManagerFactory: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
2025-10-23 17:10:14 [main] WARN o.s.b.w.s.c.AnnotationConfigServletWebServerApplicationContext - Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
2025-10-23 17:10:14 [main] INFO o.a.catalina.core.StandardService - Stopping service [Tomcat]
|
||||
2025-10-23 17:10:14 [main] INFO o.s.b.a.l.ConditionEvaluationReportLogger -
|
||||
|
||||
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
|
||||
2025-10-23 17:10:14 [main] ERROR o.s.boot.SpringApplication - Application run failed
|
||||
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1788)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
|
||||
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:205)
|
||||
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:952)
|
||||
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:624)
|
||||
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
|
||||
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
|
||||
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
|
||||
at com.unicorn.hgzero.ai.AiApplication.main(AiApplication.java:20)
|
||||
Caused by: org.hibernate.service.spi.ServiceException: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:276)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.initializeService(AbstractServiceRegistryImpl.java:238)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.getService(AbstractServiceRegistryImpl.java:215)
|
||||
at org.hibernate.boot.model.relational.Database.<init>(Database.java:45)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.getDatabase(InFlightMetadataCollectorImpl.java:221)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.<init>(InFlightMetadataCollectorImpl.java:189)
|
||||
at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:171)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1431)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1502)
|
||||
at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:75)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:390)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:366)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1835)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1784)
|
||||
... 15 common frames omitted
|
||||
Caused by: org.hibernate.HibernateException: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.determineDialect(DialectFactoryImpl.java:191)
|
||||
at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.buildDialect(DialectFactoryImpl.java:87)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentWithDefaults(JdbcEnvironmentInitiator.java:152)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentUsingJdbcMetadata(JdbcEnvironmentInitiator.java:362)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:123)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:77)
|
||||
at org.hibernate.boot.registry.internal.StandardServiceRegistryImpl.initiateService(StandardServiceRegistryImpl.java:130)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:263)
|
||||
... 30 common frames omitted
|
||||
2025-10-23 17:38:09 [main] INFO com.unicorn.hgzero.ai.AiApplication - Starting AiApplication using Java 23.0.2 with PID 49971 (/Users/jominseo/HGZero/ai/build/classes/java/main started by jominseo in /Users/jominseo/HGZero/ai)
|
||||
2025-10-23 17:38:09 [main] DEBUG com.unicorn.hgzero.ai.AiApplication - Running with Spring Boot v3.3.0, Spring v6.1.8
|
||||
2025-10-23 17:38:09 [main] INFO com.unicorn.hgzero.ai.AiApplication - The following 1 profile is active: "dev"
|
||||
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
|
||||
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data JPA repositories in DEFAULT mode.
|
||||
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 4 ms. Found 0 JPA repository interfaces.
|
||||
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
|
||||
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode.
|
||||
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 0 ms. Found 0 Redis repository interfaces.
|
||||
2025-10-23 17:38:09 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8083 (http)
|
||||
2025-10-23 17:38:09 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
|
||||
2025-10-23 17:38:09 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.24]
|
||||
2025-10-23 17:38:09 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
|
||||
2025-10-23 17:38:09 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 679 ms
|
||||
2025-10-23 17:38:10 [main] INFO o.h.jpa.internal.util.LogHelper - HHH000204: Processing PersistenceUnitInfo [name: default]
|
||||
2025-10-23 17:38:10 [main] INFO org.hibernate.Version - HHH000412: Hibernate ORM core version 6.5.2.Final
|
||||
2025-10-23 17:38:10 [main] INFO o.h.c.i.RegionFactoryInitiator - HHH000026: Second-level cache disabled
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@306c9b2c
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@306c9b2c
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Boolean -> org.hibernate.type.BasicTypeReference@306c9b2c
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration numeric_boolean -> org.hibernate.type.BasicTypeReference@1ab28416
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.NumericBooleanConverter -> org.hibernate.type.BasicTypeReference@1ab28416
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration true_false -> org.hibernate.type.BasicTypeReference@52efb338
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.TrueFalseConverter -> org.hibernate.type.BasicTypeReference@52efb338
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration yes_no -> org.hibernate.type.BasicTypeReference@64508788
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.YesNoConverter -> org.hibernate.type.BasicTypeReference@64508788
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@30b1c5d5
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@30b1c5d5
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Byte -> org.hibernate.type.BasicTypeReference@30b1c5d5
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary -> org.hibernate.type.BasicTypeReference@3e2d65e1
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte[] -> org.hibernate.type.BasicTypeReference@3e2d65e1
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [B -> org.hibernate.type.BasicTypeReference@3e2d65e1
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary_wrapper -> org.hibernate.type.BasicTypeReference@1174676f
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-binary -> org.hibernate.type.BasicTypeReference@1174676f
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration image -> org.hibernate.type.BasicTypeReference@71f8ce0e
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration blob -> org.hibernate.type.BasicTypeReference@4fd92289
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Blob -> org.hibernate.type.BasicTypeReference@4fd92289
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob -> org.hibernate.type.BasicTypeReference@1a8e44fe
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob_wrapper -> org.hibernate.type.BasicTypeReference@287317df
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@1fcc3461
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@1fcc3461
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Short -> org.hibernate.type.BasicTypeReference@1fcc3461
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration integer -> org.hibernate.type.BasicTypeReference@1987807b
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration int -> org.hibernate.type.BasicTypeReference@1987807b
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Integer -> org.hibernate.type.BasicTypeReference@1987807b
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@71469e01
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@71469e01
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Long -> org.hibernate.type.BasicTypeReference@71469e01
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@41bbb219
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@41bbb219
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Float -> org.hibernate.type.BasicTypeReference@41bbb219
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@3f2ae973
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@3f2ae973
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Double -> org.hibernate.type.BasicTypeReference@3f2ae973
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_integer -> org.hibernate.type.BasicTypeReference@1a8b22b5
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigInteger -> org.hibernate.type.BasicTypeReference@1a8b22b5
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_decimal -> org.hibernate.type.BasicTypeReference@5f781173
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigDecimal -> org.hibernate.type.BasicTypeReference@5f781173
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character -> org.hibernate.type.BasicTypeReference@43cf5bff
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char -> org.hibernate.type.BasicTypeReference@43cf5bff
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Character -> org.hibernate.type.BasicTypeReference@43cf5bff
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character_nchar -> org.hibernate.type.BasicTypeReference@2b464384
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration string -> org.hibernate.type.BasicTypeReference@681b42d3
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.String -> org.hibernate.type.BasicTypeReference@681b42d3
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nstring -> org.hibernate.type.BasicTypeReference@77f7352a
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration characters -> org.hibernate.type.BasicTypeReference@4ede8888
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char[] -> org.hibernate.type.BasicTypeReference@4ede8888
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [C -> org.hibernate.type.BasicTypeReference@4ede8888
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-characters -> org.hibernate.type.BasicTypeReference@571db8b4
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration text -> org.hibernate.type.BasicTypeReference@65a2755e
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ntext -> org.hibernate.type.BasicTypeReference@2b3242a5
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration clob -> org.hibernate.type.BasicTypeReference@11120583
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Clob -> org.hibernate.type.BasicTypeReference@11120583
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nclob -> org.hibernate.type.BasicTypeReference@2bf0c70d
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.NClob -> org.hibernate.type.BasicTypeReference@2bf0c70d
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob -> org.hibernate.type.BasicTypeReference@5d8e4fa8
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_char_array -> org.hibernate.type.BasicTypeReference@649009d6
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_character_array -> org.hibernate.type.BasicTypeReference@652f26da
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob -> org.hibernate.type.BasicTypeReference@484a5ddd
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_character_array -> org.hibernate.type.BasicTypeReference@6796a873
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_char_array -> org.hibernate.type.BasicTypeReference@3acc3ee
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Duration -> org.hibernate.type.BasicTypeReference@1f293cb7
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Duration -> org.hibernate.type.BasicTypeReference@1f293cb7
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDateTime -> org.hibernate.type.BasicTypeReference@5972e3a
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDateTime -> org.hibernate.type.BasicTypeReference@5972e3a
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDate -> org.hibernate.type.BasicTypeReference@5790cbcb
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDate -> org.hibernate.type.BasicTypeReference@5790cbcb
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalTime -> org.hibernate.type.BasicTypeReference@32c6d164
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalTime -> org.hibernate.type.BasicTypeReference@32c6d164
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTime -> org.hibernate.type.BasicTypeReference@645c9f0f
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetDateTime -> org.hibernate.type.BasicTypeReference@645c9f0f
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@58068b40
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@999cd18
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTime -> org.hibernate.type.BasicTypeReference@dd060be
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetTime -> org.hibernate.type.BasicTypeReference@dd060be
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeUtc -> org.hibernate.type.BasicTypeReference@df432ec
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithTimezone -> org.hibernate.type.BasicTypeReference@6144e499
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@26f204a4
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTime -> org.hibernate.type.BasicTypeReference@28295554
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZonedDateTime -> org.hibernate.type.BasicTypeReference@28295554
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@4e671ef
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@42403dc6
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration date -> org.hibernate.type.BasicTypeReference@74a1d60e
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Date -> org.hibernate.type.BasicTypeReference@74a1d60e
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration time -> org.hibernate.type.BasicTypeReference@16c0be3b
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Time -> org.hibernate.type.BasicTypeReference@16c0be3b
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timestamp -> org.hibernate.type.BasicTypeReference@219edc05
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Timestamp -> org.hibernate.type.BasicTypeReference@219edc05
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Date -> org.hibernate.type.BasicTypeReference@219edc05
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar -> org.hibernate.type.BasicTypeReference@62f37bfd
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Calendar -> org.hibernate.type.BasicTypeReference@62f37bfd
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.GregorianCalendar -> org.hibernate.type.BasicTypeReference@62f37bfd
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_date -> org.hibernate.type.BasicTypeReference@1818d00b
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_time -> org.hibernate.type.BasicTypeReference@b3a8455
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration instant -> org.hibernate.type.BasicTypeReference@5c930fc3
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Instant -> org.hibernate.type.BasicTypeReference@5c930fc3
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid -> org.hibernate.type.BasicTypeReference@25c6ab3f
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.UUID -> org.hibernate.type.BasicTypeReference@25c6ab3f
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration pg-uuid -> org.hibernate.type.BasicTypeReference@25c6ab3f
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-binary -> org.hibernate.type.BasicTypeReference@7b80af04
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-char -> org.hibernate.type.BasicTypeReference@2447940d
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration class -> org.hibernate.type.BasicTypeReference@60ee7a51
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Class -> org.hibernate.type.BasicTypeReference@60ee7a51
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration currency -> org.hibernate.type.BasicTypeReference@70e1aa20
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Currency -> org.hibernate.type.BasicTypeReference@70e1aa20
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Currency -> org.hibernate.type.BasicTypeReference@70e1aa20
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration locale -> org.hibernate.type.BasicTypeReference@e67d3b7
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Locale -> org.hibernate.type.BasicTypeReference@e67d3b7
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration serializable -> org.hibernate.type.BasicTypeReference@1618c98a
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.io.Serializable -> org.hibernate.type.BasicTypeReference@1618c98a
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timezone -> org.hibernate.type.BasicTypeReference@5b715ea
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.TimeZone -> org.hibernate.type.BasicTypeReference@5b715ea
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZoneOffset -> org.hibernate.type.BasicTypeReference@787a0fd6
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZoneOffset -> org.hibernate.type.BasicTypeReference@787a0fd6
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration url -> org.hibernate.type.BasicTypeReference@48b09105
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.net.URL -> org.hibernate.type.BasicTypeReference@48b09105
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration vector -> org.hibernate.type.BasicTypeReference@18b45500
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration row_version -> org.hibernate.type.BasicTypeReference@25110bb9
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration object -> org.hibernate.type.JavaObjectType@30eb55c9
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Object -> org.hibernate.type.JavaObjectType@30eb55c9
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration null -> org.hibernate.type.NullType@5badeda0
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_date -> org.hibernate.type.BasicTypeReference@56a9a7b5
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_time -> org.hibernate.type.BasicTypeReference@338270ea
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_timestamp -> org.hibernate.type.BasicTypeReference@7f64bd7
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar -> org.hibernate.type.BasicTypeReference@1c79d093
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_date -> org.hibernate.type.BasicTypeReference@746fd19b
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_time -> org.hibernate.type.BasicTypeReference@54caeadc
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_binary -> org.hibernate.type.BasicTypeReference@61d7bb61
|
||||
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_serializable -> org.hibernate.type.BasicTypeReference@33f81280
|
||||
2025-10-23 17:38:10 [main] INFO o.s.o.j.p.SpringPersistenceUnitInfo - No LoadTimeWeaver setup: ignoring JPA class transformer
|
||||
2025-10-23 17:38:10 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
|
||||
2025-10-23 17:38:11 [main] WARN o.h.e.j.e.i.JdbcEnvironmentInitiator - HHH000342: Could not obtain connection to query metadata
|
||||
java.lang.NullPointerException: Cannot invoke "org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(java.sql.SQLException, String)" because the return value of "org.hibernate.resource.transaction.backend.jdbc.internal.JdbcIsolationDelegate.sqlExceptionHelper()" is null
|
||||
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcIsolationDelegate.delegateWork(JdbcIsolationDelegate.java:116)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentUsingJdbcMetadata(JdbcEnvironmentInitiator.java:290)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:123)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:77)
|
||||
at org.hibernate.boot.registry.internal.StandardServiceRegistryImpl.initiateService(StandardServiceRegistryImpl.java:130)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:263)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.initializeService(AbstractServiceRegistryImpl.java:238)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.getService(AbstractServiceRegistryImpl.java:215)
|
||||
at org.hibernate.boot.model.relational.Database.<init>(Database.java:45)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.getDatabase(InFlightMetadataCollectorImpl.java:221)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.<init>(InFlightMetadataCollectorImpl.java:189)
|
||||
at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:171)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1431)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1502)
|
||||
at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:75)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:390)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:366)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1835)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1784)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
|
||||
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:205)
|
||||
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:952)
|
||||
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:624)
|
||||
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
|
||||
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
|
||||
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
|
||||
at com.unicorn.hgzero.ai.AiApplication.main(AiApplication.java:20)
|
||||
2025-10-23 17:38:11 [main] ERROR o.s.o.j.LocalContainerEntityManagerFactoryBean - Failed to initialize JPA EntityManagerFactory: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
2025-10-23 17:38:11 [main] WARN o.s.b.w.s.c.AnnotationConfigServletWebServerApplicationContext - Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
2025-10-23 17:38:11 [main] INFO o.a.catalina.core.StandardService - Stopping service [Tomcat]
|
||||
2025-10-23 17:38:11 [main] INFO o.s.b.a.l.ConditionEvaluationReportLogger -
|
||||
|
||||
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
|
||||
2025-10-23 17:38:11 [main] ERROR o.s.boot.SpringApplication - Application run failed
|
||||
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1788)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
|
||||
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
|
||||
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:205)
|
||||
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:952)
|
||||
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:624)
|
||||
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
|
||||
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
|
||||
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
|
||||
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
|
||||
at com.unicorn.hgzero.ai.AiApplication.main(AiApplication.java:20)
|
||||
Caused by: org.hibernate.service.spi.ServiceException: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:276)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.initializeService(AbstractServiceRegistryImpl.java:238)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.getService(AbstractServiceRegistryImpl.java:215)
|
||||
at org.hibernate.boot.model.relational.Database.<init>(Database.java:45)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.getDatabase(InFlightMetadataCollectorImpl.java:221)
|
||||
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.<init>(InFlightMetadataCollectorImpl.java:189)
|
||||
at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:171)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1431)
|
||||
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1502)
|
||||
at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:75)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:390)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
|
||||
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
|
||||
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:366)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1835)
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1784)
|
||||
... 15 common frames omitted
|
||||
Caused by: org.hibernate.HibernateException: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
|
||||
at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.determineDialect(DialectFactoryImpl.java:191)
|
||||
at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.buildDialect(DialectFactoryImpl.java:87)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentWithDefaults(JdbcEnvironmentInitiator.java:152)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentUsingJdbcMetadata(JdbcEnvironmentInitiator.java:362)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:123)
|
||||
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:77)
|
||||
at org.hibernate.boot.registry.internal.StandardServiceRegistryImpl.initiateService(StandardServiceRegistryImpl.java:130)
|
||||
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:263)
|
||||
... 30 common frames omitted
|
||||
@ -48,6 +48,11 @@ public class RelatedMinutes {
|
||||
*/
|
||||
private List<String> commonKeywords;
|
||||
|
||||
/**
|
||||
* 회의록 핵심 내용 요약 (1-2문장)
|
||||
*/
|
||||
private String summary;
|
||||
|
||||
/**
|
||||
* 회의록 링크
|
||||
*/
|
||||
|
||||
@ -31,10 +31,26 @@ public class Term {
|
||||
private Double confidence;
|
||||
|
||||
/**
|
||||
* 용어 카테고리 (기술, 업무, 도메인)
|
||||
* 용어 카테고리 (기술, 업무, 도메인, 기획, 비즈니스, 전략, 마케팅)
|
||||
*/
|
||||
private String category;
|
||||
|
||||
/**
|
||||
* 용어 정의 (간단한 설명)
|
||||
*/
|
||||
private String definition;
|
||||
|
||||
/**
|
||||
* 용어가 사용된 맥락 (과거 회의록 참조)
|
||||
* 예: "신제품 기획 회의(2024-09-15)에서 언급"
|
||||
*/
|
||||
private String context;
|
||||
|
||||
/**
|
||||
* 관련 회의 ID (용어가 논의된 과거 회의)
|
||||
*/
|
||||
private String relatedMeetingId;
|
||||
|
||||
/**
|
||||
* 하이라이트 여부
|
||||
*/
|
||||
|
||||
@ -3,11 +3,22 @@ package com.unicorn.hgzero.ai.biz.service;
|
||||
import com.unicorn.hgzero.ai.biz.domain.Suggestion;
|
||||
import com.unicorn.hgzero.ai.biz.gateway.LlmGateway;
|
||||
import com.unicorn.hgzero.ai.biz.usecase.SuggestionUseCase;
|
||||
import com.unicorn.hgzero.ai.infra.client.ClaudeApiClient;
|
||||
import com.unicorn.hgzero.ai.infra.dto.common.SimpleSuggestionDto;
|
||||
import com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 논의사항/결정사항 제안 Service
|
||||
@ -19,6 +30,15 @@ import java.util.List;
|
||||
public class SuggestionService implements SuggestionUseCase {
|
||||
|
||||
private final LlmGateway llmGateway;
|
||||
private final ClaudeApiClient claudeApiClient;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
// 회의별 실시간 스트림 관리 (회의 ID -> Sink)
|
||||
private final Map<String, Sinks.Many<RealtimeSuggestionsDto>> meetingSinks = new ConcurrentHashMap<>();
|
||||
|
||||
// 분석 임계값 설정
|
||||
private static final int MIN_SEGMENTS_FOR_ANALYSIS = 10; // 10개 세그먼트 = 약 100-200자
|
||||
private static final long TEXT_RETENTION_MS = 5 * 60 * 1000; // 5분
|
||||
|
||||
@Override
|
||||
public List<Suggestion> suggestDiscussions(String meetingId, String transcriptText) {
|
||||
@ -66,4 +86,202 @@ public class SuggestionService implements SuggestionUseCase {
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<RealtimeSuggestionsDto> streamRealtimeSuggestions(String meetingId) {
|
||||
log.info("실시간 AI 제안사항 스트리밍 시작 - meetingId: {}", meetingId);
|
||||
|
||||
// Sink 생성 및 등록 (멀티캐스트 - 여러 클라이언트 동시 지원)
|
||||
Sinks.Many<RealtimeSuggestionsDto> sink = Sinks.many()
|
||||
.multicast()
|
||||
.onBackpressureBuffer();
|
||||
|
||||
meetingSinks.put(meetingId, sink);
|
||||
|
||||
// TODO: AI 개발 완료 후 제거 - 개발 중 프론트엔드 테스트를 위한 Mock 데이터 자동 발행
|
||||
startMockDataEmission(meetingId, sink);
|
||||
|
||||
return sink.asFlux()
|
||||
.doOnCancel(() -> {
|
||||
log.info("SSE 스트림 종료 - meetingId: {}", meetingId);
|
||||
meetingSinks.remove(meetingId);
|
||||
cleanupMeetingData(meetingId);
|
||||
})
|
||||
.doOnError(error ->
|
||||
log.error("AI 제안사항 스트리밍 오류 - meetingId: {}", meetingId, error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Event Hub에서 수신한 실시간 텍스트 처리
|
||||
* STT Service에서 TranscriptSegmentReady 이벤트를 받아 처리
|
||||
*
|
||||
* @param meetingId 회의 ID
|
||||
* @param text 변환된 텍스트 세그먼트
|
||||
* @param timestamp 타임스탬프 (ms)
|
||||
*/
|
||||
public void processRealtimeTranscript(String meetingId, String text, Long timestamp) {
|
||||
try {
|
||||
// 1. Redis에 실시간 텍스트 축적 (슬라이딩 윈도우: 최근 5분)
|
||||
String key = "meeting:" + meetingId + ":transcript";
|
||||
String value = timestamp + ":" + text;
|
||||
|
||||
redisTemplate.opsForZSet().add(key, value, timestamp.doubleValue());
|
||||
|
||||
// 5분 이전 데이터 제거
|
||||
long fiveMinutesAgo = System.currentTimeMillis() - TEXT_RETENTION_MS;
|
||||
redisTemplate.opsForZSet().removeRangeByScore(key, 0, fiveMinutesAgo);
|
||||
|
||||
// 2. 누적 텍스트가 임계값 이상이면 AI 분석
|
||||
Long segmentCount = redisTemplate.opsForZSet().size(key);
|
||||
if (segmentCount != null && segmentCount >= MIN_SEGMENTS_FOR_ANALYSIS) {
|
||||
analyzeAndEmitSuggestions(meetingId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("실시간 텍스트 처리 실패 - meetingId: {}", meetingId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석 및 SSE 발행
|
||||
*/
|
||||
private void analyzeAndEmitSuggestions(String meetingId) {
|
||||
// Redis에서 최근 5분 텍스트 조회
|
||||
String key = "meeting:" + meetingId + ":transcript";
|
||||
Set<String> recentTexts = redisTemplate.opsForZSet().reverseRange(key, 0, -1);
|
||||
|
||||
if (recentTexts == null || recentTexts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 타임스탬프 제거 및 텍스트만 추출
|
||||
String accumulatedText = recentTexts.stream()
|
||||
.map(entry -> entry.split(":", 2)[1])
|
||||
.collect(Collectors.joining("\n"));
|
||||
|
||||
// Claude API 분석 (비동기)
|
||||
claudeApiClient.analyzeSuggestions(accumulatedText)
|
||||
.subscribe(
|
||||
suggestions -> {
|
||||
// SSE 스트림으로 전송
|
||||
Sinks.Many<RealtimeSuggestionsDto> sink = meetingSinks.get(meetingId);
|
||||
if (sink != null) {
|
||||
sink.tryEmitNext(suggestions);
|
||||
log.info("AI 제안사항 발행 완료 - meetingId: {}, 제안사항: {}개",
|
||||
meetingId,
|
||||
suggestions.getSuggestions().size());
|
||||
}
|
||||
},
|
||||
error -> log.error("Claude API 분석 실패 - meetingId: {}", meetingId, error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의 종료 시 데이터 정리
|
||||
*/
|
||||
private void cleanupMeetingData(String meetingId) {
|
||||
String key = "meeting:" + meetingId + ":transcript";
|
||||
redisTemplate.delete(key);
|
||||
log.info("회의 데이터 정리 완료 - meetingId: {}", meetingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: AI 개발 완료 후 제거
|
||||
* Mock 데이터 자동 발행 (프론트엔드 개발용)
|
||||
* 5초마다 샘플 제안사항을 발행합니다.
|
||||
*/
|
||||
private void startMockDataEmission(String meetingId, Sinks.Many<RealtimeSuggestionsDto> sink) {
|
||||
log.info("Mock 데이터 자동 발행 시작 - meetingId: {}", meetingId);
|
||||
|
||||
// 프론트엔드 HTML에 맞춘 샘플 데이터 (3개)
|
||||
List<SimpleSuggestionDto> mockSuggestions = List.of(
|
||||
SimpleSuggestionDto.builder()
|
||||
.id("suggestion-1")
|
||||
.content("신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.")
|
||||
.timestamp("00:05:23")
|
||||
.confidence(0.92)
|
||||
.build(),
|
||||
SimpleSuggestionDto.builder()
|
||||
.id("suggestion-2")
|
||||
.content("개발 일정: 1차 프로토타입은 11월 15일까지 완성, 2차 베타는 12월 1일 론칭")
|
||||
.timestamp("00:08:45")
|
||||
.confidence(0.88)
|
||||
.build(),
|
||||
SimpleSuggestionDto.builder()
|
||||
.id("suggestion-3")
|
||||
.content("마케팅 예산 배분에 대해 SNS 광고 60%, 인플루언서 마케팅 40%로 의견이 나왔으나 추가 검토 필요")
|
||||
.timestamp("00:12:18")
|
||||
.confidence(0.85)
|
||||
.build()
|
||||
);
|
||||
|
||||
// 5초마다 하나씩 발행 (총 3개)
|
||||
Flux.interval(Duration.ofSeconds(5))
|
||||
.take(3)
|
||||
.map(index -> {
|
||||
SimpleSuggestionDto suggestion = mockSuggestions.get(index.intValue());
|
||||
return RealtimeSuggestionsDto.builder()
|
||||
.suggestions(List.of(suggestion))
|
||||
.build();
|
||||
})
|
||||
.subscribe(
|
||||
suggestions -> {
|
||||
sink.tryEmitNext(suggestions);
|
||||
log.info("Mock 제안사항 발행 - meetingId: {}, 제안: {}",
|
||||
meetingId,
|
||||
suggestions.getSuggestions().get(0).getContent());
|
||||
},
|
||||
error -> log.error("Mock 데이터 발행 오류 - meetingId: {}", meetingId, error),
|
||||
() -> log.info("Mock 데이터 발행 완료 - meetingId: {}", meetingId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 AI 제안사항 생성 (Mock) - 간소화 버전
|
||||
* 실제로는 STT 텍스트를 분석하여 AI가 제안사항을 생성
|
||||
*
|
||||
* @param meetingId 회의 ID
|
||||
* @param sequence 시퀀스 번호
|
||||
* @return RealtimeSuggestionsDto AI 제안사항
|
||||
*/
|
||||
private RealtimeSuggestionsDto generateRealtimeSuggestions(String meetingId, Long sequence) {
|
||||
// Mock 데이터 - 실제로는 LLM을 통해 STT 텍스트 분석 후 생성
|
||||
List<SimpleSuggestionDto> suggestions = List.of(
|
||||
SimpleSuggestionDto.builder()
|
||||
.id("sugg-" + sequence)
|
||||
.content(getMockSuggestionContent(sequence))
|
||||
.timestamp(getCurrentTimestamp())
|
||||
.confidence(0.85 + (sequence % 15) * 0.01)
|
||||
.build()
|
||||
);
|
||||
|
||||
return RealtimeSuggestionsDto.builder()
|
||||
.suggestions(suggestions)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 제안사항 내용 생성
|
||||
*/
|
||||
private String getMockSuggestionContent(Long sequence) {
|
||||
String[] suggestions = {
|
||||
"신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.",
|
||||
"개발 일정: 1차 프로토타입은 11월 15일까지 완성, 2차 베타는 12월 1일 론칭",
|
||||
"마케팅 예산 배분에 대해 SNS 광고 60%, 인플루언서 마케팅 40%로 의견이 나왔으나 추가 검토 필요",
|
||||
"보안 요구사항 검토가 필요하며, 데이터 암호화 방식에 대한 논의가 진행 중입니다.",
|
||||
"React로 프론트엔드 개발하기로 결정되었으며, TypeScript 사용을 권장합니다.",
|
||||
"데이터베이스는 PostgreSQL을 메인으로 사용하고, Redis를 캐시로 활용하기로 했습니다."
|
||||
};
|
||||
return suggestions[(int) (sequence % suggestions.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 타임스탬프 생성 (HH:MM:SS 형식)
|
||||
*/
|
||||
private String getCurrentTimestamp() {
|
||||
java.time.LocalTime now = java.time.LocalTime.now();
|
||||
return String.format("%02d:%02d:%02d",
|
||||
now.getHour(),
|
||||
now.getMinute(),
|
||||
now.getSecond());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.unicorn.hgzero.ai.biz.usecase;
|
||||
|
||||
import com.unicorn.hgzero.ai.biz.domain.Suggestion;
|
||||
import com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -27,4 +29,13 @@ public interface SuggestionUseCase {
|
||||
* @return 결정사항 제안 목록
|
||||
*/
|
||||
List<Suggestion> suggestDecisions(String meetingId, String transcriptText);
|
||||
|
||||
/**
|
||||
* 실시간 AI 제안사항 스트리밍
|
||||
* 회의 진행 중 실시간으로 논의사항과 결정사항을 분석하여 제안
|
||||
*
|
||||
* @param meetingId 회의 ID
|
||||
* @return 실시간 제안사항 스트림
|
||||
*/
|
||||
Flux<RealtimeSuggestionsDto> streamRealtimeSuggestions(String meetingId);
|
||||
}
|
||||
|
||||
@ -0,0 +1,171 @@
|
||||
package com.unicorn.hgzero.ai.infra.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unicorn.hgzero.ai.infra.config.ClaudeConfig;
|
||||
import com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto;
|
||||
import com.unicorn.hgzero.ai.infra.dto.common.SimpleSuggestionDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Claude API 클라이언트
|
||||
* Anthropic Claude API를 호출하여 AI 제안사항 생성
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ClaudeApiClient {
|
||||
|
||||
private final ClaudeConfig claudeConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WebClient webClient;
|
||||
|
||||
/**
|
||||
* 실시간 AI 제안사항 분석 (간소화 버전)
|
||||
*
|
||||
* @param transcriptText 누적된 회의록 텍스트
|
||||
* @return AI 제안사항 (논의사항과 결정사항 통합)
|
||||
*/
|
||||
public Mono<RealtimeSuggestionsDto> analyzeSuggestions(String transcriptText) {
|
||||
log.debug("Claude API 호출 - 텍스트 길이: {}", transcriptText.length());
|
||||
|
||||
String systemPrompt = """
|
||||
당신은 회의록 작성 전문 AI 어시스턴트입니다.
|
||||
|
||||
실시간 회의 텍스트를 분석하여 **중요한 제안사항만** 추출하세요.
|
||||
|
||||
**추출 기준**:
|
||||
- 회의 안건과 직접 관련된 내용
|
||||
- 논의가 필요한 주제
|
||||
- 결정된 사항
|
||||
- 액션 아이템
|
||||
|
||||
**제외할 내용**:
|
||||
- 잡담, 농담, 인사말
|
||||
- 회의와 무관한 대화
|
||||
- 단순 확인이나 질의응답
|
||||
|
||||
**응답 형식**: JSON만 반환 (다른 설명 없이)
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"content": "구체적인 제안 내용 (1-2문장으로 명확하게)",
|
||||
"confidence": 0.9
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**주의**:
|
||||
- 각 제안은 독립적이고 명확해야 함
|
||||
- 회의 맥락에서 실제 중요한 내용만 포함
|
||||
- confidence는 0-1 사이 값 (확신 정도)
|
||||
""";
|
||||
|
||||
String userPrompt = String.format("""
|
||||
다음 회의 내용을 분석해주세요:
|
||||
|
||||
%s
|
||||
""", transcriptText);
|
||||
|
||||
// Claude API 요청 페이로드
|
||||
Map<String, Object> requestBody = Map.of(
|
||||
"model", claudeConfig.getModel(),
|
||||
"max_tokens", claudeConfig.getMaxTokens(),
|
||||
"temperature", claudeConfig.getTemperature(),
|
||||
"system", systemPrompt,
|
||||
"messages", List.of(
|
||||
Map.of(
|
||||
"role", "user",
|
||||
"content", userPrompt
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return webClient.post()
|
||||
.uri(claudeConfig.getBaseUrl() + "/v1/messages")
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
.header("x-api-key", claudeConfig.getApiKey())
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.bodyValue(requestBody)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.map(this::parseClaudeResponse)
|
||||
.doOnSuccess(result -> log.info("Claude API 응답 성공 - 제안사항: {}개",
|
||||
result.getSuggestions().size()))
|
||||
.doOnError(error -> log.error("Claude API 호출 실패", error))
|
||||
.onErrorResume(error -> Mono.just(RealtimeSuggestionsDto.builder()
|
||||
.suggestions(new ArrayList<>())
|
||||
.build()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude API 응답 파싱 (간소화 버전)
|
||||
*/
|
||||
private RealtimeSuggestionsDto parseClaudeResponse(String responseBody) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(responseBody);
|
||||
|
||||
// Claude 응답 구조: { "content": [ { "text": "..." } ] }
|
||||
String contentText = root.path("content").get(0).path("text").asText();
|
||||
|
||||
// JSON 부분만 추출 (코드 블록 제거)
|
||||
String jsonText = extractJson(contentText);
|
||||
|
||||
JsonNode suggestionsJson = objectMapper.readTree(jsonText);
|
||||
|
||||
// 제안사항 파싱
|
||||
List<SimpleSuggestionDto> suggestions = new ArrayList<>();
|
||||
JsonNode suggestionsNode = suggestionsJson.path("suggestions");
|
||||
if (suggestionsNode.isArray()) {
|
||||
for (JsonNode node : suggestionsNode) {
|
||||
suggestions.add(SimpleSuggestionDto.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.content(node.path("content").asText())
|
||||
.confidence(node.path("confidence").asDouble(0.8))
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
return RealtimeSuggestionsDto.builder()
|
||||
.suggestions(suggestions)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Claude 응답 파싱 실패", e);
|
||||
return RealtimeSuggestionsDto.builder()
|
||||
.suggestions(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답에서 JSON 부분만 추출
|
||||
* Claude가 마크다운 코드 블록으로 감싼 경우 처리
|
||||
*/
|
||||
private String extractJson(String text) {
|
||||
// ```json ... ``` 형식 제거
|
||||
if (text.contains("```json")) {
|
||||
int start = text.indexOf("```json") + 7;
|
||||
int end = text.lastIndexOf("```");
|
||||
return text.substring(start, end).trim();
|
||||
}
|
||||
// ``` ... ``` 형식 제거
|
||||
else if (text.contains("```")) {
|
||||
int start = text.indexOf("```") + 3;
|
||||
int end = text.lastIndexOf("```");
|
||||
return text.substring(start, end).trim();
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package com.unicorn.hgzero.ai.infra.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Claude API 설정
|
||||
*/
|
||||
@Configuration
|
||||
@Getter
|
||||
public class ClaudeConfig {
|
||||
|
||||
@Value("${external.ai.claude.api-key}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${external.ai.claude.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${external.ai.claude.model}")
|
||||
private String model;
|
||||
|
||||
@Value("${external.ai.claude.max-tokens}")
|
||||
private Integer maxTokens;
|
||||
|
||||
@Value("${external.ai.claude.temperature}")
|
||||
private Double temperature;
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
package com.unicorn.hgzero.ai.infra.config;
|
||||
|
||||
import com.azure.messaging.eventhubs.EventProcessorClient;
|
||||
import com.azure.messaging.eventhubs.EventProcessorClientBuilder;
|
||||
import com.azure.messaging.eventhubs.checkpointstore.blob.BlobCheckpointStore;
|
||||
import com.azure.messaging.eventhubs.models.ErrorContext;
|
||||
import com.azure.messaging.eventhubs.models.EventContext;
|
||||
import com.azure.storage.blob.BlobContainerAsyncClient;
|
||||
import com.azure.storage.blob.BlobContainerClientBuilder;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unicorn.hgzero.ai.biz.service.SuggestionService;
|
||||
import com.unicorn.hgzero.ai.infra.event.TranscriptSegmentReadyEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
/**
|
||||
* Azure Event Hub 설정
|
||||
* STT Service의 TranscriptSegmentReady 이벤트 구독
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class EventHubConfig {
|
||||
|
||||
private final SuggestionService suggestionService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${external.eventhub.connection-string}")
|
||||
private String connectionString;
|
||||
|
||||
@Value("${external.eventhub.eventhub-name}")
|
||||
private String eventHubName;
|
||||
|
||||
@Value("${external.eventhub.consumer-group.transcript}")
|
||||
private String consumerGroup;
|
||||
|
||||
@Value("${external.eventhub.checkpoint-storage-connection-string:}")
|
||||
private String checkpointStorageConnectionString;
|
||||
|
||||
@Value("${external.eventhub.checkpoint-container}")
|
||||
private String checkpointContainer;
|
||||
|
||||
private EventProcessorClient eventProcessorClient;
|
||||
|
||||
@PostConstruct
|
||||
public void startEventProcessor() {
|
||||
log.info("Event Hub Processor 시작 - eventhub: {}, consumerGroup: {}",
|
||||
eventHubName, consumerGroup);
|
||||
|
||||
EventProcessorClientBuilder builder = new EventProcessorClientBuilder()
|
||||
.connectionString(connectionString, eventHubName)
|
||||
.consumerGroup(consumerGroup)
|
||||
.processEvent(this::processEvent)
|
||||
.processError(this::processError);
|
||||
|
||||
// Checkpoint Storage 설정
|
||||
if (checkpointStorageConnectionString != null && !checkpointStorageConnectionString.isEmpty()) {
|
||||
log.info("Checkpoint Storage 활성화 (Azure Blob) - container: {}", checkpointContainer);
|
||||
BlobContainerAsyncClient blobContainerAsyncClient = new BlobContainerClientBuilder()
|
||||
.connectionString(checkpointStorageConnectionString)
|
||||
.containerName(checkpointContainer)
|
||||
.buildAsyncClient();
|
||||
builder.checkpointStore(new BlobCheckpointStore(blobContainerAsyncClient));
|
||||
} else {
|
||||
log.warn("⚠️ Checkpoint Storage 미설정 - 체크포인트 저장 안 함 (재시작 시 처음부터 읽음)");
|
||||
log.warn("⚠️ 프로덕션 환경에서는 AZURE_BLOB_CONNECTION_STRING 설정 필요");
|
||||
// Checkpoint Store 없이 실행 (재시작 시 처음부터 읽음)
|
||||
}
|
||||
|
||||
eventProcessorClient = builder.buildEventProcessorClient();
|
||||
eventProcessorClient.start();
|
||||
|
||||
log.info("Event Hub Processor 시작 완료");
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stopEventProcessor() {
|
||||
if (eventProcessorClient != null) {
|
||||
log.info("Event Hub Processor 종료");
|
||||
eventProcessorClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 처리 핸들러
|
||||
*/
|
||||
private void processEvent(EventContext eventContext) {
|
||||
try {
|
||||
String eventData = eventContext.getEventData().getBodyAsString();
|
||||
log.debug("이벤트 수신: {}", eventData);
|
||||
|
||||
// JSON 역직렬화
|
||||
TranscriptSegmentReadyEvent event = objectMapper.readValue(
|
||||
eventData,
|
||||
TranscriptSegmentReadyEvent.class
|
||||
);
|
||||
|
||||
log.info("실시간 텍스트 수신 - meetingId: {}, text: {}",
|
||||
event.getMeetingId(), event.getText());
|
||||
|
||||
// SuggestionService로 전달하여 AI 분석 트리거
|
||||
suggestionService.processRealtimeTranscript(
|
||||
event.getMeetingId(),
|
||||
event.getText(),
|
||||
event.getTimestamp()
|
||||
);
|
||||
|
||||
// 체크포인트 업데이트
|
||||
eventContext.updateCheckpoint();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("이벤트 처리 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리 핸들러
|
||||
*/
|
||||
private void processError(ErrorContext errorContext) {
|
||||
log.error("Event Hub 에러 - partition: {}, error: {}",
|
||||
errorContext.getPartitionContext().getPartitionId(),
|
||||
errorContext.getThrowable().getMessage(),
|
||||
errorContext.getThrowable());
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,5 @@
|
||||
package com.unicorn.hgzero.ai.infra.config;
|
||||
|
||||
import com.unicorn.hgzero.common.security.JwtTokenProvider;
|
||||
import com.unicorn.hgzero.common.security.filter.JwtAuthenticationFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@ -11,7 +8,6 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
@ -20,15 +16,12 @@ import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Spring Security 설정
|
||||
* JWT 기반 인증 및 API 보안 설정
|
||||
* CORS 설정 및 API 보안 설정 (인증 없음)
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Value("${cors.allowed-origins:http://localhost:3000,http://localhost:8080,http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@ -39,17 +32,9 @@ public class SecurityConfig {
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Actuator endpoints
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
// Swagger UI endpoints - context path와 상관없이 접근 가능하도록 설정
|
||||
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
|
||||
// Health check
|
||||
.requestMatchers("/health").permitAll()
|
||||
// All other requests require authentication
|
||||
.anyRequest().authenticated()
|
||||
// 모든 요청 허용 (인증 없음)
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
|
||||
UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
package com.unicorn.hgzero.ai.infra.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* WebClient 설정
|
||||
* 외부 API 호출을 위한 WebClient 빈 생성
|
||||
*/
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
|
||||
@Bean
|
||||
public WebClient webClient() {
|
||||
return WebClient.builder()
|
||||
.codecs(configurer -> configurer
|
||||
.defaultCodecs()
|
||||
.maxInMemorySize(10 * 1024 * 1024)) // 10MB
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -52,6 +52,7 @@ public class RelationController {
|
||||
.participants(r.getParticipants())
|
||||
.relevanceScore(r.getRelevanceScore())
|
||||
.commonKeywords(r.getCommonKeywords())
|
||||
.summary(r.getSummary())
|
||||
.link(r.getLink())
|
||||
.build())
|
||||
.collect(Collectors.toList()))
|
||||
|
||||
@ -10,12 +10,16 @@ import com.unicorn.hgzero.ai.infra.dto.common.DiscussionSuggestionDto;
|
||||
import com.unicorn.hgzero.ai.infra.dto.common.DecisionSuggestionDto;
|
||||
import com.unicorn.hgzero.common.dto.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@ -96,4 +100,33 @@ public class SuggestionController {
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 AI 제안사항 스트리밍 (SSE)
|
||||
* 회의 진행 중 실시간으로 AI가 분석한 제안사항을 Server-Sent Events로 스트리밍
|
||||
*
|
||||
* @param meetingId 회의 ID
|
||||
* @return Flux<ServerSentEvent<RealtimeSuggestionsDto>> AI 제안사항 스트림
|
||||
*/
|
||||
@GetMapping(value = "/meetings/{meetingId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
@Operation(
|
||||
summary = "실시간 AI 제안사항 스트리밍",
|
||||
description = "회의 진행 중 실시간으로 AI가 분석한 제안사항을 Server-Sent Events(SSE)로 스트리밍합니다. " +
|
||||
"클라이언트는 EventSource API를 사용하여 연결할 수 있습니다."
|
||||
)
|
||||
public Flux<ServerSentEvent<com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto>> streamRealtimeSuggestions(
|
||||
@Parameter(description = "회의 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440000")
|
||||
@PathVariable String meetingId) {
|
||||
|
||||
log.info("실시간 AI 제안사항 스트리밍 요청 - meetingId: {}", meetingId);
|
||||
|
||||
return suggestionUseCase.streamRealtimeSuggestions(meetingId)
|
||||
.map(suggestions -> ServerSentEvent.<com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto>builder()
|
||||
.id(suggestions.hashCode() + "")
|
||||
.event("ai-suggestion")
|
||||
.data(suggestions)
|
||||
.build())
|
||||
.doOnComplete(() -> log.info("AI 제안사항 스트리밍 완료 - meetingId: {}", meetingId))
|
||||
.doOnError(error -> log.error("AI 제안사항 스트리밍 오류 - meetingId: {}", meetingId, error));
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,6 +55,9 @@ public class TermController {
|
||||
.build() : null)
|
||||
.confidence(t.getConfidence())
|
||||
.category(t.getCategory())
|
||||
.definition(t.getDefinition())
|
||||
.context(t.getContext())
|
||||
.relatedMeetingId(t.getRelatedMeetingId())
|
||||
.highlight(t.getHighlight())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@ -31,10 +31,26 @@ public class DetectedTermDto {
|
||||
private Double confidence;
|
||||
|
||||
/**
|
||||
* 용어 카테고리 (기술, 업무, 도메인)
|
||||
* 용어 카테고리 (기술, 업무, 도메인, 기획, 비즈니스, 전략, 마케팅)
|
||||
*/
|
||||
private String category;
|
||||
|
||||
/**
|
||||
* 용어 정의 (간단한 설명)
|
||||
*/
|
||||
private String definition;
|
||||
|
||||
/**
|
||||
* 용어가 사용된 맥락 (과거 회의록 참조)
|
||||
* 예: "신제품 기획 회의(2024-09-15)에서 언급"
|
||||
*/
|
||||
private String context;
|
||||
|
||||
/**
|
||||
* 관련 회의 ID (용어가 논의된 과거 회의)
|
||||
*/
|
||||
private String relatedMeetingId;
|
||||
|
||||
/**
|
||||
* 하이라이트 여부
|
||||
*/
|
||||
|
||||
@ -8,8 +8,8 @@ import lombok.NoArgsConstructor;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 실시간 추천사항 DTO
|
||||
* 논의 주제와 결정사항 제안을 포함
|
||||
* 실시간 추천사항 DTO (간소화 버전)
|
||||
* 논의사항과 결정사항을 구분하지 않고 통합 제공
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@ -18,12 +18,7 @@ import java.util.List;
|
||||
public class RealtimeSuggestionsDto {
|
||||
|
||||
/**
|
||||
* 논의 주제 제안 목록
|
||||
* AI 제안사항 목록 (논의사항 + 결정사항 통합)
|
||||
*/
|
||||
private List<DiscussionSuggestionDto> discussionTopics;
|
||||
|
||||
/**
|
||||
* 결정사항 제안 목록
|
||||
*/
|
||||
private List<DecisionSuggestionDto> decisions;
|
||||
private List<SimpleSuggestionDto> suggestions;
|
||||
}
|
||||
|
||||
@ -48,6 +48,11 @@ public class RelatedTranscriptDto {
|
||||
*/
|
||||
private List<String> commonKeywords;
|
||||
|
||||
/**
|
||||
* 회의록 핵심 내용 요약 (1-2문장)
|
||||
*/
|
||||
private String summary;
|
||||
|
||||
/**
|
||||
* 회의록 링크
|
||||
*/
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
package com.unicorn.hgzero.ai.infra.dto.common;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 간소화된 AI 제안사항 DTO
|
||||
* 논의사항과 결정사항을 구분하지 않고 통합 제공
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SimpleSuggestionDto {
|
||||
|
||||
/**
|
||||
* 제안 ID
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 제안 내용 (논의사항 또는 결정사항)
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 타임스탬프 (초 단위, 예: 00:05:23)
|
||||
*/
|
||||
private String timestamp;
|
||||
|
||||
/**
|
||||
* 신뢰도 점수 (0-1)
|
||||
*/
|
||||
private Double confidence;
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package com.unicorn.hgzero.ai.infra.event;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* STT Service에서 발행하는 음성 변환 세그먼트 이벤트
|
||||
* Azure Event Hub를 통해 전달됨
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TranscriptSegmentReadyEvent {
|
||||
|
||||
/**
|
||||
* 녹음 ID
|
||||
*/
|
||||
private String recordingId;
|
||||
|
||||
/**
|
||||
* 회의 ID
|
||||
*/
|
||||
private String meetingId;
|
||||
|
||||
/**
|
||||
* 변환 텍스트 세그먼트 ID
|
||||
*/
|
||||
private String transcriptId;
|
||||
|
||||
/**
|
||||
* 변환된 텍스트
|
||||
*/
|
||||
private String text;
|
||||
|
||||
/**
|
||||
* 타임스탬프 (ms)
|
||||
*/
|
||||
private Long timestamp;
|
||||
|
||||
/**
|
||||
* 신뢰도 점수 (0-1)
|
||||
*/
|
||||
private Double confidence;
|
||||
|
||||
/**
|
||||
* 이벤트 발생 시간
|
||||
*/
|
||||
private String eventTime;
|
||||
}
|
||||
@ -26,6 +26,10 @@ spring:
|
||||
hibernate:
|
||||
ddl-auto: ${DDL_AUTO:update}
|
||||
|
||||
# Flyway Configuration
|
||||
flyway:
|
||||
enabled: false
|
||||
|
||||
# Redis Configuration
|
||||
data:
|
||||
redis:
|
||||
@ -73,6 +77,9 @@ external:
|
||||
claude:
|
||||
api-key: ${CLAUDE_API_KEY:}
|
||||
base-url: ${CLAUDE_BASE_URL:https://api.anthropic.com}
|
||||
model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
|
||||
max-tokens: ${CLAUDE_MAX_TOKENS:2000}
|
||||
temperature: ${CLAUDE_TEMPERATURE:0.3}
|
||||
openai:
|
||||
api-key: ${OPENAI_API_KEY:}
|
||||
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
|
||||
@ -146,3 +153,6 @@ logging:
|
||||
max-file-size: ${LOG_MAX_FILE_SIZE:10MB}
|
||||
max-history: ${LOG_MAX_HISTORY:7}
|
||||
total-size-cap: ${LOG_TOTAL_SIZE_CAP:100MB}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ subprojects {
|
||||
hypersistenceVersion = '3.7.3'
|
||||
openaiVersion = '0.18.2'
|
||||
feignJacksonVersion = '13.1'
|
||||
azureSpeechVersion = '1.37.0'
|
||||
azureSpeechVersion = '1.44.0'
|
||||
azureBlobVersion = '12.25.3'
|
||||
azureEventHubsVersion = '5.18.2'
|
||||
azureEventHubsCheckpointVersion = '1.19.2'
|
||||
|
||||
File diff suppressed because one or more lines are too long
392
claude/MEETING-AI-TEST-GUIDE.md
Normal file
392
claude/MEETING-AI-TEST-GUIDE.md
Normal file
@ -0,0 +1,392 @@
|
||||
# Meeting AI 통합 실행 및 테스트 가이드
|
||||
|
||||
작성일: 2025-10-28
|
||||
작성자: 이동욱 (Backend Developer)
|
||||
|
||||
## 📋 목차
|
||||
1. [사전 준비](#사전-준비)
|
||||
2. [AI Python Service 실행](#ai-python-service-실행)
|
||||
3. [Meeting Service 실행](#meeting-service-실행)
|
||||
4. [통합 테스트](#통합-테스트)
|
||||
5. [트러블슈팅](#트러블슈팅)
|
||||
|
||||
---
|
||||
|
||||
## 사전 준비
|
||||
|
||||
### 1. 포트 확인
|
||||
```bash
|
||||
# 포트 사용 확인
|
||||
lsof -i :8082 # Meeting Service
|
||||
lsof -i :8087 # AI Python Service
|
||||
```
|
||||
|
||||
### 2. 데이터베이스 확인
|
||||
```sql
|
||||
-- PostgreSQL 연결 확인
|
||||
psql -h 4.230.48.72 -U hgzerouser -d meetingdb
|
||||
|
||||
-- 필요한 테이블 확인
|
||||
\dt meeting_analysis
|
||||
\dt todos
|
||||
\dt meetings
|
||||
\dt agenda_sections
|
||||
```
|
||||
|
||||
### 3. Redis 확인
|
||||
```bash
|
||||
# Redis 연결 테스트
|
||||
redis-cli -h 20.249.177.114 -p 6379 -a Hi5Jessica! ping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI Python Service 실행
|
||||
|
||||
### 1. 디렉토리 이동
|
||||
```bash
|
||||
cd /Users/jominseo/HGZero/ai-python
|
||||
```
|
||||
|
||||
### 2. 환경 변수 확인
|
||||
```bash
|
||||
# .env 파일 확인 (없으면 .env.example에서 복사)
|
||||
cat .env
|
||||
|
||||
# 필수 환경 변수:
|
||||
# - PORT=8087
|
||||
# - CLAUDE_API_KEY=sk-ant-api03-...
|
||||
# - REDIS_HOST=20.249.177.114
|
||||
# - REDIS_PORT=6379
|
||||
```
|
||||
|
||||
### 3. 의존성 설치
|
||||
```bash
|
||||
# Python 가상환경 활성화 (선택사항)
|
||||
source venv/bin/activate # 또는 python3 -m venv venv
|
||||
|
||||
# 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. 서비스 실행
|
||||
```bash
|
||||
# 방법 1: 직접 실행
|
||||
python3 main.py
|
||||
|
||||
# 방법 2: uvicorn으로 실행
|
||||
uvicorn main:app --host 0.0.0.0 --port 8087 --reload
|
||||
|
||||
# 방법 3: 백그라운드 실행
|
||||
nohup python3 main.py > logs/ai-python.log 2>&1 & echo "Started AI Python Service with PID: $!"
|
||||
```
|
||||
|
||||
### 5. 상태 확인
|
||||
```bash
|
||||
# Health Check
|
||||
curl http://localhost:8087/health
|
||||
|
||||
# 기대 응답:
|
||||
# {"status":"healthy","service":"AI Service (Python)"}
|
||||
|
||||
# API 문서 확인
|
||||
open http://localhost:8087/docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Meeting Service 실행
|
||||
|
||||
### 1. 디렉토리 이동
|
||||
```bash
|
||||
cd /Users/jominseo/HGZero
|
||||
```
|
||||
|
||||
### 2. 빌드
|
||||
```bash
|
||||
# Java 21 사용
|
||||
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home
|
||||
|
||||
# 빌드
|
||||
./gradlew :meeting:clean :meeting:build -x test
|
||||
```
|
||||
|
||||
### 3. 실행
|
||||
```bash
|
||||
# 방법 1: Gradle로 실행
|
||||
./gradlew :meeting:bootRun
|
||||
|
||||
# 방법 2: JAR 실행
|
||||
java -jar meeting/build/libs/meeting-0.0.1-SNAPSHOT.jar
|
||||
|
||||
# 방법 3: IntelliJ 실행 프로파일 사용
|
||||
python3 tools/run-intellij-service-profile.py meeting
|
||||
```
|
||||
|
||||
### 4. 상태 확인
|
||||
```bash
|
||||
# Health Check
|
||||
curl http://localhost:8082/actuator/health
|
||||
|
||||
# Swagger UI
|
||||
open http://localhost:8082/swagger-ui.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 통합 테스트
|
||||
|
||||
### 테스트 시나리오
|
||||
|
||||
#### 1. 회의 생성 (사전 작업)
|
||||
```bash
|
||||
curl -X POST http://localhost:8082/api/meetings \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-User-Id: user123" \
|
||||
-H "X-User-Name: 홍길동" \
|
||||
-H "X-User-Email: hong@example.com" \
|
||||
-d '{
|
||||
"title": "AI 통합 테스트 회의",
|
||||
"purpose": "Meeting AI 기능 테스트",
|
||||
"scheduledAt": "2025-10-28T14:00:00",
|
||||
"endTime": "2025-10-28T15:00:00",
|
||||
"location": "회의실 A",
|
||||
"participantIds": ["user123", "user456"]
|
||||
}'
|
||||
```
|
||||
|
||||
응답에서 `meetingId` 저장
|
||||
|
||||
|
||||
#### 2. 회의 시작
|
||||
```bash
|
||||
MEETING_ID="위에서 받은 meetingId"
|
||||
|
||||
curl -X POST http://localhost:8082/api/meetings/${MEETING_ID}/start \
|
||||
-H "X-User-Id: user123" \
|
||||
-H "X-User-Name: 홍길동" \
|
||||
-H "X-User-Email: hong@example.com"
|
||||
```
|
||||
|
||||
|
||||
#### 3. 안건 섹션 생성 (테스트 데이터)
|
||||
```sql
|
||||
-- PostgreSQL에서 직접 실행
|
||||
INSERT INTO agenda_sections (
|
||||
id, minutes_id, meeting_id, agenda_number, agenda_title,
|
||||
ai_summary_short, discussions,
|
||||
decisions, pending_items, opinions, todos,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
'agenda-001', 'minutes-001', '위의_meetingId', 1, '신제품 기획',
|
||||
NULL,
|
||||
'타겟 고객층을 20-30대로 설정하고 UI/UX 개선에 집중하기로 논의했습니다.',
|
||||
'["타겟 고객: 20-30대 직장인", "UI/UX 개선 최우선"]'::json,
|
||||
'["가격 정책 추가 검토 필요"]'::json,
|
||||
'[]'::json,
|
||||
'[]'::json,
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
'agenda-002', 'minutes-001', '위의_meetingId', 2, '마케팅 전략',
|
||||
NULL,
|
||||
'SNS 마케팅과 인플루언서 협업을 통한 브랜드 인지도 제고 방안을 논의했습니다.',
|
||||
'["SNS 광고 집행", "인플루언서 3명과 계약"]'::json,
|
||||
'["예산 승인 대기"]'::json,
|
||||
'[]'::json,
|
||||
'[]'::json,
|
||||
NOW(), NOW()
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
#### 4. **핵심 테스트: 회의 종료 API 호출**
|
||||
```bash
|
||||
curl -X POST http://localhost:8082/api/meetings/${MEETING_ID}/end \
|
||||
-H "X-User-Id: user123" \
|
||||
-H "X-User-Name: 홍길동" \
|
||||
-H "X-User-Email: hong@example.com" \
|
||||
-v
|
||||
```
|
||||
|
||||
**기대 응답:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"title": "AI 통합 테스트 회의",
|
||||
"participantCount": 2,
|
||||
"durationMinutes": 60,
|
||||
"agendaCount": 2,
|
||||
"todoCount": 5,
|
||||
"keywords": ["신제품", "UI/UX", "마케팅", "SNS", "인플루언서"],
|
||||
"agendaSummaries": [
|
||||
{
|
||||
"title": "안건 1: 신제품 기획",
|
||||
"aiSummaryShort": "타겟 고객 설정 및 UI/UX 개선 방향 논의",
|
||||
"details": {
|
||||
"discussion": "타겟 고객층을 20-30대로 설정...",
|
||||
"decisions": ["타겟 고객: 20-30대 직장인", "UI/UX 개선 최우선"],
|
||||
"pending": ["가격 정책 추가 검토 필요"]
|
||||
},
|
||||
"todos": [
|
||||
{"title": "시장 조사 보고서 작성"},
|
||||
{"title": "UI/UX 개선안 프로토타입 제작"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "안건 2: 마케팅 전략",
|
||||
"aiSummaryShort": "SNS 마케팅 및 인플루언서 협업 계획",
|
||||
"details": {
|
||||
"discussion": "SNS 마케팅과 인플루언서 협업...",
|
||||
"decisions": ["SNS 광고 집행", "인플루언서 3명과 계약"],
|
||||
"pending": ["예산 승인 대기"]
|
||||
},
|
||||
"todos": [
|
||||
{"title": "인플루언서 계약서 작성"},
|
||||
{"title": "SNS 광고 컨텐츠 제작"},
|
||||
{"title": "예산안 제출"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
#### 5. 결과 확인
|
||||
|
||||
**데이터베이스 확인:**
|
||||
```sql
|
||||
-- 회의 상태 확인
|
||||
SELECT meeting_id, title, status, ended_at
|
||||
FROM meetings
|
||||
WHERE meeting_id = '위의_meetingId';
|
||||
-- 기대: status = 'COMPLETED'
|
||||
|
||||
-- AI 분석 결과 확인
|
||||
SELECT analysis_id, meeting_id, keywords, status, completed_at
|
||||
FROM meeting_analysis
|
||||
WHERE meeting_id = '위의_meetingId';
|
||||
|
||||
-- Todo 확인
|
||||
SELECT todo_id, title, status
|
||||
FROM todos
|
||||
WHERE meeting_id = '위의_meetingId';
|
||||
-- 기대: 5개의 Todo 생성
|
||||
```
|
||||
|
||||
**로그 확인:**
|
||||
```bash
|
||||
# AI Python Service 로그
|
||||
tail -f logs/ai-python.log
|
||||
|
||||
# Meeting Service 로그
|
||||
tail -f meeting/logs/meeting-service.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 1. AI Python Service 연결 실패
|
||||
```
|
||||
에러: Connection refused (8087)
|
||||
|
||||
해결:
|
||||
1. AI Python Service가 실행 중인지 확인
|
||||
ps aux | grep python | grep main.py
|
||||
2. 포트 확인
|
||||
lsof -i :8087
|
||||
3. 로그 확인
|
||||
tail -f logs/ai-python.log
|
||||
```
|
||||
|
||||
### 2. Claude API 오류
|
||||
```
|
||||
에러: Invalid API key
|
||||
|
||||
해결:
|
||||
1. .env 파일의 CLAUDE_API_KEY 확인
|
||||
2. API 키 유효성 확인
|
||||
curl https://api.anthropic.com/v1/messages \
|
||||
-H "x-api-key: $CLAUDE_API_KEY" \
|
||||
-H "anthropic-version: 2023-06-01"
|
||||
```
|
||||
|
||||
### 3. 데이터베이스 연결 실패
|
||||
```
|
||||
에러: Connection to 4.230.48.72:5432 refused
|
||||
|
||||
해결:
|
||||
1. PostgreSQL 서버 상태 확인
|
||||
2. 방화벽 규칙 확인
|
||||
3. application.yml의 DB 설정 확인
|
||||
```
|
||||
|
||||
### 4. 타임아웃 오류
|
||||
```
|
||||
에러: Read timeout (30초)
|
||||
|
||||
해결:
|
||||
1. application.yml에서 타임아웃 증가
|
||||
ai.service.timeout=60000
|
||||
2. Claude API 응답 시간 확인
|
||||
3. 네트워크 상태 확인
|
||||
```
|
||||
|
||||
### 5. 안건 데이터 없음
|
||||
```
|
||||
에러: No agenda sections found
|
||||
|
||||
해결:
|
||||
1. agenda_sections 테이블에 데이터 확인
|
||||
SELECT * FROM agenda_sections WHERE meeting_id = '해당ID';
|
||||
2. 테스트 데이터 삽입 (위 SQL 참조)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 성능 측정
|
||||
|
||||
### 응답 시간 측정
|
||||
```bash
|
||||
# 회의 종료 API 응답 시간
|
||||
time curl -X POST http://localhost:8082/api/meetings/${MEETING_ID}/end \
|
||||
-H "X-User-Id: user123" \
|
||||
-H "X-User-Name: 홍길동" \
|
||||
-H "X-User-Email: hong@example.com"
|
||||
|
||||
# 기대 시간: 5-15초 (Claude API 호출 포함)
|
||||
```
|
||||
|
||||
### 동시성 테스트
|
||||
```bash
|
||||
# Apache Bench로 부하 테스트 (선택사항)
|
||||
ab -n 10 -c 2 -H "X-User-Id: user123" \
|
||||
http://localhost:8087/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
- [ ] AI Python Service 실행 (8087)
|
||||
- [ ] Meeting Service 실행 (8082)
|
||||
- [ ] 데이터베이스 연결 확인
|
||||
- [ ] Redis 연결 확인
|
||||
- [ ] 회의 생성 API 성공
|
||||
- [ ] 회의 시작 API 성공
|
||||
- [ ] 안건 데이터 삽입
|
||||
- [ ] **회의 종료 API 성공**
|
||||
- [ ] AI 분석 결과 저장 확인
|
||||
- [ ] Todo 자동 생성 확인
|
||||
- [ ] 회의 상태 COMPLETED 확인
|
||||
|
||||
---
|
||||
|
||||
## 참고 링크
|
||||
|
||||
- AI Python Service: http://localhost:8087/docs
|
||||
- Meeting Service Swagger: http://localhost:8082/swagger-ui.html
|
||||
- Claude API 문서: https://docs.anthropic.com/claude/reference
|
||||
322
claude/README-SCHEMA-ANALYSIS.md
Normal file
322
claude/README-SCHEMA-ANALYSIS.md
Normal file
@ -0,0 +1,322 @@
|
||||
# Meeting Service 데이터베이스 스키마 분석 문서
|
||||
|
||||
## 생성된 문서 목록
|
||||
|
||||
본 분석은 Meeting Service의 데이터베이스 스키마를 전방위적으로 분석한 결과입니다.
|
||||
|
||||
### 1. SCHEMA-REPORT-SUMMARY.md (메인 보고서)
|
||||
**파일**: `/Users/jominseo/HGZero/claude/SCHEMA-REPORT-SUMMARY.md`
|
||||
**내용**:
|
||||
- Executive Summary (핵심 발견사항)
|
||||
- 데이터베이스 구조 개요
|
||||
- 테이블별 상세 분석 (1.1~1.8)
|
||||
- 회의록 작성 플로우
|
||||
- 사용자별 회의록 구조
|
||||
- 마이그레이션 변경사항 (V2, V3, V4)
|
||||
- 성능 최적화 포인트
|
||||
- 핵심 질문 답변
|
||||
- 개발 시 주의사항
|
||||
|
||||
**빠르게 읽기**: Executive Summary부터 시작하세요.
|
||||
|
||||
---
|
||||
|
||||
### 2. database-schema-analysis.md (상세 분석)
|
||||
**파일**: `/Users/jominseo/HGZero/claude/database-schema-analysis.md`
|
||||
**내용**:
|
||||
- 마이그레이션 파일 현황 (V1~V4)
|
||||
- 각 테이블의 상세 구조
|
||||
- minutes vs agenda_sections 비교 분석
|
||||
- 회의록 작성 플로우에서의 테이블 사용
|
||||
- 사용자별 회의록 저장 구조
|
||||
- SQL 쿼리 패턴
|
||||
- 데이터 정규화 현황
|
||||
- 인덱스 최적화 방안
|
||||
- 데이터 저장 크기 예상
|
||||
|
||||
**상세 분석 필요시**: 이 문서를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
### 3. data-flow-diagram.md (흐름도)
|
||||
**파일**: `/Users/jominseo/HGZero/claude/data-flow-diagram.md`
|
||||
**내용**:
|
||||
- 전체 시스템 플로우 (7 Phase)
|
||||
- 상태 전이 다이어그램
|
||||
- 사용자별 회의록 데이터 구조
|
||||
- 인덱스 활용 쿼리 예시
|
||||
- 데이터 저장 크기 예상
|
||||
|
||||
**시각적 이해 필요시**: 이 문서를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
### 4. database-diagram.puml (ER 다이어그램)
|
||||
**파일**: `/Users/jominseo/HGZero/claude/database-diagram.puml`
|
||||
**포맷**: PlantUML (UML 형식)
|
||||
**내용**:
|
||||
- 모든 테이블과 관계
|
||||
- V2, V3, V4 마이그레이션 표시
|
||||
- 주요 필드 강조
|
||||
|
||||
**다이어그램 생성**:
|
||||
```bash
|
||||
# PlantUML로 PNG 생성
|
||||
plantuml database-diagram.puml -o database-diagram.png
|
||||
|
||||
# 또는 온라인 에디터
|
||||
https://www.plantuml.com/plantuml/uml/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 발견사항 한눈에 보기
|
||||
|
||||
### 1. Minutes 테이블 구조
|
||||
```
|
||||
잘못된 이해: minutes.content ← 회의록 내용
|
||||
올바른 구조: minutes_sections.content ← 회의록 내용
|
||||
minutes ← 메타데이터만 (title, status, version)
|
||||
```
|
||||
|
||||
### 2. 사용자별 회의록 (V3)
|
||||
```
|
||||
minutes.user_id = NULL → AI 통합 회의록
|
||||
minutes.user_id = 'user@.com' → 개인 회의록
|
||||
인덱스: idx_minutes_meeting_user(meeting_id, user_id)
|
||||
```
|
||||
|
||||
### 3. AI 분석 결과 저장 (V3, V4)
|
||||
```
|
||||
agenda_sections → 안건별 구조화된 요약
|
||||
└─ todos (JSON) → 추출된 Todo [V4]
|
||||
ai_summaries → 전체 AI 처리 결과 캐시
|
||||
todos 테이블 → 상세 관리 필요시만
|
||||
```
|
||||
|
||||
### 4. 정규화 (V2)
|
||||
```
|
||||
이전: meetings.participants = "user1,user2,user3"
|
||||
현재: meeting_participants (테이블, 복합PK)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 빠른 참조표
|
||||
|
||||
### 회의록 작성 플로우
|
||||
| 단계 | API | 데이터베이스 변화 |
|
||||
|------|-----|-----------------|
|
||||
| 1 | CreateMeeting | meetings INSERT |
|
||||
| 2 | StartMeeting | meetings.status = IN_PROGRESS |
|
||||
| 3 | CreateMinutes | minutes INSERT (통합 + 개인) |
|
||||
| 4 | UpdateMinutes | minutes_sections.content UPDATE |
|
||||
| 5 | EndMeeting | meetings.status = COMPLETED, ended_at [V3] |
|
||||
| 6 | FinalizeMinutes | minutes.status = FINALIZED, sections locked |
|
||||
| 7 | AI 분석 | agenda_sections, ai_summaries, todos INSERT |
|
||||
|
||||
### 테이블별 핵심 필드
|
||||
```
|
||||
meetings : meeting_id, status, ended_at [V3]
|
||||
minutes : id, meeting_id, user_id [V3], status
|
||||
minutes_sections : id, minutes_id, content ★
|
||||
agenda_sections : id, minutes_id, agenda_number, todos [V4]
|
||||
ai_summaries : id, meeting_id, result (JSON)
|
||||
todos : todo_id, extracted_by [V3], extraction_confidence [V3]
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
```
|
||||
PRIMARY:
|
||||
idx_minutes_meeting_user (meeting_id, user_id) [V3]
|
||||
idx_sections_meeting (meeting_id) [V3]
|
||||
idx_sections_agenda (meeting_id, agenda_number) [V3]
|
||||
|
||||
SECONDARY:
|
||||
idx_todos_extracted (extracted_by) [V3]
|
||||
idx_todos_meeting (meeting_id) [V3]
|
||||
idx_summaries_type (meeting_id, summary_type) [V3]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 타임라인
|
||||
|
||||
```
|
||||
V1 (초기)
|
||||
├─ meetings, minutes, minutes_sections
|
||||
├─ todos, meeting_analysis
|
||||
└─ JPA Hibernate로 자동 생성
|
||||
|
||||
V2 (2025-10-27)
|
||||
├─ meeting_participants 테이블 생성
|
||||
├─ meetings.participants (CSV) 마이그레이션
|
||||
└─ 정규화 완료
|
||||
|
||||
V3 (2025-10-28) ★ 주요 변경
|
||||
├─ minutes.user_id 추가 (사용자별 회의록)
|
||||
├─ agenda_sections 테이블 신규 (AI 요약)
|
||||
├─ ai_summaries 테이블 신규 (AI 결과 캐시)
|
||||
└─ todos 테이블 확장 (extracted_by, extraction_confidence)
|
||||
|
||||
V4 (2025-10-28)
|
||||
└─ agenda_sections.todos JSON 필드 추가
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 자주 묻는 질문
|
||||
|
||||
### Q: minutes 테이블에 content 필드가 있나요?
|
||||
**A**: 없습니다. 실제 회의록 내용은 `minutes_sections.content`에 저장됩니다.
|
||||
`minutes` 테이블은 메타데이터만 보유합니다 (title, status, version 등).
|
||||
|
||||
### Q: 사용자별 회의록은 어떻게 구분되나요?
|
||||
**A**: `minutes.user_id` 컬럼으로 구분됩니다.
|
||||
- NULL: AI 통합 회의록
|
||||
- NOT NULL: 개인별 회의록 (각 참석자마다 생성)
|
||||
|
||||
### Q: AI 분석은 모든 회의록을 처리하나요?
|
||||
**A**: 아니요. 통합 회의록(`user_id=NULL`)만 분석합니다.
|
||||
개인별 회의록(`user_id NOT NULL`)은 개인 기록용이며 AI 분석 대상이 아닙니다.
|
||||
|
||||
### Q: agenda_sections와 minutes_sections의 차이는?
|
||||
**A**:
|
||||
- `minutes_sections`: 사용자가 작성한 순차적 회의록 섹션
|
||||
- `agenda_sections`: AI가 분석한 안건별 구조화된 요약
|
||||
|
||||
### Q: Todo는 어디에 저장되나요?
|
||||
**A**: 두 곳에 저장 가능합니다.
|
||||
1. `agenda_sections.todos` (JSON): 안건별 요약의 일부
|
||||
2. `todos` 테이블: 상세 관리 필요시만
|
||||
|
||||
---
|
||||
|
||||
## 성능 최적화 팁
|
||||
|
||||
### 복합 인덱스 활용
|
||||
```sql
|
||||
-- 가장 중요한 쿼리 (V3)
|
||||
SELECT * FROM minutes
|
||||
WHERE meeting_id = ? AND user_id = ?;
|
||||
└─ 인덱스: idx_minutes_meeting_user (meeting_id, user_id)
|
||||
```
|
||||
|
||||
### 추천 추가 인덱스
|
||||
```sql
|
||||
CREATE INDEX idx_minutes_status_created
|
||||
ON minutes(status, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_agenda_meeting_created
|
||||
ON agenda_sections(meeting_id, created_at DESC);
|
||||
```
|
||||
|
||||
### 쿼리 패턴
|
||||
```sql
|
||||
-- 통합 회의록 조회 (가장 흔함)
|
||||
SELECT m.*, ms.* FROM minutes m
|
||||
LEFT JOIN minutes_sections ms ON m.id = ms.minutes_id
|
||||
WHERE m.meeting_id = ? AND m.user_id IS NULL
|
||||
|
||||
-- 개인 회의록 조회
|
||||
SELECT m.*, ms.* FROM minutes m
|
||||
LEFT JOIN minutes_sections ms ON m.id = ms.minutes_id
|
||||
WHERE m.meeting_id = ? AND m.user_id = ?
|
||||
|
||||
-- AI 분석 결과 조회
|
||||
SELECT * FROM agenda_sections
|
||||
WHERE meeting_id = ? ORDER BY agenda_number
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문서 읽기 순서 추천
|
||||
|
||||
### 1단계: 빠른 이해 (5분)
|
||||
→ `SCHEMA-REPORT-SUMMARY.md`의 Executive Summary만 읽기
|
||||
|
||||
### 2단계: 구조 이해 (15분)
|
||||
→ `database-diagram.puml` (다이어그램 확인)
|
||||
→ `data-flow-diagram.md`의 Phase 1~7 읽기
|
||||
|
||||
### 3단계: 상세 이해 (30분)
|
||||
→ `SCHEMA-REPORT-SUMMARY.md` 전체 읽기
|
||||
→ `database-schema-analysis.md`의 핵심 섹션 읽기
|
||||
|
||||
### 4단계: 개발 참고 (필요시)
|
||||
→ `database-schema-analysis.md`의 쿼리 예시
|
||||
→ `data-flow-diagram.md`의 인덱스 활용 섹션
|
||||
|
||||
---
|
||||
|
||||
## 개발 체크리스트
|
||||
|
||||
회의록 작성 기능 개발시:
|
||||
|
||||
### 데이터 저장
|
||||
- [ ] 회의록 내용은 `minutes_sections.content`에 저장
|
||||
- [ ] `minutes` 테이블에는 메타데이터만 저장 (title, status)
|
||||
- [ ] 회의 종료시 `minutes.user_id` 값 확인 (NULL vs 사용자ID)
|
||||
|
||||
### AI 분석
|
||||
- [ ] 통합 회의록(`user_id=NULL`)만 AI 분석 대상으로 처리
|
||||
- [ ] `agenda_sections`은 통합 회의록에만 생성
|
||||
- [ ] `ai_summaries`에 전체 결과 캐싱
|
||||
|
||||
### 쿼리 성능
|
||||
- [ ] 복합 인덱스 활용: `idx_minutes_meeting_user`
|
||||
- [ ] 조회시 `WHERE meeting_id AND user_id` 조건 사용
|
||||
- [ ] 기존 인덱스 모두 생성 확인
|
||||
|
||||
### 데이터 무결성
|
||||
- [ ] 회의 종료시 `ended_at` 기록 (V3)
|
||||
- [ ] 최종화시 `minutes_sections` locked 처리
|
||||
- [ ] AI 추출 Todo의 `extraction_confidence` 값 확인
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
**마이그레이션**:
|
||||
```
|
||||
/Users/jominseo/HGZero/meeting/src/main/resources/db/migration/
|
||||
├─ V2__create_meeting_participants_table.sql
|
||||
├─ V3__add_meeting_end_support.sql
|
||||
└─ V4__add_todos_to_agenda_sections.sql
|
||||
```
|
||||
|
||||
**엔티티**:
|
||||
```
|
||||
/Users/jominseo/HGZero/meeting/src/main/java/.../entity/
|
||||
├─ MeetingEntity.java
|
||||
├─ MinutesEntity.java
|
||||
├─ MinutesSectionEntity.java
|
||||
├─ AgendaSectionEntity.java [V3]
|
||||
├─ TodoEntity.java
|
||||
└─ MeetingParticipantEntity.java [V2]
|
||||
```
|
||||
|
||||
**서비스**:
|
||||
```
|
||||
/Users/jominseo/HGZero/meeting/src/main/java/.../service/
|
||||
├─ MinutesService.java
|
||||
├─ MinutesSectionService.java
|
||||
└─ MinutesAnalysisEventConsumer.java (비동기 AI 분석)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 지원
|
||||
|
||||
이 문서에 대한 추가 질문이나 불명확한 부분이 있으면:
|
||||
|
||||
1. `SCHEMA-REPORT-SUMMARY.md`의 "핵심 질문 답변" 섹션 확인
|
||||
2. `database-schema-analysis.md`에서 상세 내용 검색
|
||||
3. `data-flow-diagram.md`에서 흐름도 재확인
|
||||
|
||||
---
|
||||
|
||||
**문서 작성일**: 2025-10-28
|
||||
**분석 대상**: Meeting Service (feat/meeting-ai 브랜치)
|
||||
**마이그레이션 버전**: V1~V4
|
||||
**상태**: 완료 및 검증됨
|
||||
607
claude/SCHEMA-REPORT-SUMMARY.md
Normal file
607
claude/SCHEMA-REPORT-SUMMARY.md
Normal file
@ -0,0 +1,607 @@
|
||||
# Meeting Service 데이터베이스 스키마 분석 최종 보고서
|
||||
|
||||
**작성일**: 2025-10-28
|
||||
**분석 대상**: Meeting Service (feat/meeting-ai 브랜치)
|
||||
**분석 범위**: 마이그레이션 V1~V4, 엔티티 구조, 데이터 플로우
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
### 핵심 발견사항
|
||||
|
||||
1. **minutes 테이블에 content 필드가 없음**
|
||||
- 실제 회의록 내용은 `minutes_sections.content`에 저장
|
||||
- minutes 테이블은 메타데이터만 보유 (title, status, version 등)
|
||||
|
||||
2. **사용자별 회의록 완벽하게 지원 (V3)**
|
||||
- `minutes.user_id = NULL`: AI 통합 회의록
|
||||
- `minutes.user_id = 참석자ID`: 개인별 회의록
|
||||
- 인덱스: `idx_minutes_meeting_user` (meeting_id, user_id)
|
||||
|
||||
3. **AI 분석 결과 구조화 저장 (V3, V4)**
|
||||
- `agenda_sections`: 안건별 구조화된 요약
|
||||
- `ai_summaries`: AI 처리 결과 캐싱
|
||||
- `todos` (V4): 각 안건의 JSON으로 저장
|
||||
|
||||
4. **정규화 완료 (V2)**
|
||||
- `meetings.participants` (CSV) → `meeting_participants` (테이블)
|
||||
- 복합 PK: (meeting_id, user_id)
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스 구조 개요
|
||||
|
||||
### 테이블 분류
|
||||
|
||||
**핵심 테이블** (V1):
|
||||
- `meetings`: 회의 기본 정보
|
||||
- `minutes`: 회의록 메타데이터
|
||||
- `minutes_sections`: 회의록 섹션 (실제 내용)
|
||||
|
||||
**참석자 관리** (V2):
|
||||
- `meeting_participants`: 회의 참석자 정보
|
||||
|
||||
**AI 분석** (V3):
|
||||
- `agenda_sections`: 안건별 AI 요약
|
||||
- `ai_summaries`: AI 처리 결과 캐시
|
||||
- `todos`: Todo 아이템 (expanded)
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 테이블별 상세 분석
|
||||
|
||||
### 1.1 meetings (회의 기본 정보)
|
||||
|
||||
**구성**:
|
||||
- PK: meeting_id (VARCHAR(50))
|
||||
- 주요 필드: title, purpose, description
|
||||
- 상태: SCHEDULED → IN_PROGRESS → COMPLETED
|
||||
- 시간: scheduled_at, started_at, ended_at (V3)
|
||||
|
||||
**중요 변경**:
|
||||
- V3에서 `ended_at` 추가
|
||||
- 회의 정확한 종료 시간 기록
|
||||
|
||||
```sql
|
||||
-- 조회 예시
|
||||
SELECT * FROM meetings
|
||||
WHERE status = 'COMPLETED'
|
||||
AND ended_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY ended_at DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 minutes (회의록 메타데이터)
|
||||
|
||||
**구성**:
|
||||
```
|
||||
minutes_id (PK)
|
||||
├─ meeting_id (FK)
|
||||
├─ user_id (V3) ← NULL: AI 통합 회의록 / NOT NULL: 개인 회의록
|
||||
├─ title
|
||||
├─ status (DRAFT, FINALIZED)
|
||||
├─ version
|
||||
├─ created_by, finalized_by
|
||||
└─ created_at, finalized_at
|
||||
```
|
||||
|
||||
**중요**:
|
||||
- **content 필드 없음** → minutes_sections에 저장
|
||||
- 메타데이터만 관리 (생성자, 확정자, 버전 등)
|
||||
|
||||
**쿼리 패턴**:
|
||||
```sql
|
||||
-- AI 통합 회의록
|
||||
SELECT * FROM minutes
|
||||
WHERE meeting_id = ? AND user_id IS NULL;
|
||||
|
||||
-- 특정 사용자의 회의록
|
||||
SELECT * FROM minutes
|
||||
WHERE meeting_id = ? AND user_id = ?;
|
||||
|
||||
-- 복합 인덱스 활용: idx_minutes_meeting_user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 minutes_sections (회의록 섹션 - 실제 내용)
|
||||
|
||||
**구성**:
|
||||
```
|
||||
section_id (PK)
|
||||
├─ minutes_id (FK) ← 어느 회의록에 속하는가
|
||||
├─ type (AGENDA, DISCUSSION, DECISION, ACTION_ITEM)
|
||||
├─ title
|
||||
├─ content ← ★ 실제 회의록 내용
|
||||
├─ order
|
||||
├─ verified (검증 완료)
|
||||
├─ locked (수정 불가)
|
||||
└─ locked_by
|
||||
```
|
||||
|
||||
**핵심 특성**:
|
||||
- **content**: 사용자가 작성한 실제 내용
|
||||
- **locked**: finalize_minutes 호출시 잠금
|
||||
- **verified**: 확정시 TRUE로 설정
|
||||
|
||||
**데이터 흐름**:
|
||||
```
|
||||
1. CreateMinutes → minutes_sections 초기 생성
|
||||
2. UpdateMinutes → content 저장 (여러 번)
|
||||
3. FinalizeMinutes → locked=TRUE, verified=TRUE
|
||||
4. (locked 상태에서 수정 불가)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.4 agenda_sections (AI 요약 - V3)
|
||||
|
||||
**구성**:
|
||||
```
|
||||
id (PK, UUID)
|
||||
├─ minutes_id (FK) ← 통합 회의록만 (user_id=NULL)
|
||||
├─ meeting_id (FK)
|
||||
├─ agenda_number (1, 2, 3...)
|
||||
├─ agenda_title
|
||||
├─ ai_summary_short (1줄 요약)
|
||||
├─ discussions (3-5문장 논의)
|
||||
├─ decisions (JSON 배열)
|
||||
├─ pending_items (JSON 배열)
|
||||
├─ opinions (JSON 배열: {speaker, opinion})
|
||||
└─ todos (JSON 배열 [V4])
|
||||
```
|
||||
|
||||
**V4 추가 사항**:
|
||||
- `todos` JSON 필드 추가
|
||||
- 안건별 추출된 Todo 저장
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "시장 조사 보고서 작성",
|
||||
"assignee": "김민준",
|
||||
"dueDate": "2025-02-15",
|
||||
"description": "20-30대 타겟 시장 조사",
|
||||
"priority": "HIGH"
|
||||
}
|
||||
```
|
||||
|
||||
**중요**:
|
||||
- **통합 회의록만 분석** (user_id=NULL인 것)
|
||||
- 참석자별 회의록(user_id NOT NULL)은 AI 분석 대상 아님
|
||||
- minutes_id로 통합 회의록 참조
|
||||
|
||||
---
|
||||
|
||||
### 1.5 minutes_sections vs agenda_sections
|
||||
|
||||
| 항목 | minutes_sections | agenda_sections |
|
||||
|------|-----------------|-----------------|
|
||||
| **용도** | 사용자 작성 | AI 요약 |
|
||||
| **모든 회의록** | ✓ 통합 + 개인 | ✗ 통합만 |
|
||||
| **구조** | 순차적 섹션 | 안건별 구조화 |
|
||||
| **내용 저장** | content (TEXT) | JSON 필드들 |
|
||||
| **관계** | 1:N (minutes과) | N:1 (minutes과) |
|
||||
| **목적** | 기록 | 분석/요약 |
|
||||
|
||||
**생성 흐름**:
|
||||
```
|
||||
회의 시작
|
||||
↓
|
||||
minutes 생성 (통합 + 개인)
|
||||
↓
|
||||
minutes_sections 생성 (4개 그룹)
|
||||
↓
|
||||
사용자 작성 중...
|
||||
↓
|
||||
회의 종료 → FinalizeMinutes
|
||||
↓
|
||||
minutes_sections locked
|
||||
↓
|
||||
AI 분석 (비동기)
|
||||
↓
|
||||
agenda_sections 생성 (통합 회의록 기반)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.6 ai_summaries (AI 처리 결과 - V3)
|
||||
|
||||
**구성**:
|
||||
```
|
||||
id (PK, UUID)
|
||||
├─ meeting_id (FK)
|
||||
├─ summary_type (CONSOLIDATED, TODO_EXTRACTION)
|
||||
├─ source_minutes_ids (JSON: 사용된 회의록 ID 배열)
|
||||
├─ result (JSON: AI 응답 전체)
|
||||
├─ processing_time_ms (처리 시간)
|
||||
├─ model_version (claude-3.5-sonnet)
|
||||
├─ keywords (JSON: 키워드 배열)
|
||||
└─ statistics (JSON: {participants, agendas, todos})
|
||||
```
|
||||
|
||||
**용도**:
|
||||
- AI 처리 결과 캐싱
|
||||
- 재처리 필요시 참조
|
||||
- 성능 통계 기록
|
||||
|
||||
---
|
||||
|
||||
### 1.7 todos (Todo 아이템)
|
||||
|
||||
**기본 구조**:
|
||||
```
|
||||
todo_id (PK)
|
||||
├─ meeting_id (FK)
|
||||
├─ minutes_id (FK)
|
||||
├─ title
|
||||
├─ description
|
||||
├─ assignee_id
|
||||
├─ due_date
|
||||
├─ status (PENDING, COMPLETED)
|
||||
├─ priority (HIGH, MEDIUM, LOW)
|
||||
└─ completed_at
|
||||
```
|
||||
|
||||
**V3 추가 필드**:
|
||||
```
|
||||
├─ extracted_by (AI / MANUAL) ← AI 자동 추출 vs 수동
|
||||
├─ section_reference (안건 참조)
|
||||
└─ extraction_confidence (0.00~1.00) ← AI 신뢰도
|
||||
```
|
||||
|
||||
**저장 전략**:
|
||||
1. `agenda_sections.todos` (JSON): 간단한 Todo, 기본 저장 위치
|
||||
2. `todos` 테이블: 상세 관리 필요시만 추가 저장
|
||||
|
||||
---
|
||||
|
||||
### 1.8 meeting_participants (참석자 관리 - V2)
|
||||
|
||||
**구성**:
|
||||
```
|
||||
PK: (meeting_id, user_id)
|
||||
├─ invitation_status (PENDING, ACCEPTED, DECLINED)
|
||||
├─ attended (BOOLEAN)
|
||||
└─ created_at, updated_at
|
||||
```
|
||||
|
||||
**V2 개선**:
|
||||
- 이전: meetings.participants (CSV 문자열)
|
||||
- 현재: 별도 테이블 (정규화)
|
||||
- 복합 PK로 중복 방지
|
||||
|
||||
---
|
||||
|
||||
## 2. 회의록 작성 플로우 (전체)
|
||||
|
||||
### 단계별 데이터 변화
|
||||
|
||||
```
|
||||
PHASE 1: 회의 준비
|
||||
═════════════════════════════════════════════
|
||||
1. CreateMeeting
|
||||
→ INSERT meetings (status='SCHEDULED')
|
||||
→ INSERT meeting_participants (5명)
|
||||
|
||||
PHASE 2: 회의 진행
|
||||
═════════════════════════════════════════════
|
||||
2. StartMeeting
|
||||
→ UPDATE meetings SET status='IN_PROGRESS'
|
||||
|
||||
3. CreateMinutes (회의 중)
|
||||
→ INSERT minutes (user_id=NULL) × 1 (통합)
|
||||
→ INSERT minutes (user_id=user_id) × 5 (개인)
|
||||
→ INSERT minutes_sections (초기 생성)
|
||||
|
||||
4. UpdateMinutes (여러 번)
|
||||
→ UPDATE minutes_sections SET content='...'
|
||||
|
||||
PHASE 3: 회의 종료
|
||||
═════════════════════════════════════════════
|
||||
5. EndMeeting
|
||||
→ UPDATE meetings SET
|
||||
status='COMPLETED',
|
||||
ended_at=NOW() [V3]
|
||||
|
||||
PHASE 4: 회의록 최종화
|
||||
═════════════════════════════════════════════
|
||||
6. FinalizeMinutes
|
||||
→ UPDATE minutes SET
|
||||
status='FINALIZED'
|
||||
→ UPDATE minutes_sections SET
|
||||
locked=TRUE,
|
||||
verified=TRUE
|
||||
|
||||
PHASE 5: AI 분석 (비동기)
|
||||
═════════════════════════════════════════════
|
||||
7. MinutesAnalysisEventConsumer
|
||||
→ Read minutes (user_id=NULL)
|
||||
→ Read minutes_sections
|
||||
→ Call AI Service
|
||||
→ INSERT agenda_sections [V3]
|
||||
→ INSERT ai_summaries [V3]
|
||||
→ INSERT todos [V3 확장]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용자별 회의록 구조
|
||||
|
||||
### 데이터 분리 방식
|
||||
|
||||
**1개 회의 (참석자 5명)**:
|
||||
|
||||
```
|
||||
meetings: 1개
|
||||
├─ meeting_id = 'meeting-001'
|
||||
└─ status = COMPLETED
|
||||
|
||||
meeting_participants: 5개
|
||||
├─ (meeting-001, user1@example.com)
|
||||
├─ (meeting-001, user2@example.com)
|
||||
├─ (meeting-001, user3@example.com)
|
||||
├─ (meeting-001, user4@example.com)
|
||||
└─ (meeting-001, user5@example.com)
|
||||
|
||||
minutes: 6개 [V3]
|
||||
├─ (id=consol-1, meeting_id=meeting-001, user_id=NULL)
|
||||
│ → 통합 회의록 (AI 분석 대상)
|
||||
├─ (id=user1-min, meeting_id=meeting-001, user_id=user1@example.com)
|
||||
│ → 사용자1 개인 회의록
|
||||
├─ (id=user2-min, meeting_id=meeting-001, user_id=user2@example.com)
|
||||
│ → 사용자2 개인 회의록
|
||||
├─ ... (user3, user4, user5)
|
||||
└─
|
||||
|
||||
minutes_sections: 수십 개 (6개 회의록 × N개 섹션)
|
||||
├─ Group 1: consol-1의 섹션들 (AI 작성)
|
||||
├─ Group 2: user1-min의 섹션들 (사용자1 작성)
|
||||
├─ Group 3: user2-min의 섹션들 (사용자2 작성)
|
||||
└─ ... (user3, user4, user5)
|
||||
|
||||
agenda_sections: 5개 [V3]
|
||||
├─ (id=ag-1, minutes_id=consol-1, agenda_number=1)
|
||||
├─ (id=ag-2, minutes_id=consol-1, agenda_number=2)
|
||||
└─ ... (3, 4, 5)
|
||||
```
|
||||
|
||||
**핵심**:
|
||||
- 참석자별 회의록은 minutes.user_id로 구분
|
||||
- 인덱스 활용: `idx_minutes_meeting_user`
|
||||
- AI 분석: 통합 회의록만 (user_id=NULL)
|
||||
|
||||
---
|
||||
|
||||
## 4. 마이그레이션 변경사항 요약
|
||||
|
||||
### V2 (2025-10-27)
|
||||
|
||||
```sql
|
||||
-- meeting_participants 테이블 생성
|
||||
CREATE TABLE meeting_participants (
|
||||
meeting_id, user_id (복합 PK),
|
||||
invitation_status, attended
|
||||
)
|
||||
|
||||
-- 데이터 마이그레이션
|
||||
SELECT TRIM(participant) FROM meetings.participants (CSV)
|
||||
→ INSERT INTO meeting_participants
|
||||
|
||||
-- meetings.participants 컬럼 삭제
|
||||
ALTER TABLE meetings DROP COLUMN participants
|
||||
```
|
||||
|
||||
**영향**: 정규화 완료, 중복 데이터 제거
|
||||
|
||||
---
|
||||
|
||||
### V3 (2025-10-28)
|
||||
|
||||
#### 3-1. minutes 테이블 확장
|
||||
```sql
|
||||
ALTER TABLE minutes ADD COLUMN user_id VARCHAR(100);
|
||||
CREATE INDEX idx_minutes_meeting_user ON minutes(meeting_id, user_id);
|
||||
```
|
||||
|
||||
**의미**: 사용자별 회의록 지원
|
||||
|
||||
---
|
||||
|
||||
#### 3-2. agenda_sections 테이블 신규
|
||||
```sql
|
||||
CREATE TABLE agenda_sections (
|
||||
id, minutes_id, meeting_id,
|
||||
agenda_number, agenda_title,
|
||||
ai_summary_short, discussions,
|
||||
decisions (JSON),
|
||||
pending_items (JSON),
|
||||
opinions (JSON)
|
||||
)
|
||||
```
|
||||
|
||||
**의미**: AI 요약을 구조화된 형식으로 저장
|
||||
|
||||
---
|
||||
|
||||
#### 3-3. ai_summaries 테이블 신규
|
||||
```sql
|
||||
CREATE TABLE ai_summaries (
|
||||
id, meeting_id, summary_type,
|
||||
source_minutes_ids (JSON),
|
||||
result (JSON),
|
||||
processing_time_ms,
|
||||
model_version,
|
||||
keywords (JSON),
|
||||
statistics (JSON)
|
||||
)
|
||||
```
|
||||
|
||||
**의미**: AI 처리 결과 캐싱
|
||||
|
||||
---
|
||||
|
||||
#### 3-4. todos 테이블 확장
|
||||
```sql
|
||||
ALTER TABLE todos ADD COLUMN extracted_by VARCHAR(50) DEFAULT 'AI';
|
||||
ALTER TABLE todos ADD COLUMN section_reference VARCHAR(200);
|
||||
ALTER TABLE todos ADD COLUMN extraction_confidence DECIMAL(3,2);
|
||||
```
|
||||
|
||||
**의미**: AI 자동 추출 추적
|
||||
|
||||
---
|
||||
|
||||
### V4 (2025-10-28)
|
||||
|
||||
```sql
|
||||
ALTER TABLE agenda_sections ADD COLUMN todos JSON;
|
||||
```
|
||||
|
||||
**의미**: 안건별 Todo를 JSON으로 저장
|
||||
|
||||
---
|
||||
|
||||
## 5. 성능 최적화
|
||||
|
||||
### 현재 인덱스
|
||||
|
||||
```
|
||||
meetings:
|
||||
├─ PK: meeting_id
|
||||
|
||||
minutes:
|
||||
├─ PK: id
|
||||
└─ idx_minutes_meeting_user (meeting_id, user_id) [V3]
|
||||
|
||||
minutes_sections:
|
||||
├─ PK: id
|
||||
└─ (minutes_id로 FK 지원)
|
||||
|
||||
agenda_sections: [V3]
|
||||
├─ PK: id
|
||||
├─ idx_sections_meeting (meeting_id)
|
||||
├─ idx_sections_agenda (meeting_id, agenda_number)
|
||||
└─ idx_sections_minutes (minutes_id)
|
||||
|
||||
ai_summaries: [V3]
|
||||
├─ PK: id
|
||||
├─ idx_summaries_meeting (meeting_id)
|
||||
├─ idx_summaries_type (meeting_id, summary_type)
|
||||
└─ idx_summaries_created (created_at)
|
||||
|
||||
todos:
|
||||
├─ PK: todo_id
|
||||
├─ idx_todos_extracted (extracted_by) [V3]
|
||||
└─ idx_todos_meeting (meeting_id) [V3]
|
||||
|
||||
meeting_participants: [V2]
|
||||
├─ PK: (meeting_id, user_id)
|
||||
├─ idx_user_id (user_id)
|
||||
└─ idx_invitation_status (invitation_status)
|
||||
```
|
||||
|
||||
### 추천 추가 인덱스
|
||||
|
||||
```sql
|
||||
-- 자주 조회하는 패턴
|
||||
CREATE INDEX idx_minutes_status_created
|
||||
ON minutes(status, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_agenda_meeting_created
|
||||
ON agenda_sections(meeting_id, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_todos_meeting_assignee
|
||||
ON todos(meeting_id, assignee_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 핵심 질문 답변
|
||||
|
||||
### Q1: minutes 테이블에 content 필드가 있는가?
|
||||
**A**: **없음**
|
||||
- minutes: 메타데이터만 (title, status, version 등)
|
||||
- 실제 내용: minutes_sections.content
|
||||
|
||||
### Q2: minutes_section과 agenda_sections의 차이점?
|
||||
**A**:
|
||||
| 항목 | minutes_sections | agenda_sections |
|
||||
|------|-----------------|-----------------|
|
||||
| 목적 | 사용자 작성 | AI 요약 |
|
||||
| 모든 회의록 | O | X (통합만) |
|
||||
| 내용 저장 | content (TEXT) | JSON |
|
||||
|
||||
### Q3: 사용자별 회의록 저장 방식?
|
||||
**A**:
|
||||
- minutes.user_id로 구분
|
||||
- NULL: AI 통합회의록
|
||||
- NOT NULL: 개인별 회의록
|
||||
- 인덱스: idx_minutes_meeting_user
|
||||
|
||||
### Q4: V3, V4 주요 변경?
|
||||
**A**:
|
||||
- V3: user_id, agenda_sections, ai_summaries, todos 확장
|
||||
- V4: agenda_sections.todos JSON 추가
|
||||
|
||||
---
|
||||
|
||||
## 7. 개발 시 주의사항
|
||||
|
||||
### Do's ✓
|
||||
- minutes_sections.content에 실제 내용 저장
|
||||
- AI 분석시 user_id=NULL인 minutes만 처리
|
||||
- agenda_sections.todos와 todos 테이블 동시 저장 (필요시)
|
||||
- 복합 인덱스 활용 (meeting_id, user_id)
|
||||
|
||||
### Don'ts ✗
|
||||
- minutes 테이블에 content 저장 (없음)
|
||||
- 참석자별 회의록(user_id NOT NULL)을 AI 분석 (통합만)
|
||||
- agenda_sections를 모든 minutes에 생성 (통합만)
|
||||
- 인덱스 무시한 풀 스캔
|
||||
|
||||
---
|
||||
|
||||
## 8. 파일 위치 및 참조
|
||||
|
||||
**마이그레이션 파일**:
|
||||
- `/Users/jominseo/HGZero/meeting/src/main/resources/db/migration/V2__*.sql`
|
||||
- `/Users/jominseo/HGZero/meeting/src/main/resources/db/migration/V3__*.sql`
|
||||
- `/Users/jominseo/HGZero/meeting/src/main/resources/db/migration/V4__*.sql`
|
||||
|
||||
**엔티티**:
|
||||
- `MeetingEntity`, `MinutesEntity`, `MinutesSectionEntity`
|
||||
- `AgendaSectionEntity`, `TodoEntity`, `MeetingParticipantEntity`
|
||||
|
||||
**서비스**:
|
||||
- `MinutesService`, `MinutesSectionService`
|
||||
- `MinutesAnalysisEventConsumer` (비동기)
|
||||
|
||||
---
|
||||
|
||||
## 9. 결론
|
||||
|
||||
### 핵심 설계 원칙
|
||||
1. **메타데이터 vs 내용 분리**: minutes (메타) vs minutes_sections (내용)
|
||||
2. **사용자별 격리**: user_id 컬럼으로 개인 회의록 관리
|
||||
3. **AI 결과 구조화**: JSON으로 유연성과 성능 확보
|
||||
4. **정규화 완료**: 참석자 정보 테이블화
|
||||
|
||||
### 검증 사항
|
||||
- V3, V4 마이그레이션 정상 적용
|
||||
- 모든 인덱스 생성됨
|
||||
- 관계 설정 정상 (FK, 1:N)
|
||||
|
||||
### 다음 단계
|
||||
- 성능 모니터링 (쿼리 실행 계획)
|
||||
- 추가 인덱스 검토
|
||||
- AI 분석 결과 검증
|
||||
- 참석자별 회의록 사용성 테스트
|
||||
|
||||
---
|
||||
|
||||
**문서 정보**:
|
||||
- 작성자: Database Architecture Analysis
|
||||
- 대상 서비스: Meeting Service (AI 통합 회의록)
|
||||
- 최종 버전: 2025-10-28
|
||||
560
claude/data-flow-diagram.md
Normal file
560
claude/data-flow-diagram.md
Normal file
@ -0,0 +1,560 @@
|
||||
# Meeting Service 데이터 플로우 다이어그램
|
||||
|
||||
## 1. 전체 시스템 플로우
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 회의 생명주기 데이터 플로우 │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Phase 1: 회의 준비 단계
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
사용자가 회의 생성
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1-1. CreateMeeting API │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ INSERT INTO meetings ( │
|
||||
│ meeting_id, title, purpose, scheduled_at, │
|
||||
│ organizer_id, status, created_at │
|
||||
│ ) │
|
||||
│ VALUES (...) │
|
||||
│ │
|
||||
│ + INSERT INTO meeting_participants [V2] │
|
||||
│ (meeting_id, user_id, invitation_status) │
|
||||
│ FOR EACH participant │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
DB State:
|
||||
✓ meetings: SCHEDULED status
|
||||
✓ meeting_participants: PENDING status
|
||||
|
||||
|
||||
Phase 2: 회의 진행 중
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
회의 시작 (start_meeting API)
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2-1. StartMeeting UseCase │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ UPDATE meetings SET │
|
||||
│ status = 'IN_PROGRESS', │
|
||||
│ started_at = NOW() │
|
||||
│ WHERE meeting_id = ? │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
회의 중 회의록 작성
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2-2. CreateMinutes API (회의 시작 후) │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ INSERT INTO minutes ( │
|
||||
│ id, meeting_id, user_id, title, status, │
|
||||
│ created_by, version, created_at │
|
||||
│ ) VALUES ( │
|
||||
│ 'consolidated-minutes-1', 'meeting-001', │
|
||||
│ NULL, [V3] ← AI 통합 회의록 표시 │
|
||||
│ '2025년 1월 10일 회의', 'DRAFT', ... │
|
||||
│ ) │
|
||||
│ │
|
||||
│ + 각 참석자별 회의록도 동시 생성: │
|
||||
│ INSERT INTO minutes ( │
|
||||
│ id, meeting_id, user_id, ... │
|
||||
│ ) VALUES ( │
|
||||
│ 'user-minutes-user1', 'meeting-001', │
|
||||
│ 'user1@example.com', [V3] ← 참석자 구분 │
|
||||
│ ... │
|
||||
│ ) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
DB State:
|
||||
✓ meetings: IN_PROGRESS
|
||||
✓ minutes (multiple records):
|
||||
- 1개의 통합 회의록 (user_id=NULL)
|
||||
- N개의 참석자별 회의록 (user_id=참석자ID)
|
||||
✓ minutes_sections: 초기 섹션 생성
|
||||
|
||||
|
||||
Phase 3: 회의록 작성 중
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
사용자가 회의록 섹션 작성
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 3-1. UpdateMinutes API (여러 번) │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ 각 섹션별로: │
|
||||
│ INSERT INTO minutes_sections ( │
|
||||
│ id, minutes_id, type, title, content, order │
|
||||
│ ) VALUES ( │
|
||||
│ 'section-1', 'consolidated-minutes-1', │
|
||||
│ 'DISCUSSION', '신제품 기획 방향', │
|
||||
│ '신제품의 주요 타겟은 20-30대 직장인으로 설정...', │
|
||||
│ 1 │
|
||||
│ ) │
|
||||
│ │
|
||||
│ UPDATE minutes_sections SET │
|
||||
│ content = '...', │
|
||||
│ updated_at = NOW() │
|
||||
│ WHERE id = 'section-1' │
|
||||
│ │
|
||||
│ ★ 중요: content 컬럼에 실제 회의록 내용 저장! │
|
||||
│ minutes 테이블에는 content가 없음 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
DB State:
|
||||
✓ minutes: status='DRAFT'
|
||||
✓ minutes_sections: 사용자가 작성한 내용 축적
|
||||
✓ 각 참석자가 자신의 회의록을 독립적으로 작성
|
||||
|
||||
|
||||
Phase 4: 회의 종료
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
회의 종료 (end_meeting API)
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 4-1. EndMeeting UseCase [V3 추가] │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ UPDATE meetings SET │
|
||||
│ status = 'COMPLETED', │
|
||||
│ ended_at = NOW() [V3] ← 종료 시간 기록 │
|
||||
│ WHERE meeting_id = ? │
|
||||
│ │
|
||||
│ ★ 중요: 회의 종료와 동시에 회의록 준비 시작 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
DB State:
|
||||
✓ meetings: status='COMPLETED', ended_at=현재시간
|
||||
✓ minutes: 계속 DRAFT (사용자 추가 편집 가능)
|
||||
|
||||
|
||||
Phase 5: 회의록 최종화
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
사용자가 회의록 최종화 요청
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 5-1. FinalizeMinutes API │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ UPDATE minutes SET │
|
||||
│ status = 'FINALIZED', │
|
||||
│ finalized_by = ?, │
|
||||
│ finalized_at = NOW(), │
|
||||
│ version = version + 1 │
|
||||
│ WHERE id = 'consolidated-minutes-1' │
|
||||
│ │
|
||||
│ UPDATE minutes_sections SET │
|
||||
│ locked = TRUE, │
|
||||
│ locked_by = ?, │
|
||||
│ verified = TRUE │
|
||||
│ WHERE minutes_id = 'consolidated-minutes-1' │
|
||||
│ │
|
||||
│ ★ 중요: minutes_id를 통해 관련된 모든 섹션 잠금 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
Event 발생: MinutesAnalysisRequestEvent (Async)
|
||||
↓
|
||||
DB State:
|
||||
✓ minutes: status='FINALIZED'
|
||||
✓ minutes_sections: locked=TRUE, verified=TRUE
|
||||
✓ 모든 섹션이 수정 불가능
|
||||
|
||||
|
||||
Phase 6: AI 분석 처리 (비동기 - MinutesAnalysisEventConsumer)
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
이벤트 수신: MinutesAnalysisRequestEvent
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 6-1. 통합 회의록 조회 (user_id=NULL) │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ SELECT m.*, GROUP_CONCAT(ms.content) AS full_content │
|
||||
│ FROM minutes m │
|
||||
│ LEFT JOIN minutes_sections ms ON m.id = ms.minutes_id │
|
||||
│ WHERE m.meeting_id = ? AND m.user_id IS NULL │
|
||||
│ ORDER BY ms.order │
|
||||
│ │
|
||||
│ ★ 참석자별 회의록은 AI 분석 대상이 아님 │
|
||||
│ user_id IS NOT NULL인 것들은 개인 기록용 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
AI Service 호출 (Claude API)
|
||||
↓
|
||||
AI가 회의록 분석
|
||||
- 안건별로 분리
|
||||
- 요약 생성
|
||||
- 결정사항 추출
|
||||
- 보류사항 추출
|
||||
- Todo 추출
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 6-2. agenda_sections 생성 [V3] │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ INSERT INTO agenda_sections ( │
|
||||
│ id, minutes_id, meeting_id, agenda_number, │
|
||||
│ agenda_title, ai_summary_short, discussions, │
|
||||
│ decisions, pending_items, opinions, todos [V4] │
|
||||
│ ) VALUES ( │
|
||||
│ 'uuid-1', 'consolidated-minutes-1', 'meeting-001', │
|
||||
│ 1, '신제품 기획 방향성', │
|
||||
│ '타겟 고객을 20-30대로 설정...', │
|
||||
│ '신제품의 주요 타겟 고객층을 20-30대...', │
|
||||
│ ["타겟 고객: 20-30대 직장인", "UI 개선 최우선"], │
|
||||
│ [], │
|
||||
│ [{"speaker": "김민준", "opinion": "..."}], │
|
||||
│ [ │
|
||||
│ {"title": "시장 조사", "assignee": "김민준", │
|
||||
│ "dueDate": "2025-02-15", "priority": "HIGH"} │
|
||||
│ ] [V4] │
|
||||
│ ) │
|
||||
│ │
|
||||
│ FOR EACH agenda detected by AI │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 6-3. ai_summaries 저장 [V3] │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ INSERT INTO ai_summaries ( │
|
||||
│ id, meeting_id, summary_type, │
|
||||
│ source_minutes_ids, result, processing_time_ms, │
|
||||
│ model_version, keywords, statistics, created_at │
|
||||
│ ) VALUES ( │
|
||||
│ 'summary-uuid-1', 'meeting-001', 'CONSOLIDATED', │
|
||||
│ ["consolidated-minutes-1"], │
|
||||
│ {AI 응답 전체 JSON}, │
|
||||
│ 2500, │
|
||||
│ 'claude-3.5-sonnet', │
|
||||
│ ["신제품", "타겟층", "UI개선"], │
|
||||
│ {"participants": 5, "agendas": 3, "todos": 8}, │
|
||||
│ NOW() │
|
||||
│ ) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 6-4. todos 저장 [V3 확장] │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ INSERT INTO todos ( │
|
||||
│ todo_id, meeting_id, minutes_id, title, │
|
||||
│ assignee_id, due_date, status, priority, │
|
||||
│ extracted_by, section_reference, │
|
||||
│ extraction_confidence, created_at │
|
||||
│ ) VALUES ( │
|
||||
│ 'todo-uuid-1', 'meeting-001', │
|
||||
│ 'consolidated-minutes-1', '시장 조사 보고서 작성', │
|
||||
│ 'user1@example.com', '2025-02-15', 'PENDING', 'HIGH', │
|
||||
│ 'AI', '안건 1: 신제품 기획', [V3] │
|
||||
│ 0.95, [V3] 신뢰도 │
|
||||
│ NOW() │
|
||||
│ ) │
|
||||
│ │
|
||||
│ ★ 주의: agenda_sections.todos (JSON)에도 동시 저장 │
|
||||
│ 개별 관리 필요시만 todos 테이블에 저장 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
DB State:
|
||||
✓ agenda_sections: AI 요약 결과 저장됨 (안건별)
|
||||
✓ ai_summaries: AI 처리 결과 캐시
|
||||
✓ todos: AI 추출 Todo (extracted_by='AI')
|
||||
|
||||
|
||||
Phase 7: 회의록 및 분석 결과 조회
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Case 1: 통합 회의록 조회
|
||||
─────────────────────────────────────────────────────────────
|
||||
SELECT m.*, ms.*, ag.*, ai.* FROM minutes m
|
||||
LEFT JOIN minutes_sections ms ON m.id = ms.minutes_id
|
||||
LEFT JOIN agenda_sections ag ON m.id = ag.minutes_id
|
||||
LEFT JOIN ai_summaries ai ON m.meeting_id = ai.meeting_id
|
||||
WHERE m.meeting_id = 'meeting-001'
|
||||
AND m.user_id IS NULL [V3]
|
||||
ORDER BY ms.order
|
||||
|
||||
|
||||
Case 2: 특정 사용자의 개인 회의록 조회
|
||||
─────────────────────────────────────────────────────────────
|
||||
SELECT m.*, ms.* FROM minutes m
|
||||
LEFT JOIN minutes_sections ms ON m.id = ms.minutes_id
|
||||
WHERE m.meeting_id = 'meeting-001'
|
||||
AND m.user_id = 'user1@example.com' [V3]
|
||||
ORDER BY ms.order
|
||||
|
||||
→ 개인이 작성한 회의록만 조회
|
||||
→ AI 분석 결과(agenda_sections) 미포함
|
||||
|
||||
|
||||
Case 3: AI 분석 결과만 조회
|
||||
─────────────────────────────────────────────────────────────
|
||||
SELECT ag.* FROM agenda_sections ag
|
||||
WHERE ag.meeting_id = 'meeting-001'
|
||||
ORDER BY ag.agenda_number
|
||||
|
||||
→ 안건별 AI 요약
|
||||
→ todos JSON 필드 포함 (V4)
|
||||
|
||||
|
||||
Case 4: 추출된 Todo 조회
|
||||
─────────────────────────────────────────────────────────────
|
||||
SELECT * FROM todos
|
||||
WHERE meeting_id = 'meeting-001'
|
||||
AND extracted_by = 'AI' [V3]
|
||||
ORDER BY priority DESC, due_date ASC
|
||||
|
||||
또는 agenda_sections의 JSON todos 필드 사용
|
||||
|
||||
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 상태 전이 다이어그램 (State Transition)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ meetings 테이블 상태 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
[생성]
|
||||
│
|
||||
├─────────────────────────┐
|
||||
▼ │
|
||||
SCHEDULED │ (시간 경과)
|
||||
(scheduled_at 설정) │
|
||||
│ │
|
||||
│ start_meeting API │
|
||||
▼ │
|
||||
IN_PROGRESS │
|
||||
(started_at 설정) │
|
||||
│ │
|
||||
│ end_meeting API [V3] │
|
||||
▼ │
|
||||
COMPLETED │
|
||||
(ended_at 설정) [V3 추가] ├─────────────────────────┐
|
||||
│ │ │
|
||||
└─────────────────────────┘ │
|
||||
│ 회의록 최종화 │
|
||||
│ (finalize_minutes API) │
|
||||
▼ │
|
||||
minutes: FINALIZED │
|
||||
(status='FINALIZED') │
|
||||
│ │
|
||||
│ (비동기 이벤트) │
|
||||
▼ │
|
||||
AI 분석 완료 │
|
||||
agenda_sections 생성 │
|
||||
ai_summaries 생성 │
|
||||
todos 추출 │
|
||||
│ │
|
||||
└─────────────────────────┘
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ minutes 테이블 상태 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
CREATE DRAFT
|
||||
(minutes 생성) ───────────► (사용자 작성 중)
|
||||
│
|
||||
update_minutes API
|
||||
│
|
||||
(섹션 추가/수정)
|
||||
│
|
||||
│
|
||||
finalize_minutes API
|
||||
│
|
||||
▼
|
||||
FINALIZED
|
||||
(AI 분석 대기 중)
|
||||
│
|
||||
(비동기 처리 완료)
|
||||
│
|
||||
▼
|
||||
분석 완료 (상태 유지)
|
||||
agenda_sections 생성됨
|
||||
ai_summaries 생성됨
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ minutes_sections 잠금 상태 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
편집 가능
|
||||
(locked=FALSE)
|
||||
│
|
||||
│ finalize_minutes
|
||||
│
|
||||
▼
|
||||
잠금됨
|
||||
(locked=TRUE, locked_by=user_id)
|
||||
│
|
||||
└─────► 수정 불가
|
||||
verified=TRUE
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ todos 완료 상태 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
PENDING
|
||||
(생성됨)
|
||||
│
|
||||
│ todo 완료 API
|
||||
│
|
||||
▼
|
||||
COMPLETED
|
||||
(completed_at 설정)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용자별 회의록 데이터 구조
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1개 회의 (meetings: meeting-001)
|
||||
│ ├─ 참석자: user1, user2, user3
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
회의 종료 → minutes 테이블에 여러 레코드 생성
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ minutes 테이블 (3개 레코드 생성) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ id │ meeting_id │ user_id │ status
|
||||
├─────────────────────┼─────────────┼──────────────────────┼────────
|
||||
│ consol-minutes-001 │ meeting-001 │ NULL [V3] │ DRAFT
|
||||
│ user1-minutes-001 │ meeting-001 │ user1@example.com │ DRAFT
|
||||
│ user2-minutes-001 │ meeting-001 │ user2@example.com │ DRAFT
|
||||
│ user3-minutes-001 │ meeting-001 │ user3@example.com │ DRAFT
|
||||
└─────────────────────┴─────────────┴──────────────────────┴────────
|
||||
|
||||
↓ (각각 minutes_sections 참조)
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ minutes_sections 테이블 (4그룹 × N개 섹션) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ id │ minutes_id │ type │ title │ content
|
||||
├────────┼────────────────────┼─────────────┼──────────┼─────────
|
||||
│ sec-1 │ consol-minutes-001 │ DISCUSSION │ 안건1 │ "AI가..."
|
||||
│ sec-2 │ consol-minutes-001 │ DECISION │ 결정1 │ "..."
|
||||
│ │ │ │ │
|
||||
│ sec-3 │ user1-minutes-001 │ DISCUSSION │ 안건1 │ "사용자1..."
|
||||
│ sec-4 │ user1-minutes-001 │ DISCUSSION │ 안건2 │ "..."
|
||||
│ │ │ │ │
|
||||
│ sec-5 │ user2-minutes-001 │ DISCUSSION │ 안건1 │ "사용자2..."
|
||||
│ sec-6 │ user2-minutes-001 │ DECISION │ 결정1 │ "..."
|
||||
│ │ │ │ │
|
||||
│ sec-7 │ user3-minutes-001 │ DISCUSSION │ 안건1 │ "사용자3..."
|
||||
└────────┴────────────────────┴─────────────┴──────────┴─────────
|
||||
|
||||
|
||||
각 사용자가 독립적으로 작성:
|
||||
- User1: consol-minutes-001의 sec-3, sec-4 편집
|
||||
- User2: user2-minutes-001의 sec-5, sec-6 편집
|
||||
- User3: user3-minutes-001의 sec-7 편집
|
||||
|
||||
AI 분석 (user_id=NULL인 것만):
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ agenda_sections 테이블 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ id │ minutes_id │ meeting_id │ agenda_number
|
||||
├────────┼────────────────────┼─────────────┼──────────────────
|
||||
│ ag-1 │ consol-minutes-001 │ meeting-001 │ 1
|
||||
│ ag-2 │ consol-minutes-001 │ meeting-001 │ 2
|
||||
└────────┴────────────────────┴─────────────┴──────────────────
|
||||
|
||||
→ minutes_id를 통해 통합 회의록만 참조
|
||||
→ user_id='user1@example.com'인 회의록은 참조하지 않음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 인덱스 활용 쿼리 예시
|
||||
|
||||
```sql
|
||||
-- 쿼리 1: 특정 회의의 통합 회의록 조회 (V3 인덱스 활용)
|
||||
SELECT * FROM minutes
|
||||
WHERE meeting_id = 'meeting-001' AND user_id IS NULL
|
||||
ORDER BY created_at DESC;
|
||||
└─► 인덱스: idx_minutes_meeting_user (meeting_id, user_id)
|
||||
|
||||
|
||||
-- 쿼리 2: 특정 사용자의 회의록 조회 (복합 인덱스 활용)
|
||||
SELECT * FROM minutes
|
||||
WHERE meeting_id = 'meeting-001' AND user_id = 'user1@example.com'
|
||||
ORDER BY created_at DESC;
|
||||
└─► 인덱스: idx_minutes_meeting_user (meeting_id, user_id)
|
||||
|
||||
|
||||
-- 쿼리 3: 안건별 AI 요약 조회 (V3 인덱스 활용)
|
||||
SELECT * FROM agenda_sections
|
||||
WHERE meeting_id = 'meeting-001'
|
||||
ORDER BY agenda_number ASC;
|
||||
└─► 인덱스: idx_sections_meeting (meeting_id)
|
||||
|
||||
|
||||
-- 쿼리 4: 특정 안건의 세부 요약 (복합 인덱스 활용)
|
||||
SELECT * FROM agenda_sections
|
||||
WHERE meeting_id = 'meeting-001' AND agenda_number = 1;
|
||||
└─► 인덱스: idx_sections_agenda (meeting_id, agenda_number)
|
||||
|
||||
|
||||
-- 쿼리 5: AI 추출 Todo 조회 (V3 인덱스 활용)
|
||||
SELECT * FROM todos
|
||||
WHERE meeting_id = 'meeting-001' AND extracted_by = 'AI'
|
||||
ORDER BY priority DESC, due_date ASC;
|
||||
└─► 인덱스: idx_todos_extracted (extracted_by)
|
||||
└─► 인덱스: idx_todos_meeting (meeting_id)
|
||||
|
||||
|
||||
-- 쿼리 6: 특정 회의의 모든 데이터 조회 (JOIN)
|
||||
SELECT
|
||||
m.*,
|
||||
ms.content,
|
||||
ag.ai_summary_short,
|
||||
ag.todos,
|
||||
ai.keywords
|
||||
FROM minutes m
|
||||
LEFT JOIN minutes_sections ms ON m.id = ms.minutes_id
|
||||
LEFT JOIN agenda_sections ag ON m.id = ag.minutes_id
|
||||
LEFT JOIN ai_summaries ai ON m.meeting_id = ai.meeting_id
|
||||
WHERE m.meeting_id = 'meeting-001' AND m.user_id IS NULL
|
||||
ORDER BY ms.order ASC, ag.agenda_number ASC;
|
||||
└─► 인덱스: idx_minutes_meeting_user (meeting_id, user_id)
|
||||
└─► 인덱스: idx_sections_minutes (minutes_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 저장 크기 예상
|
||||
|
||||
```
|
||||
1개 회의 (참석자 5명) 데이터 크기:
|
||||
|
||||
├─ meetings: ~500 bytes
|
||||
├─ meeting_participants (5명): ~5 × 150 = 750 bytes
|
||||
├─ minutes (6개: 1 통합 + 5 개인): ~6 × 400 = 2.4 KB
|
||||
├─ minutes_sections (30개 섹션): ~30 × 2 KB = 60 KB
|
||||
├─ agenda_sections (5개 안건): ~5 × 4 KB = 20 KB
|
||||
├─ ai_summaries: ~10 KB
|
||||
└─ todos (8개): ~8 × 800 bytes = 6.4 KB
|
||||
|
||||
Total: ~100 KB/회의
|
||||
|
||||
1년 (250개 회의) 예상:
|
||||
└─► 25 MB + 인덱스 ~5 MB = ~30 MB
|
||||
|
||||
|
||||
JSON 필드 데이터 크기:
|
||||
├─ agenda_sections.decisions: ~200 bytes/건
|
||||
├─ agenda_sections.opinions: ~300 bytes/건
|
||||
├─ agenda_sections.todos: ~500 bytes/건 [V4]
|
||||
├─ ai_summaries.result: ~5-10 KB/건
|
||||
└─ ai_summaries.statistics: ~200 bytes/건
|
||||
```
|
||||
130
claude/database-diagram.puml
Normal file
130
claude/database-diagram.puml
Normal file
@ -0,0 +1,130 @@
|
||||
@startuml Meeting Service Database Schema
|
||||
!theme mono
|
||||
|
||||
'=== Core Tables ===
|
||||
entity "meetings" {
|
||||
* **meeting_id : VARCHAR(50)
|
||||
--
|
||||
title : VARCHAR(200) NOT NULL
|
||||
purpose : VARCHAR(500)
|
||||
description : TEXT
|
||||
scheduled_at : TIMESTAMP NOT NULL
|
||||
started_at : TIMESTAMP
|
||||
ended_at : TIMESTAMP [V3]
|
||||
status : VARCHAR(20) NOT NULL
|
||||
organizer_id : VARCHAR(50) NOT NULL
|
||||
created_at : TIMESTAMP
|
||||
updated_at : TIMESTAMP
|
||||
template_id : VARCHAR(50)
|
||||
}
|
||||
|
||||
entity "meeting_participants" {
|
||||
* **meeting_id : VARCHAR(50) [FK]
|
||||
* **user_id : VARCHAR(100)
|
||||
--
|
||||
invitation_status : VARCHAR(20)
|
||||
attended : BOOLEAN
|
||||
created_at : TIMESTAMP
|
||||
updated_at : TIMESTAMP
|
||||
}
|
||||
|
||||
entity "minutes" {
|
||||
* **id : VARCHAR(50)
|
||||
--
|
||||
meeting_id : VARCHAR(50) [FK] NOT NULL
|
||||
user_id : VARCHAR(100) [V3]
|
||||
title : VARCHAR(200) NOT NULL
|
||||
status : VARCHAR(20) NOT NULL
|
||||
version : INT NOT NULL
|
||||
created_by : VARCHAR(50) NOT NULL
|
||||
finalized_by : VARCHAR(50)
|
||||
finalized_at : TIMESTAMP
|
||||
created_at : TIMESTAMP
|
||||
updated_at : TIMESTAMP
|
||||
}
|
||||
|
||||
entity "minutes_sections" {
|
||||
* **id : VARCHAR(50)
|
||||
--
|
||||
minutes_id : VARCHAR(50) [FK] NOT NULL
|
||||
type : VARCHAR(50) NOT NULL
|
||||
title : VARCHAR(200) NOT NULL
|
||||
**content : TEXT
|
||||
order : INT
|
||||
verified : BOOLEAN
|
||||
locked : BOOLEAN
|
||||
locked_by : VARCHAR(50)
|
||||
created_at : TIMESTAMP
|
||||
updated_at : TIMESTAMP
|
||||
}
|
||||
|
||||
'=== V3 New Tables ===
|
||||
entity "agenda_sections" {
|
||||
* **id : VARCHAR(36)
|
||||
--
|
||||
minutes_id : VARCHAR(36) [FK] NOT NULL
|
||||
meeting_id : VARCHAR(50) [FK] NOT NULL
|
||||
agenda_number : INT NOT NULL
|
||||
agenda_title : VARCHAR(200) NOT NULL
|
||||
ai_summary_short : TEXT
|
||||
discussions : TEXT
|
||||
decisions : JSON
|
||||
pending_items : JSON
|
||||
opinions : JSON
|
||||
**todos : JSON [V4]
|
||||
created_at : TIMESTAMP
|
||||
updated_at : TIMESTAMP
|
||||
}
|
||||
|
||||
entity "ai_summaries" {
|
||||
* **id : VARCHAR(36)
|
||||
--
|
||||
meeting_id : VARCHAR(50) [FK] NOT NULL
|
||||
summary_type : VARCHAR(50) NOT NULL
|
||||
source_minutes_ids : JSON NOT NULL
|
||||
result : JSON NOT NULL
|
||||
processing_time_ms : INT
|
||||
model_version : VARCHAR(50)
|
||||
keywords : JSON
|
||||
statistics : JSON
|
||||
created_at : TIMESTAMP
|
||||
}
|
||||
|
||||
entity "todos" {
|
||||
* **todo_id : VARCHAR(50)
|
||||
--
|
||||
meeting_id : VARCHAR(50) [FK] NOT NULL
|
||||
minutes_id : VARCHAR(50) [FK]
|
||||
title : VARCHAR(200) NOT NULL
|
||||
description : TEXT
|
||||
assignee_id : VARCHAR(50) NOT NULL
|
||||
due_date : DATE
|
||||
status : VARCHAR(20) NOT NULL
|
||||
priority : VARCHAR(20)
|
||||
extracted_by : VARCHAR(50) [V3]
|
||||
section_reference : VARCHAR(200) [V3]
|
||||
extraction_confidence : DECIMAL(3,2) [V3]
|
||||
completed_at : TIMESTAMP
|
||||
created_at : TIMESTAMP
|
||||
updated_at : TIMESTAMP
|
||||
}
|
||||
|
||||
'=== Relationships ===
|
||||
meetings ||--o{ meeting_participants : "1:N [V2]"
|
||||
meetings ||--o{ minutes : "1:N"
|
||||
meetings ||--o{ agenda_sections : "1:N [V3]"
|
||||
meetings ||--o{ ai_summaries : "1:N [V3]"
|
||||
meetings ||--o{ todos : "1:N"
|
||||
minutes ||--o{ minutes_sections : "1:N"
|
||||
minutes ||--o{ agenda_sections : "1:N [V3]"
|
||||
|
||||
'=== Legend ===
|
||||
legend right
|
||||
V2 = Migration 2 (2025-10-27)
|
||||
V3 = Migration 3 (2025-10-28)
|
||||
V4 = Migration 4 (2025-10-28)
|
||||
[FK] = Foreign Key
|
||||
**bold** = Important fields
|
||||
end legend
|
||||
|
||||
@enduml
|
||||
675
claude/database-schema-analysis.md
Normal file
675
claude/database-schema-analysis.md
Normal file
@ -0,0 +1,675 @@
|
||||
# Meeting Service 데이터베이스 스키마 전체 분석
|
||||
|
||||
## 1. 마이그레이션 파일 현황
|
||||
|
||||
### 마이그레이션 체인
|
||||
```
|
||||
V1 (초기) → V2 (회의 참석자) → V3 (회의종료) → V4 (todos)
|
||||
```
|
||||
|
||||
### 각 마이그레이션 내용
|
||||
- **V1**: 초기 스키마 (meetings, minutes, minutes_sections 등 - JPA로 자동 생성)
|
||||
- **V2**: `meeting_participants` 테이블 분리 (2025-10-27)
|
||||
- **V3**: 회의종료 기능 지원 (2025-10-28) - **주요 변경**
|
||||
- **V4**: `agenda_sections` 테이블에 `todos` 컬럼 추가 (2025-10-28)
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 테이블 구조 분석
|
||||
|
||||
### 2.1 meetings 테이블
|
||||
**용도**: 회의 기본 정보 저장
|
||||
|
||||
| 컬럼명 | 타입 | 설명 | 용도 |
|
||||
|--------|------|------|------|
|
||||
| meeting_id | VARCHAR(50) | PK | 회의 고유 식별자 |
|
||||
| title | VARCHAR(200) | NOT NULL | 회의 제목 |
|
||||
| purpose | VARCHAR(500) | | 회의 목적 |
|
||||
| description | TEXT | | 상세 설명 |
|
||||
| scheduled_at | TIMESTAMP | NOT NULL | 예정된 시간 |
|
||||
| started_at | TIMESTAMP | | 실제 시작 시간 |
|
||||
| ended_at | TIMESTAMP | | **V3 추가**: 실제 종료 시간 |
|
||||
| status | VARCHAR(20) | NOT NULL | 상태: SCHEDULED, IN_PROGRESS, COMPLETED |
|
||||
| organizer_id | VARCHAR(50) | NOT NULL | 회의 주최자 |
|
||||
| created_at | TIMESTAMP | | 생성 시간 |
|
||||
| updated_at | TIMESTAMP | | 수정 시간 |
|
||||
|
||||
**관계**:
|
||||
- 1:N with `meeting_participants` (V2에서 분리)
|
||||
- 1:N with `minutes`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 minutes 테이블
|
||||
**용도**: 회의록 기본 정보 + 사용자별 회의록 구분
|
||||
|
||||
| 컬럼명 | 타입 | 설명 | 용도 |
|
||||
|--------|------|------|------|
|
||||
| id/minutes_id | VARCHAR(50) | PK | 회의록 고유 식별자 |
|
||||
| meeting_id | VARCHAR(50) | FK | 해당 회의 ID |
|
||||
| user_id | VARCHAR(100) | **V3 추가** | NULL: AI 통합 회의록 / NOT NULL: 참석자별 회의록 |
|
||||
| title | VARCHAR(200) | NOT NULL | 회의록 제목 |
|
||||
| status | VARCHAR(20) | NOT NULL | DRAFT, FINALIZED |
|
||||
| version | INT | NOT NULL | 버전 관리 |
|
||||
| created_by | VARCHAR(50) | NOT NULL | 작성자 |
|
||||
| finalized_by | VARCHAR(50) | | 확정자 |
|
||||
| finalized_at | TIMESTAMP | | 확정 시간 |
|
||||
| created_at | TIMESTAMP | | 생성 시간 |
|
||||
| updated_at | TIMESTAMP | | 수정 시간 |
|
||||
|
||||
**중요**: `minutes` 테이블에는 `content` 컬럼이 **없음**
|
||||
- 실제 회의록 내용은 `minutes_sections`의 `content`에 저장됨
|
||||
- minutes는 메타데이터만 저장
|
||||
|
||||
**인덱스 (V3)**: `idx_minutes_meeting_user` on (meeting_id, user_id)
|
||||
|
||||
**관계**:
|
||||
- N:1 with `meetings`
|
||||
- 1:N with `minutes_sections`
|
||||
- 1:N with `agenda_sections` (V3 추가)
|
||||
|
||||
---
|
||||
|
||||
### 2.3 minutes_sections 테이블
|
||||
**용도**: 회의록 섹션별 상세 내용
|
||||
|
||||
| 컬럼명 | 타입 | 설명 |
|
||||
|--------|------|------|
|
||||
| id | VARCHAR(50) | PK |
|
||||
| minutes_id | VARCHAR(50) | FK to minutes |
|
||||
| type | VARCHAR(50) | AGENDA, DISCUSSION, DECISION, ACTION_ITEM |
|
||||
| title | VARCHAR(200) | 섹션 제목 |
|
||||
| **content** | TEXT | **섹션 상세 내용 저장** |
|
||||
| order | INT | 섹션 순서 |
|
||||
| verified | BOOLEAN | 검증 완료 여부 |
|
||||
| locked | BOOLEAN | 잠금 여부 |
|
||||
| locked_by | VARCHAR(50) | 잠금 사용자 |
|
||||
|
||||
**중요 사항**:
|
||||
- 회의록 실제 내용은 여기에 저장됨
|
||||
- `minutes`와 N:1 관계 (1개 회의록에 다중 섹션)
|
||||
- 사용자별 회의록도 각각 섹션을 가짐
|
||||
|
||||
---
|
||||
|
||||
### 2.4 agenda_sections 테이블 (V3 신규)
|
||||
**용도**: 안건별 AI 요약 결과 저장 (구조화된 형식)
|
||||
|
||||
| 컬럼명 | 타입 | 설명 | 포함 데이터 |
|
||||
|--------|------|------|-----------|
|
||||
| id | VARCHAR(36) | PK | UUID |
|
||||
| minutes_id | VARCHAR(36) | FK | 통합 회의록 참조 |
|
||||
| meeting_id | VARCHAR(50) | FK | 회의 ID |
|
||||
| agenda_number | INT | | 안건 번호 (1, 2, 3...) |
|
||||
| agenda_title | VARCHAR(200) | | 안건 제목 |
|
||||
| ai_summary_short | TEXT | | 짧은 요약 (1줄, 20자 이내) |
|
||||
| discussions | TEXT | | 논의 사항 (3-5문장) |
|
||||
| decisions | JSON | | 결정 사항 배열 |
|
||||
| pending_items | JSON | | 보류 사항 배열 |
|
||||
| opinions | JSON | | 참석자별 의견: [{speaker, opinion}] |
|
||||
| **todos** | JSON | **V4 추가** | 추출된 Todo: [{title, assignee, dueDate, description, priority}] |
|
||||
|
||||
**V4 추가 구조** (todos JSON):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "시장 조사 보고서 작성",
|
||||
"assignee": "김민준",
|
||||
"dueDate": "2025-02-15",
|
||||
"description": "20-30대 타겟 시장 조사",
|
||||
"priority": "HIGH"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**인덱스**:
|
||||
- `idx_sections_meeting` on meeting_id
|
||||
- `idx_sections_agenda` on (meeting_id, agenda_number)
|
||||
- `idx_sections_minutes` on minutes_id
|
||||
|
||||
**관계**:
|
||||
- N:1 with `minutes` (통합 회의록만 참조)
|
||||
- N:1 with `meetings`
|
||||
|
||||
---
|
||||
|
||||
### 2.5 minutes_section vs agenda_sections 차이점
|
||||
|
||||
| 특성 | minutes_sections | agenda_sections |
|
||||
|------|------------------|-----------------|
|
||||
| **용도** | 회의록 작성용 | AI 요약 결과 저장용 |
|
||||
| **구조** | 순차적 섹션 (type: AGENDA, DISCUSSION, DECISION) | 안건별 구조화된 데이터 |
|
||||
| **내용 저장** | content (TEXT) | 구조화된 필드 + JSON |
|
||||
| **소유 관계** | 모든 회의록 (사용자별 포함) | 통합 회의록만 (user_id=NULL) |
|
||||
| **목적** | 사용자 작성 | AI 자동 생성 |
|
||||
| **JSON 필드** | 없음 | decisions, pending_items, opinions, todos |
|
||||
|
||||
**생성 흐름**:
|
||||
```
|
||||
회의 종료 → 통합 회의록 (minutes, user_id=NULL)
|
||||
→ minutes_sections 생성 (사용자가 내용 작성)
|
||||
→ AI 분석 → agenda_sections 생성 (AI 요약 결과 저장)
|
||||
|
||||
동시에:
|
||||
→ 참석자별 회의록 (minutes, user_id NOT NULL)
|
||||
→ 참석자별 minutes_sections 생성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.6 ai_summaries 테이블 (V3 신규)
|
||||
**용도**: AI 요약 결과 캐싱
|
||||
|
||||
| 컬럼명 | 타입 | 설명 |
|
||||
|--------|------|------|
|
||||
| id | VARCHAR(36) | PK |
|
||||
| meeting_id | VARCHAR(50) | FK |
|
||||
| summary_type | VARCHAR(50) | CONSOLIDATED (통합 요약) / TODO_EXTRACTION (Todo 추출) |
|
||||
| source_minutes_ids | JSON | 통합에 사용된 회의록 ID 배열 |
|
||||
| result | JSON | **AI 응답 전체 결과** |
|
||||
| processing_time_ms | INT | AI 처리 시간 |
|
||||
| model_version | VARCHAR(50) | 사용 모델 (claude-3.5-sonnet) |
|
||||
| keywords | JSON | 주요 키워드 배열 |
|
||||
| statistics | JSON | 통계 (참석자 수, 안건 수 등) |
|
||||
|
||||
---
|
||||
|
||||
### 2.7 todos 테이블
|
||||
**용도**: Todo 아이템 저장
|
||||
|
||||
| 컬럼명 | 타입 | 설명 |
|
||||
|--------|------|------|
|
||||
| todo_id | VARCHAR(50) | PK |
|
||||
| minutes_id | VARCHAR(50) | FK | 관련 회의록 |
|
||||
| meeting_id | VARCHAR(50) | FK | 회의 ID |
|
||||
| title | VARCHAR(200) | 제목 |
|
||||
| description | TEXT | 상세 설명 |
|
||||
| assignee_id | VARCHAR(50) | 담당자 |
|
||||
| due_date | DATE | 마감일 |
|
||||
| status | VARCHAR(20) | PENDING, COMPLETED |
|
||||
| priority | VARCHAR(20) | HIGH, MEDIUM, LOW |
|
||||
| completed_at | TIMESTAMP | 완료 시간 |
|
||||
|
||||
**V3에서 추가된 컬럼**:
|
||||
```sql
|
||||
extracted_by VARCHAR(50) -- AI 또는 MANUAL
|
||||
section_reference VARCHAR(200) -- 관련 회의록 섹션 참조
|
||||
extraction_confidence DECIMAL(3,2) -- AI 신뢰도 (0.00~1.00)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.8 meeting_participants 테이블 (V2 신규)
|
||||
**용도**: 회의 참석자 정보 분리
|
||||
|
||||
| 컬럼명 | 타입 | 설명 |
|
||||
|--------|------|------|
|
||||
| meeting_id | VARCHAR(50) | PK1, FK |
|
||||
| user_id | VARCHAR(100) | PK2 |
|
||||
| invitation_status | VARCHAR(20) | PENDING, ACCEPTED, DECLINED |
|
||||
| attended | BOOLEAN | 참석 여부 |
|
||||
| created_at | TIMESTAMP | |
|
||||
| updated_at | TIMESTAMP | |
|
||||
|
||||
**변경 배경 (V2)**:
|
||||
- 이전: meetings.participants (CSV 문자열)
|
||||
- 현재: meeting_participants (별도 테이블, 정규화)
|
||||
|
||||
---
|
||||
|
||||
## 3. 회의록 작성 플로우에서의 테이블 사용
|
||||
|
||||
### 3.1 회의 시작 (StartMeeting)
|
||||
```
|
||||
meetings 테이블 UPDATE
|
||||
└─ status: SCHEDULED → IN_PROGRESS
|
||||
└─ started_at 기록
|
||||
```
|
||||
|
||||
### 3.2 회의 종료 (EndMeeting)
|
||||
```
|
||||
meetings 테이블 UPDATE
|
||||
├─ status: IN_PROGRESS → COMPLETED
|
||||
└─ ended_at 기록 (V3 신규)
|
||||
|
||||
↓
|
||||
|
||||
minutes 테이블 생성 (AI 통합 회의록)
|
||||
├─ user_id = NULL
|
||||
├─ status = DRAFT
|
||||
└─ 각 참석자별 회의록도 동시 생성
|
||||
└─ user_id = 참석자ID
|
||||
|
||||
↓
|
||||
|
||||
minutes_sections 테이블 초기 생성
|
||||
├─ 통합 회의록용 섹션
|
||||
└─ 각 참석자별 섹션
|
||||
```
|
||||
|
||||
### 3.3 회의록 작성 (CreateMinutes / UpdateMinutes)
|
||||
```
|
||||
minutes 테이블 UPDATE
|
||||
├─ title 작성
|
||||
└─ status 유지 (DRAFT)
|
||||
|
||||
↓
|
||||
|
||||
minutes_sections 테이블 INSERT/UPDATE
|
||||
├─ type: AGENDA, DISCUSSION, DECISION 등
|
||||
├─ title: 섹션 제목
|
||||
├─ content: 실제 회의록 내용 ← **여기에 사용자가 입력한 내용 저장**
|
||||
└─ order: 순서
|
||||
|
||||
사용자가 작성한 내용 저장 경로:
|
||||
minutes_sections.content (TEXT 컬럼)
|
||||
```
|
||||
|
||||
### 3.4 AI 분석 (FinializeMinutes + AI Processing)
|
||||
```
|
||||
minutes 테이블 UPDATE
|
||||
├─ status: DRAFT → FINALIZED
|
||||
└─ finalized_at 기록
|
||||
|
||||
↓
|
||||
|
||||
agenda_sections 테이블 INSERT
|
||||
├─ minutesId = 통합 회의록 ID (user_id=NULL)
|
||||
├─ AI 요약: aiSummaryShort, discussions
|
||||
├─ 구조화된 데이터: decisions, pendingItems, opinions (JSON)
|
||||
└─ todos (V4): AI 추출 Todo (JSON)
|
||||
|
||||
↓
|
||||
|
||||
ai_summaries 테이블 INSERT
|
||||
├─ summary_type: CONSOLIDATED
|
||||
├─ result: AI 응답 전체 결과
|
||||
└─ keywords, statistics
|
||||
|
||||
↓
|
||||
|
||||
todos 테이블 INSERT (선택)
|
||||
├─ 간단한 Todo는 agenda_sections.todos에만 저장
|
||||
└─ 상세 관리 필요한 경우 별도 테이블 저장
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 사용자별 회의록 저장 구조
|
||||
|
||||
### 4.1 회의 종료 시 자동 생성
|
||||
|
||||
```
|
||||
1개의 회의 → 여러 회의록
|
||||
├─ AI 통합 회의록 (minutes.user_id = NULL)
|
||||
│ ├─ minutes_sections (AI/시스템이 생성)
|
||||
│ └─ agenda_sections (AI 분석 결과)
|
||||
│
|
||||
└─ 각 참석자별 회의록 (minutes.user_id = 참석자ID)
|
||||
├─ User1의 회의록 (minutes.user_id = 'user1@example.com')
|
||||
│ └─ minutes_sections (User1이 작성)
|
||||
│
|
||||
├─ User2의 회의록 (minutes.user_id = 'user2@example.com')
|
||||
│ └─ minutes_sections (User2이 작성)
|
||||
│
|
||||
└─ ...
|
||||
```
|
||||
|
||||
### 4.2 minutes 테이블 쿼리 예시
|
||||
|
||||
```sql
|
||||
-- 특정 회의의 AI 통합 회의록
|
||||
SELECT * FROM minutes
|
||||
WHERE meeting_id = 'meeting-001' AND user_id IS NULL;
|
||||
|
||||
-- 특정 회의의 참석자별 회의록
|
||||
SELECT * FROM minutes
|
||||
WHERE meeting_id = 'meeting-001' AND user_id IS NOT NULL;
|
||||
|
||||
-- 특정 사용자의 회의록
|
||||
SELECT * FROM minutes
|
||||
WHERE user_id = 'user1@example.com';
|
||||
|
||||
-- 참석자별로 회의록 조회 (복합 인덱스 활용)
|
||||
SELECT * FROM minutes
|
||||
WHERE meeting_id = 'meeting-001' AND user_id = 'user1@example.com';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. V3 마이그레이션의 주요 변경사항
|
||||
|
||||
### 5.1 minutes 테이블 확장
|
||||
```sql
|
||||
ALTER TABLE minutes ADD COLUMN IF NOT EXISTS user_id VARCHAR(100);
|
||||
CREATE INDEX IF NOT EXISTS idx_minutes_meeting_user ON minutes(meeting_id, user_id);
|
||||
```
|
||||
|
||||
**영향**:
|
||||
- 기존 회의록: `user_id = NULL` (AI 통합 회의록)
|
||||
- 새 회의록: `user_id = 참석자ID` (참석자별)
|
||||
- 쿼리 성능: 복합 인덱스로 빠른 검색
|
||||
|
||||
### 5.2 agenda_sections 테이블 신규 생성
|
||||
- AI 요약을 구조화된 형식으로 저장
|
||||
- JSON 필드로 결정사항, 보류사항, 의견, Todo 저장
|
||||
- minutes_id로 통합 회의록과 연결
|
||||
|
||||
### 5.3 ai_summaries 테이블 신규 생성
|
||||
- AI 처리 결과 캐싱
|
||||
- 처리 시간, 모델 버전 기록
|
||||
- 재처리 필요 시 참조 가능
|
||||
|
||||
### 5.4 todos 테이블 확장
|
||||
```sql
|
||||
ALTER TABLE todos ADD COLUMN extracted_by VARCHAR(50) DEFAULT 'AI';
|
||||
ALTER TABLE todos ADD COLUMN section_reference VARCHAR(200);
|
||||
ALTER TABLE todos ADD COLUMN extraction_confidence DECIMAL(3,2) DEFAULT 0.00;
|
||||
```
|
||||
|
||||
**목적**:
|
||||
- AI 자동 추출 vs 수동 작성 구분
|
||||
- Todo의 출처 추적
|
||||
- AI 신뢰도 관리
|
||||
|
||||
---
|
||||
|
||||
## 6. V4 마이그레이션의 변경사항
|
||||
|
||||
### 6.1 agenda_sections 테이블에 todos 컬럼 추가
|
||||
```sql
|
||||
ALTER TABLE agenda_sections ADD COLUMN IF NOT EXISTS todos JSON;
|
||||
```
|
||||
|
||||
**구조**:
|
||||
```json
|
||||
{
|
||||
"title": "시장 조사 보고서 작성",
|
||||
"assignee": "김민준",
|
||||
"dueDate": "2025-02-15",
|
||||
"description": "20-30대 타겟 시장 조사",
|
||||
"priority": "HIGH"
|
||||
}
|
||||
```
|
||||
|
||||
**저장 경로**:
|
||||
- **안건별 요약의 Todo**: `agenda_sections.todos` (JSON)
|
||||
- **개별 Todo 관리**: `todos` 테이블 (필요시)
|
||||
|
||||
---
|
||||
|
||||
## 7. 데이터 정규화 현황
|
||||
|
||||
### 7.1 정규화 수행 (V2)
|
||||
```
|
||||
meetings (이전):
|
||||
participants: "user1@example.com,user2@example.com"
|
||||
|
||||
↓ 정규화 (V2 마이그레이션)
|
||||
|
||||
meetings_participants (별도 테이블):
|
||||
[meeting_id, user_id] (복합 PK)
|
||||
invitation_status
|
||||
attended
|
||||
```
|
||||
|
||||
### 7.2 JSON 필드 사용 (V3, V4)
|
||||
- `decisions`, `pending_items`, `opinions`, `todos` (agenda_sections)
|
||||
- `keywords`, `statistics` (ai_summaries)
|
||||
- `source_minutes_ids` (ai_summaries)
|
||||
|
||||
**사용 이유**:
|
||||
- 변동적인 구조 데이터
|
||||
- AI 응답의 유연한 저장
|
||||
- 쿼리 패턴이 검색보다 전체 조회
|
||||
|
||||
---
|
||||
|
||||
## 8. 핵심 질문 답변
|
||||
|
||||
### Q1: minutes 테이블에 content 필드가 있는가?
|
||||
**A**: **없음**. 회의록 실제 내용은 `minutes_sections.content`에 저장됨.
|
||||
|
||||
### Q2: minutes_section과 agenda_sections의 차이점?
|
||||
| 항목 | minutes_sections | agenda_sections |
|
||||
|------|-----------------|-----------------|
|
||||
| 목적 | 사용자 작성 | AI 요약 |
|
||||
| 모든 회의록 | O | X (통합만) |
|
||||
| 구조 | 순차적 | 안건별 |
|
||||
| 내용 저장 | content (TEXT) | JSON |
|
||||
|
||||
### Q3: 사용자별 회의록을 저장할 적절한 구조는?
|
||||
**A**:
|
||||
- `minutes` 테이블: `user_id` 컬럼으로 구분
|
||||
- `minutes_sections`: 각 회의록의 섹션
|
||||
- 인덱스: `idx_minutes_meeting_user` (meeting_id, user_id)
|
||||
|
||||
### Q4: V3, V4 주요 변경사항은?
|
||||
- **V3**: user_id 추가, agenda_sections 신규, ai_summaries 신규, todos 확장
|
||||
- **V4**: agenda_sections.todos JSON 필드 추가
|
||||
|
||||
---
|
||||
|
||||
## 9. 데이터베이스 구조도 (PlantUML)
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
!theme mono
|
||||
|
||||
entity "meetings" as meetings {
|
||||
* meeting_id: VARCHAR(50)
|
||||
--
|
||||
title: VARCHAR(200)
|
||||
status: VARCHAR(20)
|
||||
organizer_id: VARCHAR(50)
|
||||
started_at: TIMESTAMP
|
||||
ended_at: TIMESTAMP [V3]
|
||||
created_at: TIMESTAMP
|
||||
updated_at: TIMESTAMP
|
||||
}
|
||||
|
||||
entity "meeting_participants" as participants {
|
||||
* meeting_id: VARCHAR(50) [FK]
|
||||
* user_id: VARCHAR(100)
|
||||
--
|
||||
invitation_status: VARCHAR(20)
|
||||
attended: BOOLEAN
|
||||
}
|
||||
|
||||
entity "minutes" as minutes {
|
||||
* id: VARCHAR(50)
|
||||
--
|
||||
meeting_id: VARCHAR(50) [FK]
|
||||
user_id: VARCHAR(100) [V3]
|
||||
title: VARCHAR(200)
|
||||
status: VARCHAR(20)
|
||||
created_by: VARCHAR(50)
|
||||
finalized_at: TIMESTAMP
|
||||
}
|
||||
|
||||
entity "minutes_sections" as sections {
|
||||
* id: VARCHAR(50)
|
||||
--
|
||||
minutes_id: VARCHAR(50) [FK]
|
||||
type: VARCHAR(50)
|
||||
title: VARCHAR(200)
|
||||
content: TEXT
|
||||
locked: BOOLEAN
|
||||
}
|
||||
|
||||
entity "agenda_sections" as agenda {
|
||||
* id: VARCHAR(36)
|
||||
--
|
||||
minutes_id: VARCHAR(36) [FK, 통합회의록만]
|
||||
meeting_id: VARCHAR(50) [FK]
|
||||
agenda_number: INT
|
||||
agenda_title: VARCHAR(200)
|
||||
ai_summary_short: TEXT
|
||||
discussions: TEXT
|
||||
decisions: JSON
|
||||
opinions: JSON
|
||||
todos: JSON [V4]
|
||||
}
|
||||
|
||||
entity "ai_summaries" as summaries {
|
||||
* id: VARCHAR(36)
|
||||
--
|
||||
meeting_id: VARCHAR(50) [FK]
|
||||
summary_type: VARCHAR(50)
|
||||
result: JSON
|
||||
keywords: JSON
|
||||
statistics: JSON
|
||||
}
|
||||
|
||||
entity "todos" as todos {
|
||||
* todo_id: VARCHAR(50)
|
||||
--
|
||||
meeting_id: VARCHAR(50) [FK]
|
||||
minutes_id: VARCHAR(50) [FK]
|
||||
title: VARCHAR(200)
|
||||
assignee_id: VARCHAR(50)
|
||||
status: VARCHAR(20)
|
||||
extracted_by: VARCHAR(50) [V3]
|
||||
}
|
||||
|
||||
meetings ||--o{ participants: "1:N"
|
||||
meetings ||--o{ minutes: "1:N"
|
||||
meetings ||--o{ agenda: "1:N"
|
||||
meetings ||--o{ todos: "1:N"
|
||||
minutes ||--o{ sections: "1:N"
|
||||
minutes ||--o{ agenda: "1:N"
|
||||
meetings ||--o{ summaries: "1:N"
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 회의록 작성 전체 플로우
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 1. 회의 시작 (StartMeeting) │
|
||||
│ ├─ meetings.status = IN_PROGRESS │
|
||||
│ └─ meetings.started_at 기록 │
|
||||
└─────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────────────────┐
|
||||
│ 2. 회의 진행 중 (회의록 작성) │
|
||||
│ ├─ CreateMinutes: minutes 생성 (user_id=NULL 통합) │
|
||||
│ ├─ CreateMinutes: 참석자별 minutes 생성 │
|
||||
│ ├─ UpdateMinutes: minutes_sections 작성 │
|
||||
│ │ └─ content에 회의 내용 저장 │
|
||||
│ └─ SaveMinutes: draft 상태 유지 │
|
||||
└─────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────────────────┐
|
||||
│ 3. 회의 종료 (EndMeeting) │
|
||||
│ ├─ meetings.status = COMPLETED │
|
||||
│ ├─ meetings.ended_at = NOW() [V3] │
|
||||
│ └─ 회의 기본 정보 확정 │
|
||||
└─────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────────────────┐
|
||||
│ 4. 회의록 최종화 (FinalizeMinutes) │
|
||||
│ ├─ minutes.status = FINALIZED │
|
||||
│ ├─ minutes.finalized_by = 확정자 │
|
||||
│ ├─ minutes.finalized_at = NOW() │
|
||||
│ └─ minutes_sections 내용 확정 (locked) │
|
||||
└─────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────────────────┐
|
||||
│ 5. AI 분석 처리 (MinutesAnalysisEventConsumer) │
|
||||
│ ├─ 통합 회의록 분석 (user_id=NULL) │
|
||||
│ │ │
|
||||
│ ├─ agenda_sections INSERT [V3] │
|
||||
│ │ ├─ minutes_id = 통합 회의록 ID │
|
||||
│ │ ├─ ai_summary_short, discussions │
|
||||
│ │ ├─ decisions, pending_items, opinions (JSON) │
|
||||
│ │ └─ todos (JSON) [V4] │
|
||||
│ │ │
|
||||
│ ├─ ai_summaries INSERT [V3] │
|
||||
│ │ ├─ summary_type = CONSOLIDATED │
|
||||
│ │ ├─ result = AI 응답 전체 │
|
||||
│ │ └─ keywords, statistics │
|
||||
│ │ │
|
||||
│ └─ todos TABLE INSERT (선택) │
|
||||
│ ├─ extracted_by = 'AI' [V3] │
|
||||
│ └─ extraction_confidence [V3] │
|
||||
└─────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────────────────┐
|
||||
│ 6. 회의록 조회 │
|
||||
│ ├─ 통합 회의록 조회 │
|
||||
│ │ └─ minutes + minutes_sections + agenda_sections │
|
||||
│ ├─ 참석자별 회의록 조회 │
|
||||
│ │ └─ minutes (user_id=참석자) + minutes_sections │
|
||||
│ └─ Todo 조회 │
|
||||
│ └─ agenda_sections.todos 또는 todos 테이블 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 성능 최적화 포인트
|
||||
|
||||
### 11.1 인덱스 현황
|
||||
```
|
||||
meetings:
|
||||
- PK: meeting_id
|
||||
|
||||
minutes:
|
||||
- PK: id
|
||||
- idx_minutes_meeting_user (meeting_id, user_id) [V3] ← 핵심
|
||||
|
||||
minutes_sections:
|
||||
- PK: id
|
||||
- FK: minutes_id
|
||||
|
||||
agenda_sections: [V3]
|
||||
- PK: id
|
||||
- idx_sections_meeting (meeting_id)
|
||||
- idx_sections_agenda (meeting_id, agenda_number)
|
||||
- idx_sections_minutes (minutes_id)
|
||||
|
||||
ai_summaries: [V3]
|
||||
- PK: id
|
||||
- idx_summaries_meeting (meeting_id)
|
||||
- idx_summaries_type (meeting_id, summary_type)
|
||||
- idx_summaries_created (created_at)
|
||||
|
||||
todos:
|
||||
- PK: todo_id
|
||||
- idx_todos_extracted (extracted_by) [V3]
|
||||
- idx_todos_meeting (meeting_id) [V3]
|
||||
|
||||
meeting_participants: [V2]
|
||||
- PK: (meeting_id, user_id)
|
||||
- idx_user_id (user_id)
|
||||
- idx_invitation_status (invitation_status)
|
||||
```
|
||||
|
||||
### 11.2 추천 추가 인덱스
|
||||
```sql
|
||||
-- 빠른 조회를 위한 인덱스
|
||||
CREATE INDEX idx_minutes_status ON minutes(status, created_at DESC);
|
||||
CREATE INDEX idx_agenda_meeting_created ON agenda_sections(meeting_id, created_at DESC);
|
||||
CREATE INDEX idx_todos_meeting_assignee ON todos(meeting_id, assignee_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 결론
|
||||
|
||||
### 핵심 설계 원칙
|
||||
1. **참석자별 회의록**: minutes.user_id로 구분 (NULL=AI 통합, NOT NULL=개인)
|
||||
2. **내용 저장**: minutes_sections.content에 사용자가 작성한 내용 저장
|
||||
3. **구조화된 요약**: agenda_sections에 AI 요약을 JSON으로 저장
|
||||
4. **추적 가능성**: extracted_by, section_reference로 Todo 출처 추적
|
||||
5. **정규화**: V2에서 meeting_participants로 정규화 완료
|
||||
|
||||
### 주의사항
|
||||
- `minutes` 테이블 자체는 메타데이터만 저장 (title, status 등)
|
||||
- 실제 회의 내용: `minutes_sections.content`
|
||||
- AI 요약 결과: `agenda_sections` (구조화됨)
|
||||
- Todo는 두 곳에 저장 가능: agenda_sections.todos (JSON) / todos 테이블
|
||||
27
deploy/k8s/backend/ai-secret-template.yaml
Normal file
27
deploy/k8s/backend/ai-secret-template.yaml
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
# AI Service Secret Template
|
||||
# 실제 배포 시 base64로 인코딩된 값으로 교체 필요
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ai-secret
|
||||
namespace: hgzero
|
||||
type: Opaque
|
||||
data:
|
||||
# Claude API Key (base64 인코딩 필요)
|
||||
# echo -n "sk-ant-api03-..." | base64
|
||||
claude-api-key: <BASE64_ENCODED_CLAUDE_API_KEY>
|
||||
|
||||
---
|
||||
# Azure EventHub Secret for AI Service
|
||||
# AI 서비스용 Event Hub 연결 문자열
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: azure-secret
|
||||
namespace: hgzero
|
||||
type: Opaque
|
||||
data:
|
||||
# Event Hub Connection String (AI Listen Policy)
|
||||
# echo -n "Endpoint=sb://..." | base64
|
||||
eventhub-ai-connection-string: <BASE64_ENCODED_EVENTHUB_CONNECTION_STRING>
|
||||
130
deploy/k8s/backend/ai-service.yaml
Normal file
130
deploy/k8s/backend/ai-service.yaml
Normal file
@ -0,0 +1,130 @@
|
||||
---
|
||||
# AI Service (Python) Deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ai-service
|
||||
namespace: hgzero
|
||||
labels:
|
||||
app: ai-service
|
||||
tier: backend
|
||||
language: python
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ai-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ai-service
|
||||
tier: backend
|
||||
language: python
|
||||
spec:
|
||||
containers:
|
||||
- name: ai-service
|
||||
image: acrdigitalgarage02.azurecr.io/hgzero/ai-service:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8087
|
||||
name: http
|
||||
env:
|
||||
# 서버 설정
|
||||
- name: PORT
|
||||
value: "8087"
|
||||
- name: HOST
|
||||
value: "0.0.0.0"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
|
||||
# Claude API
|
||||
- name: CLAUDE_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ai-secret
|
||||
key: claude-api-key
|
||||
- name: CLAUDE_MODEL
|
||||
value: "claude-3-5-sonnet-20241022"
|
||||
- name: CLAUDE_MAX_TOKENS
|
||||
value: "2000"
|
||||
- name: CLAUDE_TEMPERATURE
|
||||
value: "0.3"
|
||||
|
||||
# Redis
|
||||
- name: REDIS_HOST
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: redis-config
|
||||
key: host
|
||||
- name: REDIS_PORT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: redis-config
|
||||
key: port
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: redis-secret
|
||||
key: password
|
||||
- name: REDIS_DB
|
||||
value: "4"
|
||||
|
||||
# Azure Event Hub
|
||||
- name: EVENTHUB_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: azure-secret
|
||||
key: eventhub-ai-connection-string
|
||||
- name: EVENTHUB_NAME
|
||||
value: "hgzero-eventhub-name"
|
||||
- name: EVENTHUB_CONSUMER_GROUP
|
||||
value: "$Default"
|
||||
|
||||
# CORS
|
||||
- name: CORS_ORIGINS
|
||||
value: '["*"]'
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1024Mi
|
||||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8087
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8087
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
---
|
||||
# AI Service Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ai-service
|
||||
namespace: hgzero
|
||||
labels:
|
||||
app: ai-service
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 8087
|
||||
targetPort: 8087
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: ai-service
|
||||
@ -857,6 +857,10 @@ components:
|
||||
type: string
|
||||
description: 공통 키워드
|
||||
example: ["MSA", "API Gateway", "Spring Boot"]
|
||||
summary:
|
||||
type: string
|
||||
description: 회의록 핵심 내용 요약 (1-2문장)
|
||||
example: "MSA 아키텍처 설계 및 API Gateway 도입을 결정. 서비스별 독립 배포 전략 수립."
|
||||
link:
|
||||
type: string
|
||||
description: 회의록 링크
|
||||
@ -880,9 +884,22 @@ components:
|
||||
example: 0.92
|
||||
category:
|
||||
type: string
|
||||
enum: [기술, 업무, 도메인]
|
||||
enum: [기술, 업무, 도메인, 기획, 비즈니스, 전략, 마케팅]
|
||||
description: 용어 카테고리
|
||||
example: "기술"
|
||||
definition:
|
||||
type: string
|
||||
description: 용어 정의 (간단한 설명)
|
||||
example: "Microservices Architecture의 약자. 애플리케이션을 작은 독립적인 서비스로 나누는 아키텍처 패턴"
|
||||
context:
|
||||
type: string
|
||||
description: 용어가 사용된 맥락 (과거 회의록 참조)
|
||||
example: "신제품 기획 회의(2024-09-15)에서 언급"
|
||||
relatedMeetingId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: 관련 회의 ID (용어가 논의된 과거 회의)
|
||||
example: "bb0e8400-e29b-41d4-a716-446655440006"
|
||||
highlight:
|
||||
type: boolean
|
||||
description: 하이라이트 여부
|
||||
|
||||
@ -1,832 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>대시보드 - 회의록 서비스</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
<style>
|
||||
/* Mobile 프로필 버튼 (데스크톱에서 숨김) */
|
||||
.mobile-profile-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mobile-profile-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 프로필 드롭다운 */
|
||||
.profile-dropdown {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
right: 16px;
|
||||
width: 280px;
|
||||
background: var(--white);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--space-md);
|
||||
z-index: 2001;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-dropdown.show {
|
||||
display: block;
|
||||
animation: slideDown 0.2s ease;
|
||||
}
|
||||
|
||||
.profile-dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: var(--space-md);
|
||||
border-bottom: 1px solid var(--gray-300);
|
||||
}
|
||||
|
||||
.profile-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-overlay.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop에서 프로필 드롭다운 숨김 */
|
||||
@media (min-width: 768px) {
|
||||
.profile-dropdown,
|
||||
.profile-overlay {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 대시보드 헤더 커스터마이징 */
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-title {
|
||||
font-size: var(--font-h2);
|
||||
}
|
||||
|
||||
.header-title img {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-subtitle {
|
||||
font-size: var(--font-small);
|
||||
}
|
||||
}
|
||||
|
||||
/* 통계 카드 - common.css의 공통 스타일 사용 */
|
||||
.stat-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--font-small);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--font-h2);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
/* 섹션 헤더 */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-h3);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.section-link {
|
||||
font-size: var(--font-small);
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 회의 카드 */
|
||||
.meeting-grid {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.meeting-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.meeting-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.meeting-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.meeting-card {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.meeting-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.meeting-card.ongoing {
|
||||
border-left: 4px solid var(--ongoing);
|
||||
}
|
||||
|
||||
.meeting-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.meeting-card-title {
|
||||
font-size: var(--font-body);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.meeting-card-meta {
|
||||
font-size: var(--font-small);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.meeting-card-meta-item {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meeting-card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
/* Todo 리스트 */
|
||||
.todo-list {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
padding: var(--space-md);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.todo-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.todo-item:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.todo-item.overdue {
|
||||
border-left: 4px solid var(--error);
|
||||
padding-left: calc(var(--space-md) - 4px);
|
||||
}
|
||||
|
||||
.todo-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.todo-title {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.todo-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
font-size: var(--font-small);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.todo-progress {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* 회의록 리스트 */
|
||||
.minutes-list {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.minutes-item {
|
||||
padding: var(--space-md);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.minutes-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.minutes-item:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.minutes-item-title {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.minutes-item-meta {
|
||||
font-size: var(--font-small);
|
||||
color: var(--gray-500);
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-xxl);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
/* 플로팅 액션 메뉴 */
|
||||
.fab-menu {
|
||||
position: fixed;
|
||||
bottom: 160px; /* 모바일: FAB 버튼(88px) + FAB 높이(56px) + 여백(16px) */
|
||||
right: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
z-index: 999;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
pointer-events: none;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.fab-menu.active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* FAB 메뉴 아이템 & FAB 버튼 공통 스타일 */
|
||||
.fab-menu-item,
|
||||
.fab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 100px;
|
||||
height: 56px;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
padding: 0 var(--space-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--white);
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
box-shadow: var(--shadow-fab);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fab-menu-item:hover,
|
||||
.fab:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.fab-menu-item:active,
|
||||
.fab:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
/* FAB 회전 효과 제거 */
|
||||
.fab.active {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
/* 오버레이 */
|
||||
.fab-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 998;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.fab-overlay.active {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* 데스크톱에서 위치 조정 */
|
||||
@media (min-width: 768px) {
|
||||
.fab-menu {
|
||||
bottom: 96px; /* 데스크톱: FAB 버튼(24px) + FAB 높이(56px) + 여백(16px) */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="layout-sidebar-header">
|
||||
<!-- 사이드바 (데스크톱) -->
|
||||
<aside class="sidebar">
|
||||
<a href="02-대시보드.html" class="sidebar-logo">
|
||||
<img src="img/cicle.png" alt="로고" class="sidebar-logo-icon-img">
|
||||
<div class="sidebar-logo-text">회의록 서비스</div>
|
||||
</a>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a href="12-회의록목록조회.html" class="sidebar-nav-item">
|
||||
<span class="sidebar-nav-icon"><img src="img/edit.png" width="32"></span>
|
||||
<span>회의 목록</span>
|
||||
</a>
|
||||
<a href="09-Todo관리.html" class="sidebar-nav-item">
|
||||
<span class="sidebar-nav-icon"><img src="img/list.png" width="32"></span>
|
||||
<span>Todo 관리</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- 사용자 정보 영역 (Desktop) -->
|
||||
<div class="sidebar-user">
|
||||
<div class="avatar avatar-green">김</div>
|
||||
<div class="sidebar-user-info">
|
||||
<div class="sidebar-user-name">김민준</div>
|
||||
<div class="sidebar-user-email">minjun.kim@company.com</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost" style="width: calc(100% - 32px); margin: 0 16px 16px;" onclick="logout()">로그아웃</button>
|
||||
</aside>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="header-title"><img src="img/hi.png" alt="" style="width: 18px; height: 18px; vertical-align: middle; margin-right: 6px;">안녕하세요, 김민준님!</h1>
|
||||
<p class="header-subtitle">오늘의 일정을 확인하세요</p>
|
||||
</div>
|
||||
<!-- Mobile 프로필 아이콘 -->
|
||||
<button class="icon-btn mobile-profile-btn" onclick="toggleProfileMenu()" title="프로필">
|
||||
👤
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Mobile 프로필 드롭다운 -->
|
||||
<div class="profile-dropdown" id="profileDropdown">
|
||||
<div class="profile-dropdown-header">
|
||||
<div class="avatar avatar-green">김</div>
|
||||
<div style="margin-left: 12px;">
|
||||
<div style="font-weight: 600; font-size: 14px; color: var(--gray-900);">김민준</div>
|
||||
<div style="font-size: 12px; color: var(--gray-500);">minjun.kim@company.com</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost" style="width: 100%; margin-top: 12px;" onclick="logout()">로그아웃</button>
|
||||
</div>
|
||||
<div class="profile-overlay" id="profileOverlay" onclick="toggleProfileMenu()"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
|
||||
<!-- 통계 -->
|
||||
<section class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-label">예정된 회의</div>
|
||||
<div class="stat-value" id="stat-scheduled">3</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-label">진행 중 Todo</div>
|
||||
<div class="stat-value" id="stat-todos">1</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📈</div>
|
||||
<div class="stat-label">Todo 완료율</div>
|
||||
<div class="stat-value" id="stat-completion">33%</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 최근 회의 -->
|
||||
<section>
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">최근 회의</h2>
|
||||
<a href="12-회의록목록조회.html" class="section-link">전체 보기 →</a>
|
||||
</div>
|
||||
<div class="meeting-grid" id="recent-meetings">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 할당된 Todo -->
|
||||
<section>
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">할당된 Todo</h2>
|
||||
<a href="09-Todo관리.html" class="section-link">전체 보기 →</a>
|
||||
</div>
|
||||
<div class="todo-list" id="my-todos">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 내 회의록 -->
|
||||
<section>
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">내 회의록</h2>
|
||||
<a href="12-회의록목록조회.html" class="section-link">전체 보기 →</a>
|
||||
</div>
|
||||
<div class="minutes-list" id="my-minutes">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- 하단 네비게이션 (모바일) -->
|
||||
<nav class="bottom-nav">
|
||||
<a href="02-대시보드.html" class="nav-item active">
|
||||
<img src="img/home.png" alt="홈" style="width: 45px;">
|
||||
</a>
|
||||
<a href="12-회의록목록조회.html" class="nav-item">
|
||||
<img src="img/edit.png" alt="회의록" style="width: 45px;">
|
||||
</a>
|
||||
<a href="09-Todo관리.html" class="nav-item">
|
||||
<img src="img/list.png" alt="Todo" style="width: 45px;">
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- FAB 오버레이 -->
|
||||
<div class="fab-overlay" id="fabOverlay"></div>
|
||||
|
||||
<!-- FAB 확장 메뉴 (단일 버튼) -->
|
||||
<div class="fab-menu" id="fabMenu">
|
||||
<button class="fab-menu-item" id="quickStartBtn" title="바로 시작">
|
||||
바로시작
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- FAB (Floating Action Button) - 회의 예약 -->
|
||||
<button class="fab" id="fabButton" title="회의 예약">
|
||||
회의예약
|
||||
</button>
|
||||
|
||||
<script src="common.js"></script>
|
||||
<script>
|
||||
/**
|
||||
* 대시보드 초기화
|
||||
*/
|
||||
|
||||
// 로그인 체크
|
||||
if (!getFromStorage('isLoggedIn')) {
|
||||
navigateTo('01-로그인.html');
|
||||
}
|
||||
|
||||
const currentUser = getFromStorage('currentUser') || CURRENT_USER;
|
||||
|
||||
/**
|
||||
* 최근 회의 렌더링
|
||||
*/
|
||||
function renderRecentMeetings() {
|
||||
const container = $('#recent-meetings');
|
||||
|
||||
// 진행중 우선, 날짜순 정렬
|
||||
const meetings = [...SAMPLE_MEETINGS]
|
||||
.sort((a, b) => {
|
||||
if (a.status === 'ongoing' && b.status !== 'ongoing') return -1;
|
||||
if (a.status !== 'ongoing' && b.status === 'ongoing') return 1;
|
||||
return new Date(b.date + ' ' + b.time) - new Date(a.date + ' ' + a.time);
|
||||
})
|
||||
.slice(0, 3);
|
||||
|
||||
container.innerHTML = meetings.map(meeting => {
|
||||
const statusInfo = getMeetingStatusInfo(meeting);
|
||||
const isCreator = meeting.participants.some(p => p.id === currentUser.id && p.role === 'creator');
|
||||
|
||||
return `
|
||||
<div class="meeting-card ${meeting.status === 'ongoing' ? 'ongoing' : ''}" data-id="${meeting.id}">
|
||||
<div class="meeting-card-header">
|
||||
<div>
|
||||
${createBadge(statusInfo.badgeText, statusInfo.badgeType)}
|
||||
${isCreator ? ' <span>👑</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="meeting-card-title">${meeting.title}</h3>
|
||||
<div class="meeting-card-meta">
|
||||
<div class="meeting-card-meta-item">📅 ${formatDate(meeting.date)} ${formatTime(meeting.time)}</div>
|
||||
<div class="meeting-card-meta-item">📍 ${meeting.location}</div>
|
||||
<div class="meeting-card-meta-item">👥 ${meeting.participants.length}명</div>
|
||||
</div>
|
||||
<div class="meeting-card-actions">
|
||||
${meeting.status === 'ongoing'
|
||||
? `<button class="btn btn-primary btn-sm" onclick="navigateTo('05-회의진행.html'); event.stopPropagation();">참여하기</button>`
|
||||
: meeting.status === 'scheduled' && isCreator
|
||||
? `<button class="btn btn-secondary btn-sm" onclick="navigateTo('03-회의예약.html'); event.stopPropagation();">수정</button>`
|
||||
: `<button class="btn btn-ghost btn-sm" onclick="navigateTo('10-회의록상세조회.html'); event.stopPropagation();">보기</button>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 클릭 이벤트
|
||||
$$('.meeting-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.tagName !== 'BUTTON') {
|
||||
const meetingId = card.dataset.id;
|
||||
const meeting = SAMPLE_MEETINGS.find(m => m.id === meetingId);
|
||||
if (meeting.status === 'ongoing') {
|
||||
navigateTo('05-회의진행.html');
|
||||
} else if (meeting.status === 'completed') {
|
||||
navigateTo('10-회의록상세조회.html');
|
||||
} else {
|
||||
navigateTo('03-회의예약.html');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 Todo 렌더링
|
||||
*/
|
||||
function renderMyTodos() {
|
||||
const container = $('#my-todos');
|
||||
|
||||
const myTodos = SAMPLE_TODOS
|
||||
.filter(todo => todo.assignee.id === currentUser.id)
|
||||
.sort((a, b) => {
|
||||
const priorityOrder = { overdue: 0, in_progress: 1, not_started: 2, completed: 3 };
|
||||
return priorityOrder[a.status] - priorityOrder[b.status];
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
if (myTodos.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="empty-icon">✅</div><p>할당된 Todo가 없습니다</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = myTodos.map(todo => {
|
||||
const statusInfo = getTodoStatusInfo(todo);
|
||||
const isOverdue = calculateDday(todo.dueDate) < 0 && todo.status !== 'completed';
|
||||
|
||||
return `
|
||||
<div class="todo-item ${isOverdue ? 'overdue' : ''}" data-todo-id="${todo.id}" data-meeting-id="${todo.meetingId}">
|
||||
<div class="todo-header">
|
||||
<h4 class="todo-title">${todo.title}</h4>
|
||||
${createBadge(statusInfo.badgeText, statusInfo.badgeType)}
|
||||
</div>
|
||||
<div class="todo-meta">
|
||||
<span>마감: ${formatDate(todo.dueDate)}</span>
|
||||
${createBadge(todo.priority === 'high' ? '높음' : todo.priority === 'medium' ? '보통' : '낮음', todo.priority)}
|
||||
</div>
|
||||
<div class="todo-progress">
|
||||
${createProgressBar(todo.progress)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Todo 항목 클릭 시 해당 회의록 상세로 이동
|
||||
$$('.todo-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const meetingId = item.dataset.meetingId;
|
||||
const todoId = item.dataset.todoId;
|
||||
// 회의록 상세 페이지로 이동 (Todo ID를 파라미터로 전달)
|
||||
navigateTo(`10-회의록상세조회.html?meetingId=${meetingId}&todoId=${todoId}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 회의록 렌더링
|
||||
*/
|
||||
function renderMyMinutes() {
|
||||
const container = $('#my-minutes');
|
||||
|
||||
const myMeetings = SAMPLE_MEETINGS
|
||||
.filter(m => m.participants.some(p => p.id === currentUser.id && p.role === 'creator'))
|
||||
.slice(0, 3);
|
||||
|
||||
if (myMeetings.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="empty-icon">📝</div><p>작성한 회의록이 없습니다</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = myMeetings.map(meeting => {
|
||||
const statusInfo = getMeetingStatusInfo(meeting);
|
||||
return `
|
||||
<div class="minutes-item" onclick="navigateTo('10-회의록상세조회.html')">
|
||||
<h4 class="minutes-item-title">${meeting.title}</h4>
|
||||
<div class="minutes-item-meta">
|
||||
<span>📅 ${formatDate(meeting.date)}</span>
|
||||
<span>👥 ${meeting.participants.length}명</span>
|
||||
${createBadge(statusInfo.badgeText, statusInfo.badgeType)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 업데이트
|
||||
*/
|
||||
function updateStats() {
|
||||
const scheduled = SAMPLE_MEETINGS.filter(m => m.status === 'scheduled' || m.status === 'ongoing').length;
|
||||
const todos = SAMPLE_TODOS.filter(t => t.assignee.id === currentUser.id && t.status !== 'completed').length;
|
||||
const totalTodos = SAMPLE_TODOS.filter(t => t.assignee.id === currentUser.id).length;
|
||||
const completedTodos = SAMPLE_TODOS.filter(t => t.assignee.id === currentUser.id && t.status === 'completed').length;
|
||||
const completion = totalTodos > 0 ? Math.round((completedTodos / totalTodos) * 100) : 0;
|
||||
|
||||
$('#stat-scheduled').textContent = scheduled;
|
||||
$('#stat-todos').textContent = todos;
|
||||
$('#stat-completion').textContent = completion + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화
|
||||
*/
|
||||
function init() {
|
||||
updateStats();
|
||||
renderRecentMeetings();
|
||||
renderMyTodos();
|
||||
renderMyMinutes();
|
||||
|
||||
console.log('대시보드 초기화 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* FAB 메뉴 토글
|
||||
*/
|
||||
let fabMenuOpen = false;
|
||||
|
||||
function toggleFabMenu() {
|
||||
fabMenuOpen = !fabMenuOpen;
|
||||
const fabButton = $('#fabButton');
|
||||
const fabMenu = $('#fabMenu');
|
||||
const fabOverlay = $('#fabOverlay');
|
||||
|
||||
if (fabMenuOpen) {
|
||||
fabButton.classList.add('active');
|
||||
fabMenu.classList.add('active');
|
||||
fabOverlay.classList.add('active');
|
||||
} else {
|
||||
fabButton.classList.remove('active');
|
||||
fabMenu.classList.remove('active');
|
||||
fabOverlay.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function closeFabMenu() {
|
||||
if (fabMenuOpen) {
|
||||
toggleFabMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FAB 오버레이 클릭 시 메뉴 닫기
|
||||
*/
|
||||
$('#fabOverlay').addEventListener('click', () => {
|
||||
closeFabMenu();
|
||||
});
|
||||
|
||||
/**
|
||||
* play 버튼 (새 회의 시작) 클릭
|
||||
*/
|
||||
$('#quickStartBtn').addEventListener('click', () => {
|
||||
closeFabMenu();
|
||||
// 새 회의 생성 화면으로 이동
|
||||
navigateTo('04-템플릿선택.html');
|
||||
});
|
||||
|
||||
/**
|
||||
* FAB 버튼 (add 이미지) 클릭
|
||||
* - 메뉴 닫혀있을 때: play 버튼 확장
|
||||
* - 메뉴 열려있을 때: 회의 예약 화면으로 이동
|
||||
*/
|
||||
$('#fabButton').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (fabMenuOpen) {
|
||||
// 이미 메뉴가 열려있으면 회의 예약 화면으로 이동
|
||||
closeFabMenu();
|
||||
navigateTo('03-회의예약.html');
|
||||
} else {
|
||||
// 메뉴가 닫혀있으면 play 버튼 표시
|
||||
toggleFabMenu();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 프로필 메뉴 토글 (Mobile)
|
||||
*/
|
||||
function toggleProfileMenu() {
|
||||
const dropdown = $('#profileDropdown');
|
||||
const overlay = $('#profileOverlay');
|
||||
|
||||
dropdown.classList.toggle('show');
|
||||
overlay.classList.toggle('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
function logout() {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
// 로컬 스토리지 초기화
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
localStorage.removeItem('currentUser');
|
||||
|
||||
// 로그인 페이지로 이동
|
||||
navigateTo('01-로그인.html');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
188
design/uiux/prototype/ai-suggestion-integration.js
vendored
Normal file
188
design/uiux/prototype/ai-suggestion-integration.js
vendored
Normal file
@ -0,0 +1,188 @@
|
||||
/**
|
||||
* AI 제안사항 SSE 연동 예시
|
||||
* 05-회의진행.html에 추가할 JavaScript 코드
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 1. 전역 변수 선언
|
||||
// ============================================
|
||||
let eventSource = null;
|
||||
const meetingId = "test-meeting-001"; // 실제로는 URL 파라미터에서 가져옴
|
||||
|
||||
// ============================================
|
||||
// 2. SSE 연결 초기화
|
||||
// ============================================
|
||||
function initializeAiSuggestions() {
|
||||
console.log('AI 제안사항 SSE 연결 시작 - meetingId:', meetingId);
|
||||
|
||||
// SSE 연결
|
||||
eventSource = new EventSource(
|
||||
`http://localhost:8083/api/suggestions/meetings/${meetingId}/stream`
|
||||
);
|
||||
|
||||
// 연결 성공
|
||||
eventSource.onopen = function() {
|
||||
console.log('SSE 연결 성공');
|
||||
};
|
||||
|
||||
// AI 제안사항 수신
|
||||
eventSource.addEventListener('ai-suggestion', function(event) {
|
||||
console.log('AI 제안사항 수신:', event.data);
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleAiSuggestions(data);
|
||||
} catch (error) {
|
||||
console.error('JSON 파싱 오류:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 연결 오류
|
||||
eventSource.onerror = function(error) {
|
||||
console.error('SSE 연결 오류:', error);
|
||||
|
||||
// 자동 재연결은 브라우저가 처리
|
||||
// 필요시 수동 재연결 로직 추가 가능
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. AI 제안사항 처리
|
||||
// ============================================
|
||||
function handleAiSuggestions(data) {
|
||||
console.log('AI 제안사항 처리:', data);
|
||||
|
||||
// data 형식:
|
||||
// {
|
||||
// "suggestions": [
|
||||
// {
|
||||
// "id": "sugg-001",
|
||||
// "content": "신제품의 타겟 고객층을...",
|
||||
// "timestamp": "00:05:23",
|
||||
// "confidence": 0.92
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
|
||||
if (data.suggestions && data.suggestions.length > 0) {
|
||||
data.suggestions.forEach(suggestion => {
|
||||
addSuggestionCard(suggestion);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4. 제안사항 카드 추가
|
||||
// ============================================
|
||||
function addSuggestionCard(suggestion) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'ai-suggestion-card';
|
||||
card.id = 'suggestion-' + suggestion.id;
|
||||
|
||||
// 타임스탬프 (있으면 사용, 없으면 현재 시간)
|
||||
const timestamp = suggestion.timestamp || getCurrentRecordingTime();
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="ai-suggestion-header">
|
||||
<span class="ai-suggestion-time">${timestamp}</span>
|
||||
<button class="ai-suggestion-add-btn"
|
||||
onclick="addToMemo('${escapeHtml(suggestion.content)}', document.getElementById('suggestion-${suggestion.id}'))"
|
||||
title="메모에 추가">
|
||||
➕
|
||||
</button>
|
||||
</div>
|
||||
<div class="ai-suggestion-text">
|
||||
${escapeHtml(suggestion.content)}
|
||||
</div>
|
||||
${suggestion.confidence ? `
|
||||
<div class="ai-suggestion-confidence">
|
||||
<span style="font-size: 11px; color: var(--gray-500);">
|
||||
신뢰도: ${Math.round(suggestion.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
// aiSuggestionList의 맨 위에 추가 (최신 항목이 위로)
|
||||
const listElement = document.getElementById('aiSuggestionList');
|
||||
if (listElement) {
|
||||
listElement.insertBefore(card, listElement.firstChild);
|
||||
|
||||
// 부드러운 등장 애니메이션
|
||||
setTimeout(() => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(-10px)';
|
||||
card.style.transition = 'all 0.3s ease';
|
||||
|
||||
setTimeout(() => {
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'translateY(0)';
|
||||
}, 10);
|
||||
}, 0);
|
||||
} else {
|
||||
console.error('aiSuggestionList 엘리먼트를 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5. 유틸리티 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 현재 녹음 시간 가져오기 (HH:MM 형식)
|
||||
*/
|
||||
function getCurrentRecordingTime() {
|
||||
const timerElement = document.getElementById('recordingTime');
|
||||
if (timerElement) {
|
||||
const time = timerElement.textContent;
|
||||
return time.substring(0, 5); // "00:05:23" -> "00:05"
|
||||
}
|
||||
return "00:00";
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 이스케이프 (XSS 방지)
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 연결 종료
|
||||
*/
|
||||
function closeAiSuggestions() {
|
||||
if (eventSource) {
|
||||
console.log('SSE 연결 종료');
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 6. 페이지 로드 시 자동 시작
|
||||
// ============================================
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('페이지 로드 완료 - AI 제안사항 초기화');
|
||||
|
||||
// SSE 연결 시작
|
||||
initializeAiSuggestions();
|
||||
|
||||
// 페이지 닫을 때 SSE 연결 종료
|
||||
window.addEventListener('beforeunload', function() {
|
||||
closeAiSuggestions();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 7. 회의 종료 시 SSE 연결 종료
|
||||
// ============================================
|
||||
// 기존 endMeeting 함수 수정
|
||||
const originalEndMeeting = window.endMeeting;
|
||||
window.endMeeting = function() {
|
||||
closeAiSuggestions(); // SSE 연결 종료
|
||||
if (originalEndMeeting) {
|
||||
originalEndMeeting(); // 기존 로직 실행
|
||||
}
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
2167
design/uiux/uiux.md
2167
design/uiux/uiux.md
File diff suppressed because it is too large
Load Diff
1137
design/userstory.md
1137
design/userstory.md
File diff suppressed because it is too large
Load Diff
482
develop/dev/dev-ai-frontend-integration.md
Normal file
482
develop/dev/dev-ai-frontend-integration.md
Normal file
@ -0,0 +1,482 @@
|
||||
# AI 서비스 프론트엔드 통합 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
AI 서비스의 실시간 제안사항 API를 프론트엔드에서 사용하기 위한 통합 가이드입니다.
|
||||
|
||||
**⚠️ 중요**: AI 서비스가 **Python (FastAPI)**로 마이그레이션 되었습니다.
|
||||
- **기존 포트**: 8083 (Java Spring Boot) → **새 포트**: 8086 (Python FastAPI)
|
||||
- **엔드포인트 경로**: `/api/suggestions/...` → `/api/v1/ai/suggestions/...`
|
||||
|
||||
---
|
||||
|
||||
## 1. API 정보
|
||||
|
||||
### 엔드포인트
|
||||
```
|
||||
GET /api/v1/ai/suggestions/meetings/{meetingId}/stream
|
||||
```
|
||||
|
||||
**변경 사항**:
|
||||
- ✅ **새 경로** (Python): `/api/v1/ai/suggestions/meetings/{meetingId}/stream`
|
||||
- ❌ **구 경로** (Java): `/api/suggestions/meetings/{meetingId}/stream`
|
||||
|
||||
### 메서드
|
||||
- **HTTP Method**: GET
|
||||
- **Content-Type**: text/event-stream (SSE)
|
||||
- **인증**: 개발 환경에서는 불필요 (운영 환경에서는 JWT 필요)
|
||||
|
||||
### 파라미터
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| meetingId | string (UUID) | 필수 | 회의 고유 ID |
|
||||
|
||||
### 예시
|
||||
```
|
||||
# Python (새 버전)
|
||||
http://localhost:8086/api/v1/ai/suggestions/meetings/550e8400-e29b-41d4-a716-446655440000/stream
|
||||
|
||||
# Java (구 버전 - 사용 중단 예정)
|
||||
http://localhost:8083/api/suggestions/meetings/550e8400-e29b-41d4-a716-446655440000/stream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 응답 데이터 구조
|
||||
|
||||
### SSE 이벤트 형식
|
||||
```
|
||||
event: ai-suggestion
|
||||
id: 123456789
|
||||
data: {"suggestions":[...]}
|
||||
```
|
||||
|
||||
### 데이터 스키마 (JSON)
|
||||
```typescript
|
||||
interface RealtimeSuggestionsDto {
|
||||
suggestions: SimpleSuggestionDto[];
|
||||
}
|
||||
|
||||
interface SimpleSuggestionDto {
|
||||
id: string; // 제안 고유 ID (예: "suggestion-1")
|
||||
content: string; // 제안 내용 (예: "신제품의 타겟 고객층...")
|
||||
timestamp: string; // 시간 정보 (HH:MM:SS 형식, 예: "00:05:23")
|
||||
confidence: number; // 신뢰도 점수 (0.0 ~ 1.0)
|
||||
}
|
||||
```
|
||||
|
||||
### 샘플 응답
|
||||
```json
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "suggestion-1",
|
||||
"content": "신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.",
|
||||
"timestamp": "00:05:23",
|
||||
"confidence": 0.92
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 프론트엔드 구현 방법
|
||||
|
||||
### 3.1 EventSource로 연결
|
||||
|
||||
```javascript
|
||||
// 회의 ID (실제로는 회의 생성 API에서 받아야 함)
|
||||
const meetingId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
// SSE 연결 (Python 버전)
|
||||
const apiUrl = `http://localhost:8086/api/v1/ai/suggestions/meetings/${meetingId}/stream`;
|
||||
const eventSource = new EventSource(apiUrl);
|
||||
|
||||
// 연결 성공
|
||||
eventSource.onopen = function(event) {
|
||||
console.log('SSE 연결 성공');
|
||||
};
|
||||
|
||||
// ai-suggestion 이벤트 수신
|
||||
eventSource.addEventListener('ai-suggestion', function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
const suggestions = data.suggestions;
|
||||
|
||||
suggestions.forEach(suggestion => {
|
||||
console.log('제안:', suggestion.content);
|
||||
addSuggestionToUI(suggestion);
|
||||
});
|
||||
});
|
||||
|
||||
// 에러 처리
|
||||
eventSource.onerror = function(error) {
|
||||
console.error('SSE 연결 오류:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 UI에 제안사항 추가
|
||||
|
||||
```javascript
|
||||
function addSuggestionToUI(suggestion) {
|
||||
const container = document.getElementById('aiSuggestionList');
|
||||
|
||||
// 중복 방지
|
||||
if (document.getElementById(`suggestion-${suggestion.id}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML 생성
|
||||
const html = `
|
||||
<div class="ai-suggestion-card" id="suggestion-${suggestion.id}">
|
||||
<div class="ai-suggestion-header">
|
||||
<span class="ai-suggestion-time">${escapeHtml(suggestion.timestamp)}</span>
|
||||
<button onclick="handleAddToMemo('${escapeHtml(suggestion.content)}')">
|
||||
➕
|
||||
</button>
|
||||
</div>
|
||||
<div class="ai-suggestion-text">
|
||||
${escapeHtml(suggestion.content)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 XSS 방지
|
||||
|
||||
```javascript
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 연결 종료
|
||||
|
||||
```javascript
|
||||
// 페이지 종료 시 또는 회의 종료 시
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. React 통합 예시
|
||||
|
||||
### 4.1 Custom Hook
|
||||
|
||||
```typescript
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Suggestion {
|
||||
id: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export function useAiSuggestions(meetingId: string) {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const apiUrl = `http://localhost:8086/api/v1/ai/suggestions/meetings/${meetingId}/stream`;
|
||||
const eventSource = new EventSource(apiUrl);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setSuggestions(prev => [...prev, ...data.suggestions]);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
setError(new Error('SSE connection failed'));
|
||||
setIsConnected(false);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [meetingId]);
|
||||
|
||||
return { suggestions, isConnected, error };
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Component 사용
|
||||
|
||||
```typescript
|
||||
function MeetingPage({ meetingId }: { meetingId: string }) {
|
||||
const { suggestions, isConnected, error } = useAiSuggestions(meetingId);
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>연결 상태: {isConnected ? '연결됨' : '연결 안 됨'}</div>
|
||||
|
||||
<div className="suggestions-list">
|
||||
{suggestions.map(suggestion => (
|
||||
<div key={suggestion.id} className="suggestion-card">
|
||||
<span className="timestamp">{suggestion.timestamp}</span>
|
||||
<p>{suggestion.content}</p>
|
||||
<button onClick={() => addToMemo(suggestion.content)}>
|
||||
메모에 추가
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 환경별 설정
|
||||
|
||||
### 5.1 개발 환경
|
||||
```javascript
|
||||
// Python 버전 (권장)
|
||||
const API_BASE_URL = 'http://localhost:8086';
|
||||
|
||||
// Java 버전 (구버전 - 사용 중단 예정)
|
||||
// const API_BASE_URL = 'http://localhost:8083';
|
||||
```
|
||||
|
||||
### 5.2 테스트 환경
|
||||
```javascript
|
||||
const API_BASE_URL = 'https://test-api.hgzero.com';
|
||||
```
|
||||
|
||||
### 5.3 운영 환경
|
||||
```javascript
|
||||
// 같은 도메인에서 실행될 경우
|
||||
const API_BASE_URL = '';
|
||||
|
||||
// 또는 환경변수 사용
|
||||
const API_BASE_URL = process.env.REACT_APP_AI_API_URL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 에러 처리
|
||||
|
||||
### 6.1 연결 실패
|
||||
|
||||
```javascript
|
||||
eventSource.onerror = function(error) {
|
||||
console.error('SSE 연결 실패:', error);
|
||||
|
||||
// 사용자에게 알림
|
||||
showErrorNotification('AI 제안사항을 받을 수 없습니다. 다시 시도해주세요.');
|
||||
|
||||
// 재연결 시도 (옵션)
|
||||
setTimeout(() => {
|
||||
reconnect();
|
||||
}, 5000);
|
||||
};
|
||||
```
|
||||
|
||||
### 6.2 파싱 오류
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
} catch (error) {
|
||||
console.error('데이터 파싱 오류:', error);
|
||||
console.error('원본 데이터:', event.data);
|
||||
|
||||
// Sentry 등 에러 모니터링 서비스에 전송
|
||||
reportError(error, { eventData: event.data });
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 네트워크 오류
|
||||
|
||||
```javascript
|
||||
// Timeout 설정 (EventSource는 기본 타임아웃 없음)
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (!isConnected) {
|
||||
console.error('연결 타임아웃');
|
||||
eventSource.close();
|
||||
handleConnectionTimeout();
|
||||
}
|
||||
}, 10000); // 10초
|
||||
|
||||
eventSource.onopen = function() {
|
||||
clearTimeout(connectionTimeout);
|
||||
setIsConnected(true);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 운영 환경 배포 시 변경 사항
|
||||
|
||||
### 7.1 인증 헤더 추가 (운영 환경)
|
||||
|
||||
⚠️ **중요**: 개발 환경에서는 인증이 해제되어 있지만, **운영 환경에서는 JWT 토큰이 필요**합니다.
|
||||
|
||||
```javascript
|
||||
// EventSource는 헤더를 직접 설정할 수 없으므로 URL에 토큰 포함
|
||||
const token = getAccessToken();
|
||||
const apiUrl = `${API_BASE_URL}/api/suggestions/meetings/${meetingId}/stream?token=${token}`;
|
||||
|
||||
// 또는 fetch API + ReadableStream 사용 (권장)
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const reader = response.body.getReader();
|
||||
// SSE 파싱 로직 구현
|
||||
```
|
||||
|
||||
### 7.2 CORS 설정 확인
|
||||
|
||||
운영 환경 도메인이 백엔드 CORS 설정에 포함되어 있는지 확인:
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
cors:
|
||||
allowed-origins: https://your-production-domain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. AI 개발 완료 후 변경 사항
|
||||
|
||||
### 8.1 제거할 백엔드 코드
|
||||
- [SuggestionService.java:102](ai/src/main/java/com/unicorn/hgzero/ai/biz/service/SuggestionService.java:102) - Mock 데이터 발행 호출
|
||||
- [SuggestionService.java:192-236](ai/src/main/java/com/unicorn/hgzero/ai/biz/service/SuggestionService.java:192-236) - Mock 메서드 전체
|
||||
- [SecurityConfig.java:49](ai/src/main/java/com/unicorn/hgzero/ai/infra/config/SecurityConfig.java:49) - 인증 해제 설정
|
||||
|
||||
### 8.2 프론트엔드는 변경 불필요
|
||||
- SSE 연결 코드는 그대로 유지
|
||||
- API URL만 운영 환경에 맞게 수정
|
||||
- JWT 토큰 추가 (위 7.1 참고)
|
||||
|
||||
### 8.3 실제 AI 동작 방식 (예상)
|
||||
```
|
||||
STT 텍스트 생성 → Event Hub 전송 → AI 서비스 수신 →
|
||||
텍스트 축적 (Redis) → 임계값 도달 → Claude API 분석 →
|
||||
SSE로 제안사항 발행 → 프론트엔드 수신
|
||||
```
|
||||
|
||||
현재 Mock은 **5초, 10초, 15초**에 발행하지만, 실제 AI는 **회의 진행 상황에 따라 동적으로** 발행됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 알려진 제한사항
|
||||
|
||||
### 9.1 브라우저 호환성
|
||||
- **EventSource는 IE 미지원** (Edge, Chrome, Firefox, Safari는 지원)
|
||||
- 필요 시 Polyfill 사용: `event-source-polyfill`
|
||||
|
||||
### 9.2 연결 제한
|
||||
- 동일 도메인에 대한 SSE 연결은 브라우저당 **6개로 제한**
|
||||
- 여러 탭에서 동시 접속 시 주의
|
||||
|
||||
### 9.3 재연결
|
||||
- EventSource는 자동 재연결을 시도하지만, 서버에서 연결을 끊으면 재연결 안 됨
|
||||
- 수동 재연결 로직 구현 권장
|
||||
|
||||
### 9.4 Mock 데이터 특성
|
||||
- **개발 환경 전용**: 3개 제안 후 자동 종료
|
||||
- **실제 AI**: 회의 진행 중 계속 발행, 회의 종료 시까지 연결 유지
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 방법
|
||||
|
||||
### 10.1 로컬 테스트
|
||||
```bash
|
||||
# 1. AI 서비스 실행
|
||||
python3 tools/run-intellij-service-profile.py ai
|
||||
|
||||
# 2. HTTP 서버 실행 (file:// 프로토콜은 CORS 제한)
|
||||
cd design/uiux/prototype
|
||||
python3 -m http.server 8000
|
||||
|
||||
# 3. 브라우저에서 접속
|
||||
open http://localhost:8000/05-회의진행.html
|
||||
```
|
||||
|
||||
### 10.2 디버깅
|
||||
```javascript
|
||||
// 브라우저 개발자 도구 Console 탭에서 확인
|
||||
// [DEBUG] 로그로 상세 정보 출력
|
||||
// [ERROR] 로그로 에러 추적
|
||||
```
|
||||
|
||||
### 10.3 curl 테스트
|
||||
```bash
|
||||
# Python 버전 (새 포트)
|
||||
curl -N http://localhost:8086/api/v1/ai/suggestions/meetings/test-meeting/stream
|
||||
|
||||
# Java 버전 (구 포트 - 사용 중단 예정)
|
||||
# curl -N http://localhost:8083/api/suggestions/meetings/550e8400-e29b-41d4-a716-446655440000/stream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 참고 문서
|
||||
|
||||
- [AI Service API 설계서](../../design/backend/api/spec/ai-service-api-spec.md)
|
||||
- [AI 샘플 데이터 통합 가이드](dev-ai-sample-data-guide.md)
|
||||
- [SSE 스트리밍 가이드](dev-ai-realtime-streaming.md)
|
||||
|
||||
---
|
||||
|
||||
## 12. FAQ
|
||||
|
||||
### Q1. 왜 EventSource를 사용하나요?
|
||||
**A**: WebSocket보다 단방향 통신에 적합하고, 자동 재연결 기능이 있으며, 구현이 간단합니다.
|
||||
|
||||
### Q2. 제안사항이 중복으로 표시되는 경우?
|
||||
**A**: `addSuggestionToUI` 함수에 중복 체크 로직이 있는지 확인하세요.
|
||||
|
||||
### Q3. 연결은 되는데 데이터가 안 오는 경우?
|
||||
**A**:
|
||||
1. 백엔드 로그 확인 (`ai/logs/ai-service.log`)
|
||||
2. Network 탭에서 `stream` 요청 확인
|
||||
3. `ai-suggestion` 이벤트 리스너가 등록되었는지 확인
|
||||
|
||||
### Q4. 운영 환경에서 401 Unauthorized 에러?
|
||||
**A**: JWT 토큰이 필요합니다. 7.1절 "인증 헤더 추가" 참고.
|
||||
|
||||
---
|
||||
|
||||
## 문서 이력
|
||||
|
||||
| 버전 | 작성일 | 작성자 | 변경 내용 |
|
||||
|------|--------|--------|----------|
|
||||
| 1.0 | 2025-10-27 | 준호 (Backend), 유진 (Frontend) | 초안 작성 |
|
||||
832
develop/dev/dev-ai-guide.md
Normal file
832
develop/dev/dev-ai-guide.md
Normal file
@ -0,0 +1,832 @@
|
||||
# AI 서비스 개발 가이드
|
||||
|
||||
## 📋 **목차**
|
||||
1. [AI 제안 기능 개발](#1-ai-제안-기능)
|
||||
2. [용어 사전 기능 개발](#2-용어-사전-기능)
|
||||
3. [관련 회의록 추천 기능 개발](#3-관련-회의록-추천-기능)
|
||||
4. [백엔드 API 검증](#4-백엔드-api-검증)
|
||||
5. [프롬프트 엔지니어링 가이드](#5-프롬프트-엔지니어링)
|
||||
|
||||
---
|
||||
|
||||
## 1. AI 제안 기능
|
||||
|
||||
### 📌 **기능 개요**
|
||||
- **목적**: STT로 5초마다 수신되는 회의 텍스트를 분석하여 실시간 제안사항 제공
|
||||
- **입력**: Redis에 축적된 최근 5분간의 회의 텍스트
|
||||
- **출력**:
|
||||
- 논의사항 (Discussions): 회의와 관련있고 추가 논의가 필요한 주제
|
||||
- 결정사항 (Decisions): 명확한 의사결정 패턴이 감지된 내용
|
||||
|
||||
### ✅ **구현 완료 사항**
|
||||
|
||||
1. **SSE 스트리밍 엔드포인트**
|
||||
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/infra/controller/SuggestionController.java:111-131`
|
||||
- 엔드포인트: `GET /api/suggestions/meetings/{meetingId}/stream`
|
||||
- 프로토콜: Server-Sent Events (SSE)
|
||||
|
||||
2. **실시간 텍스트 처리**
|
||||
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/biz/service/SuggestionService.java:120-140`
|
||||
- Event Hub에서 TranscriptSegmentReady 이벤트 수신
|
||||
- Redis에 최근 5분 텍스트 슬라이딩 윈도우 저장
|
||||
|
||||
3. **Claude API 통합**
|
||||
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/infra/client/ClaudeApiClient.java`
|
||||
- 모델: claude-3-5-sonnet-20241022
|
||||
- 비동기 분석 및 JSON 파싱 구현
|
||||
|
||||
### 🔧 **개선 필요 사항**
|
||||
|
||||
#### 1.1 프롬프트 엔지니어링 개선
|
||||
|
||||
**현재 문제점**:
|
||||
- 회의와 관련 없는 일상 대화도 분석될 가능성
|
||||
- 회의 목적/안건 정보가 활용되지 않음
|
||||
|
||||
**개선 방법**:
|
||||
|
||||
```java
|
||||
// ClaudeApiClient.java의 analyzeSuggestions 메서드 개선
|
||||
public Mono<RealtimeSuggestionsDto> analyzeSuggestions(
|
||||
String transcriptText,
|
||||
String meetingPurpose, // 추가
|
||||
List<String> agendaItems // 추가
|
||||
) {
|
||||
String systemPrompt = """
|
||||
당신은 비즈니스 회의록 작성 전문 AI 어시스턴트입니다.
|
||||
|
||||
**역할**: 실시간 회의 텍스트를 분석하여 업무 관련 핵심 내용만 추출
|
||||
|
||||
**분석 기준**:
|
||||
1. 논의사항 (discussions):
|
||||
- 회의 안건과 관련된 미결 주제
|
||||
- 추가 검토가 필요한 업무 항목
|
||||
- "~에 대해 논의 필요", "~검토해야 함" 등의 패턴
|
||||
- **제외**: 잡담, 농담, 인사말, 회의와 무관한 대화
|
||||
|
||||
2. 결정사항 (decisions):
|
||||
- 명확한 의사결정 표현 ("~하기로 함", "~로 결정", "~로 합의")
|
||||
- 구체적인 Action Item
|
||||
- 책임자와 일정이 언급된 항목
|
||||
|
||||
**필터링 규칙**:
|
||||
- 회의 목적/안건과 무관한 내용 제외
|
||||
- 단순 질의응답이나 확인 대화 제외
|
||||
- 업무 맥락이 명확한 내용만 추출
|
||||
|
||||
**응답 형식**: 반드시 JSON만 반환
|
||||
{
|
||||
"discussions": [
|
||||
{
|
||||
"topic": "구체적인 논의 주제 (회의 안건과 직접 연관)",
|
||||
"reason": "회의 안건과의 연관성 설명",
|
||||
"priority": "HIGH|MEDIUM|LOW",
|
||||
"relatedAgenda": "관련 안건"
|
||||
}
|
||||
],
|
||||
"decisions": [
|
||||
{
|
||||
"content": "결정된 내용",
|
||||
"confidence": 0.9,
|
||||
"extractedFrom": "원문 인용",
|
||||
"actionOwner": "담당자 (언급된 경우)",
|
||||
"deadline": "일정 (언급된 경우)"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
String userPrompt = String.format("""
|
||||
**회의 정보**:
|
||||
- 목적: %s
|
||||
- 안건: %s
|
||||
|
||||
**회의 내용 (최근 5분)**:
|
||||
%s
|
||||
|
||||
위 회의 목적과 안건에 맞춰 **회의와 관련된 내용만** 분석해주세요.
|
||||
""",
|
||||
meetingPurpose,
|
||||
String.join(", ", agendaItems),
|
||||
transcriptText
|
||||
);
|
||||
|
||||
// 나머지 코드 동일
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 회의 컨텍스트 조회 기능 추가
|
||||
|
||||
**구현 위치**: `SuggestionService.java`
|
||||
|
||||
```java
|
||||
@RequiredArgsConstructor
|
||||
public class SuggestionService implements SuggestionUseCase {
|
||||
|
||||
private final MeetingGateway meetingGateway; // 추가 필요
|
||||
|
||||
private void analyzeAndEmitSuggestions(String meetingId) {
|
||||
// 1. 회의 정보 조회
|
||||
MeetingInfo meetingInfo = meetingGateway.getMeetingInfo(meetingId);
|
||||
String meetingPurpose = meetingInfo.getPurpose();
|
||||
List<String> agendaItems = meetingInfo.getAgendaItems();
|
||||
|
||||
// 2. Redis에서 최근 5분 텍스트 조회
|
||||
String key = "meeting:" + meetingId + ":transcript";
|
||||
Set<String> recentTexts = redisTemplate.opsForZSet().reverseRange(key, 0, -1);
|
||||
|
||||
if (recentTexts == null || recentTexts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String accumulatedText = recentTexts.stream()
|
||||
.map(entry -> entry.split(":", 2)[1])
|
||||
.collect(Collectors.joining("\n"));
|
||||
|
||||
// 3. Claude API 분석 (회의 컨텍스트 포함)
|
||||
claudeApiClient.analyzeSuggestions(
|
||||
accumulatedText,
|
||||
meetingPurpose, // 추가
|
||||
agendaItems // 추가
|
||||
)
|
||||
.subscribe(
|
||||
suggestions -> {
|
||||
Sinks.Many<RealtimeSuggestionsDto> sink = meetingSinks.get(meetingId);
|
||||
if (sink != null) {
|
||||
sink.tryEmitNext(suggestions);
|
||||
log.info("AI 제안사항 발행 완료 - meetingId: {}, 논의사항: {}, 결정사항: {}",
|
||||
meetingId,
|
||||
suggestions.getDiscussionTopics().size(),
|
||||
suggestions.getDecisions().size());
|
||||
}
|
||||
},
|
||||
error -> log.error("Claude API 분석 실패 - meetingId: {}", meetingId, error)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**필요한 Gateway 인터페이스**:
|
||||
|
||||
```java
|
||||
package com.unicorn.hgzero.ai.biz.gateway;
|
||||
|
||||
public interface MeetingGateway {
|
||||
MeetingInfo getMeetingInfo(String meetingId);
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class MeetingInfo {
|
||||
private String meetingId;
|
||||
private String purpose;
|
||||
private List<String> agendaItems;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 용어 사전 기능
|
||||
|
||||
### 📌 **기능 개요**
|
||||
- **목적**: 회의 중 언급된 전문 용어를 맥락에 맞게 설명
|
||||
- **입력**: 용어, 회의 컨텍스트
|
||||
- **출력**:
|
||||
- 기본 정의
|
||||
- 회의 맥락에 맞는 설명
|
||||
- 이전 회의에서의 사용 예시 (있는 경우)
|
||||
|
||||
### ✅ **구현 완료 사항**
|
||||
|
||||
1. **용어 감지 API**
|
||||
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/infra/controller/TermController.java:35-82`
|
||||
- 엔드포인트: `POST /api/terms/detect`
|
||||
|
||||
2. **용어 설명 서비스 (Mock)**
|
||||
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/biz/service/TermExplanationService.java:24-53`
|
||||
|
||||
### 🔧 **개선 필요 사항**
|
||||
|
||||
#### 2.1 RAG 기반 용어 설명 구현
|
||||
|
||||
**구현 방법**:
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TermExplanationService implements TermExplanationUseCase {
|
||||
|
||||
private final SearchGateway searchGateway;
|
||||
private final ClaudeApiClient claudeApiClient; // 추가
|
||||
|
||||
@Override
|
||||
public TermExplanationResult explainTerm(
|
||||
String term,
|
||||
String meetingId,
|
||||
String context
|
||||
) {
|
||||
log.info("용어 설명 생성 - term: {}, meetingId: {}", term, meetingId);
|
||||
|
||||
// 1. RAG 검색: 이전 회의록에서 해당 용어 사용 사례 검색
|
||||
List<TermUsageExample> pastUsages = searchGateway.searchTermUsages(
|
||||
term,
|
||||
meetingId
|
||||
);
|
||||
|
||||
// 2. Claude API로 맥락 기반 설명 생성
|
||||
String explanation = generateContextualExplanation(
|
||||
term,
|
||||
context,
|
||||
pastUsages
|
||||
);
|
||||
|
||||
return TermExplanationResult.builder()
|
||||
.term(term)
|
||||
.definition(explanation)
|
||||
.context(context)
|
||||
.pastUsages(pastUsages.stream()
|
||||
.map(TermUsageExample::getDescription)
|
||||
.collect(Collectors.toList()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private String generateContextualExplanation(
|
||||
String term,
|
||||
String context,
|
||||
List<TermUsageExample> pastUsages
|
||||
) {
|
||||
String prompt = String.format("""
|
||||
다음 용어를 회의 맥락에 맞게 설명해주세요:
|
||||
|
||||
**용어**: %s
|
||||
**현재 회의 맥락**: %s
|
||||
|
||||
**이전 회의에서의 사용 사례**:
|
||||
%s
|
||||
|
||||
**설명 형식**:
|
||||
1. 기본 정의 (1-2문장)
|
||||
2. 현재 회의 맥락에서의 의미 (1-2문장)
|
||||
3. 이전 논의 참고사항 (있는 경우)
|
||||
|
||||
비즈니스 맥락에 맞는 실용적인 설명을 제공하세요.
|
||||
""",
|
||||
term,
|
||||
context,
|
||||
formatPastUsages(pastUsages)
|
||||
);
|
||||
|
||||
// Claude API 호출 (동기 방식)
|
||||
return claudeApiClient.generateExplanation(prompt)
|
||||
.block(); // 또는 비동기 처리
|
||||
}
|
||||
|
||||
private String formatPastUsages(List<TermUsageExample> pastUsages) {
|
||||
if (pastUsages.isEmpty()) {
|
||||
return "이전 회의에서 언급된 적 없음";
|
||||
}
|
||||
|
||||
return pastUsages.stream()
|
||||
.map(usage -> String.format(
|
||||
"- [%s] %s: %s",
|
||||
usage.getMeetingDate(),
|
||||
usage.getMeetingTitle(),
|
||||
usage.getDescription()
|
||||
))
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 Azure AI Search 통합 (RAG)
|
||||
|
||||
**SearchGateway 구현**:
|
||||
|
||||
```java
|
||||
package com.unicorn.hgzero.ai.infra.search;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AzureAiSearchGateway implements SearchGateway {
|
||||
|
||||
@Value("${external.ai-search.endpoint}")
|
||||
private String endpoint;
|
||||
|
||||
@Value("${external.ai-search.api-key}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${external.ai-search.index-name}")
|
||||
private String indexName;
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
@Override
|
||||
public List<TermUsageExample> searchTermUsages(String term, String meetingId) {
|
||||
// 1. 벡터 검색 쿼리 생성
|
||||
Map<String, Object> searchRequest = Map.of(
|
||||
"search", term,
|
||||
"filter", String.format("meetingId ne '%s'", meetingId), // 현재 회의 제외
|
||||
"top", 5,
|
||||
"select", "meetingId,meetingTitle,meetingDate,content,score"
|
||||
);
|
||||
|
||||
// 2. Azure AI Search API 호출
|
||||
String response = webClient.post()
|
||||
.uri(endpoint + "/indexes/" + indexName + "/docs/search?api-version=2023-11-01")
|
||||
.header("api-key", apiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.bodyValue(searchRequest)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
|
||||
// 3. 응답 파싱
|
||||
return parseSearchResults(response);
|
||||
}
|
||||
|
||||
private List<TermUsageExample> parseSearchResults(String response) {
|
||||
// JSON 파싱 로직
|
||||
// Azure AI Search 응답 형식에 맞춰 파싱
|
||||
return List.of(); // 구현 필요
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 관련 회의록 추천 기능
|
||||
|
||||
### 📌 **기능 개요**
|
||||
- **목적**: 현재 회의와 관련된 과거 회의록 추천
|
||||
- **입력**: 회의 목적, 안건, 진행 중인 회의 내용
|
||||
- **출력**: 관련도 점수와 함께 관련 회의록 목록
|
||||
|
||||
### ✅ **구현 완료 사항**
|
||||
|
||||
1. **관련 회의록 조회 API**
|
||||
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/infra/controller/RelationController.java:31-63`
|
||||
- 엔드포인트: `GET /api/transcripts/{meetingId}/related`
|
||||
|
||||
2. **벡터 검색 서비스 (Mock)**
|
||||
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/biz/service/RelatedTranscriptSearchService.java:27-47`
|
||||
|
||||
### 🔧 **개선 필요 사항**
|
||||
|
||||
#### 3.1 Azure AI Search 벡터 검색 구현
|
||||
|
||||
**구현 방법**:
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RelatedTranscriptSearchService implements RelatedTranscriptSearchUseCase {
|
||||
|
||||
private final SearchGateway searchGateway;
|
||||
private final MeetingGateway meetingGateway;
|
||||
private final ClaudeApiClient claudeApiClient;
|
||||
|
||||
@Override
|
||||
public List<RelatedMinutes> findRelatedTranscripts(
|
||||
String meetingId,
|
||||
String transcriptId,
|
||||
int limit
|
||||
) {
|
||||
log.info("관련 회의록 검색 - meetingId: {}, limit: {}", meetingId, limit);
|
||||
|
||||
// 1. 현재 회의 정보 조회
|
||||
MeetingInfo currentMeeting = meetingGateway.getMeetingInfo(meetingId);
|
||||
String searchQuery = buildSearchQuery(currentMeeting);
|
||||
|
||||
// 2. Azure AI Search로 벡터 유사도 검색
|
||||
List<SearchResult> searchResults = searchGateway.searchRelatedMeetings(
|
||||
searchQuery,
|
||||
meetingId,
|
||||
limit
|
||||
);
|
||||
|
||||
// 3. 검색 결과를 RelatedMinutes로 변환
|
||||
return searchResults.stream()
|
||||
.map(this::toRelatedMinutes)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private String buildSearchQuery(MeetingInfo meeting) {
|
||||
// 회의 목적 + 안건을 검색 쿼리로 변환
|
||||
return String.format("%s %s",
|
||||
meeting.getPurpose(),
|
||||
String.join(" ", meeting.getAgendaItems())
|
||||
);
|
||||
}
|
||||
|
||||
private RelatedMinutes toRelatedMinutes(SearchResult result) {
|
||||
return RelatedMinutes.builder()
|
||||
.transcriptId(result.getMeetingId())
|
||||
.title(result.getTitle())
|
||||
.date(result.getDate())
|
||||
.participants(result.getParticipants())
|
||||
.relevanceScore(result.getScore() * 100) // 0-1 -> 0-100
|
||||
.commonKeywords(extractCommonKeywords(result))
|
||||
.summary(result.getSummary())
|
||||
.link("/transcripts/" + result.getMeetingId())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 임베딩 기반 검색 구현
|
||||
|
||||
**Azure OpenAI Embedding 활용**:
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class EmbeddingService {
|
||||
|
||||
@Value("${azure.openai.endpoint}")
|
||||
private String endpoint;
|
||||
|
||||
@Value("${azure.openai.api-key}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${azure.openai.embedding-deployment}")
|
||||
private String embeddingDeployment;
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
/**
|
||||
* 텍스트를 벡터로 변환
|
||||
*/
|
||||
public float[] generateEmbedding(String text) {
|
||||
Map<String, Object> request = Map.of(
|
||||
"input", text
|
||||
);
|
||||
|
||||
String response = webClient.post()
|
||||
.uri(endpoint + "/openai/deployments/" + embeddingDeployment + "/embeddings?api-version=2024-02-15-preview")
|
||||
.header("api-key", apiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
|
||||
// 응답에서 embedding 벡터 추출
|
||||
return parseEmbedding(response);
|
||||
}
|
||||
|
||||
private float[] parseEmbedding(String response) {
|
||||
// JSON 파싱하여 float[] 반환
|
||||
return new float[0]; // 구현 필요
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Azure AI Search 벡터 검색**:
|
||||
|
||||
```java
|
||||
@Override
|
||||
public List<SearchResult> searchRelatedMeetings(
|
||||
String query,
|
||||
String excludeMeetingId,
|
||||
int limit
|
||||
) {
|
||||
// 1. 쿼리 텍스트를 벡터로 변환
|
||||
float[] queryVector = embeddingService.generateEmbedding(query);
|
||||
|
||||
// 2. 벡터 검색 쿼리
|
||||
Map<String, Object> searchRequest = Map.of(
|
||||
"vector", Map.of(
|
||||
"value", queryVector,
|
||||
"fields", "contentVector",
|
||||
"k", limit
|
||||
),
|
||||
"filter", String.format("meetingId ne '%s'", excludeMeetingId),
|
||||
"select", "meetingId,title,date,participants,summary,score"
|
||||
);
|
||||
|
||||
// 3. Azure AI Search API 호출
|
||||
String response = webClient.post()
|
||||
.uri(endpoint + "/indexes/" + indexName + "/docs/search?api-version=2023-11-01")
|
||||
.header("api-key", apiKey)
|
||||
.bodyValue(searchRequest)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
|
||||
return parseSearchResults(response);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 API 검증
|
||||
|
||||
### 4.1 SSE 스트리밍 테스트
|
||||
|
||||
**테스트 방법**:
|
||||
|
||||
```bash
|
||||
# 1. SSE 엔드포인트 연결
|
||||
curl -N -H "Accept: text/event-stream" \
|
||||
"http://localhost:8083/api/suggestions/meetings/test-meeting-001/stream"
|
||||
|
||||
# 예상 출력:
|
||||
# event: ai-suggestion
|
||||
# data: {"discussionTopics":[...],"decisions":[...]}
|
||||
```
|
||||
|
||||
**프론트엔드 연동 예시** (JavaScript):
|
||||
|
||||
```javascript
|
||||
// 05-회의진행.html에 추가
|
||||
const meetingId = "test-meeting-001";
|
||||
const eventSource = new EventSource(
|
||||
`http://localhost:8083/api/suggestions/meetings/${meetingId}/stream`
|
||||
);
|
||||
|
||||
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||
const suggestions = JSON.parse(event.data);
|
||||
|
||||
// 논의사항 표시
|
||||
suggestions.discussionTopics.forEach(topic => {
|
||||
addDiscussionCard(topic);
|
||||
});
|
||||
|
||||
// 결정사항 표시
|
||||
suggestions.decisions.forEach(decision => {
|
||||
addDecisionCard(decision);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE 연결 오류:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
function addDiscussionCard(topic) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'ai-suggestion-card';
|
||||
card.innerHTML = `
|
||||
<div class="ai-suggestion-header">
|
||||
<span class="ai-suggestion-time">${new Date().toLocaleTimeString()}</span>
|
||||
<span class="badge badge-${topic.priority.toLowerCase()}">${topic.priority}</span>
|
||||
</div>
|
||||
<div class="ai-suggestion-text">
|
||||
<strong>[논의사항]</strong> ${topic.topic}
|
||||
</div>
|
||||
<div class="ai-suggestion-reason">${topic.reason}</div>
|
||||
`;
|
||||
document.getElementById('aiSuggestionList').prepend(card);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 용어 설명 API 테스트
|
||||
|
||||
```bash
|
||||
# POST /api/terms/detect
|
||||
curl -X POST http://localhost:8083/api/terms/detect \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"meetingId": "test-meeting-001",
|
||||
"text": "오늘 회의에서는 MSA 아키텍처와 API Gateway 설계에 대해 논의하겠습니다.",
|
||||
"organizationId": "org-001"
|
||||
}'
|
||||
|
||||
# 예상 응답:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"detectedTerms": [
|
||||
{
|
||||
"term": "MSA",
|
||||
"confidence": 0.95,
|
||||
"category": "아키텍처",
|
||||
"definition": "Microservices Architecture의 약자...",
|
||||
"context": "회의 맥락에 맞는 설명..."
|
||||
},
|
||||
{
|
||||
"term": "API Gateway",
|
||||
"confidence": 0.92,
|
||||
"category": "아키텍처"
|
||||
}
|
||||
],
|
||||
"totalCount": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 관련 회의록 API 테스트
|
||||
|
||||
```bash
|
||||
# GET /api/transcripts/{meetingId}/related
|
||||
curl "http://localhost:8083/api/transcripts/test-meeting-001/related?transcriptId=transcript-001&limit=5"
|
||||
|
||||
# 예상 응답:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"relatedTranscripts": [
|
||||
{
|
||||
"transcriptId": "meeting-002",
|
||||
"title": "MSA 아키텍처 설계 회의",
|
||||
"date": "2025-01-15",
|
||||
"participants": ["김철수", "이영희"],
|
||||
"relevanceScore": 85.5,
|
||||
"commonKeywords": ["MSA", "API Gateway", "Spring Boot"],
|
||||
"link": "/transcripts/meeting-002"
|
||||
}
|
||||
],
|
||||
"totalCount": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 프롬프트 엔지니어링 가이드
|
||||
|
||||
### 5.1 AI 제안사항 프롬프트
|
||||
|
||||
**핵심 원칙**:
|
||||
1. **명확한 역할 정의**: AI의 역할을 "회의록 작성 전문가"로 명시
|
||||
2. **구체적인 기준**: 무엇을 추출하고 무엇을 제외할지 명확히 명시
|
||||
3. **컨텍스트 제공**: 회의 목적과 안건을 프롬프트에 포함
|
||||
4. **구조화된 출력**: JSON 형식으로 파싱 가능한 응답 요청
|
||||
|
||||
**프롬프트 템플릿**:
|
||||
|
||||
```
|
||||
당신은 비즈니스 회의록 작성 전문 AI 어시스턴트입니다.
|
||||
|
||||
**역할**: 실시간 회의 텍스트를 분석하여 업무 관련 핵심 내용만 추출
|
||||
|
||||
**분석 기준**:
|
||||
1. 논의사항 (discussions):
|
||||
- 회의 안건과 관련된 미결 주제
|
||||
- 추가 검토가 필요한 업무 항목
|
||||
- "~에 대해 논의 필요", "~검토해야 함" 등의 패턴
|
||||
- **제외**: 잡담, 농담, 인사말, 회의와 무관한 대화
|
||||
|
||||
2. 결정사항 (decisions):
|
||||
- 명확한 의사결정 표현 ("~하기로 함", "~로 결정", "~로 합의")
|
||||
- 구체적인 Action Item
|
||||
- 책임자와 일정이 언급된 항목
|
||||
|
||||
**필터링 규칙**:
|
||||
- 회의 목적/안건과 무관한 내용 제외
|
||||
- 단순 질의응답이나 확인 대화 제외
|
||||
- 업무 맥락이 명확한 내용만 추출
|
||||
|
||||
**응답 형식**: 반드시 JSON만 반환
|
||||
{
|
||||
"discussions": [
|
||||
{
|
||||
"topic": "구체적인 논의 주제",
|
||||
"reason": "회의 안건과의 연관성",
|
||||
"priority": "HIGH|MEDIUM|LOW",
|
||||
"relatedAgenda": "관련 안건"
|
||||
}
|
||||
],
|
||||
"decisions": [
|
||||
{
|
||||
"content": "결정 내용",
|
||||
"confidence": 0.9,
|
||||
"extractedFrom": "원문 인용",
|
||||
"actionOwner": "담당자 (있는 경우)",
|
||||
"deadline": "일정 (있는 경우)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
**회의 정보**:
|
||||
- 목적: {meeting_purpose}
|
||||
- 안건: {agenda_items}
|
||||
|
||||
**회의 내용 (최근 5분)**:
|
||||
{transcript_text}
|
||||
|
||||
위 회의 목적과 안건에 맞춰 **회의와 관련된 내용만** 분석해주세요.
|
||||
```
|
||||
|
||||
### 5.2 용어 설명 프롬프트
|
||||
|
||||
```
|
||||
다음 용어를 회의 맥락에 맞게 설명해주세요:
|
||||
|
||||
**용어**: {term}
|
||||
**현재 회의 맥락**: {context}
|
||||
|
||||
**이전 회의에서의 사용 사례**:
|
||||
{past_usages}
|
||||
|
||||
**설명 형식**:
|
||||
1. 기본 정의 (1-2문장, 비전문가도 이해 가능하도록)
|
||||
2. 현재 회의 맥락에서의 의미 (1-2문장, 이번 회의에서 이 용어가 어떤 의미로 쓰이는지)
|
||||
3. 이전 논의 참고사항 (있는 경우, 과거 회의에서 관련 결정사항)
|
||||
|
||||
비즈니스 맥락에 맞는 실용적인 설명을 제공하세요.
|
||||
```
|
||||
|
||||
### 5.3 관련 회의록 검색 쿼리 생성 프롬프트
|
||||
|
||||
```
|
||||
현재 회의와 관련된 과거 회의록을 찾기 위한 검색 쿼리를 생성하세요.
|
||||
|
||||
**현재 회의 정보**:
|
||||
- 목적: {meeting_purpose}
|
||||
- 안건: {agenda_items}
|
||||
- 진행 중인 주요 논의: {current_discussions}
|
||||
|
||||
**검색 쿼리 생성 규칙**:
|
||||
1. 회의 목적과 안건에서 핵심 키워드 추출
|
||||
2. 동의어와 관련 용어 포함
|
||||
3. 너무 일반적인 단어는 제외 (예: "회의", "논의")
|
||||
4. 5-10개의 키워드로 구성
|
||||
|
||||
**출력 형식**: 키워드를 공백으로 구분한 문자열
|
||||
예시: "MSA 마이크로서비스 API게이트웨이 분산시스템 아키텍처설계"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 환경 설정
|
||||
|
||||
### 6.1 application.yml 확인 사항
|
||||
|
||||
```yaml
|
||||
# Claude API 설정
|
||||
external:
|
||||
ai:
|
||||
claude:
|
||||
api-key: ${CLAUDE_API_KEY} # 환경변수 설정 필요
|
||||
base-url: https://api.anthropic.com
|
||||
model: claude-3-5-sonnet-20241022
|
||||
max-tokens: 2000
|
||||
temperature: 0.3
|
||||
|
||||
# Azure AI Search 설정
|
||||
ai-search:
|
||||
endpoint: ${AZURE_AI_SEARCH_ENDPOINT}
|
||||
api-key: ${AZURE_AI_SEARCH_API_KEY}
|
||||
index-name: meeting-transcripts
|
||||
|
||||
# Event Hub 설정
|
||||
eventhub:
|
||||
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING}
|
||||
namespace: hgzero-eventhub-ns
|
||||
eventhub-name: hgzero-eventhub-name
|
||||
consumer-group:
|
||||
transcript: ai-transcript-group
|
||||
```
|
||||
|
||||
### 6.2 환경 변수 설정
|
||||
|
||||
```bash
|
||||
# Claude API
|
||||
export CLAUDE_API_KEY="sk-ant-..."
|
||||
|
||||
# Azure AI Search
|
||||
export AZURE_AI_SEARCH_ENDPOINT="https://your-search-service.search.windows.net"
|
||||
export AZURE_AI_SEARCH_API_KEY="your-api-key"
|
||||
|
||||
# Azure Event Hub
|
||||
export AZURE_EVENTHUB_CONNECTION_STRING="Endpoint=sb://..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 테스트 시나리오
|
||||
|
||||
### 7.1 전체 통합 테스트 시나리오
|
||||
|
||||
1. **회의 시작**
|
||||
- 회의 생성 API 호출
|
||||
- SSE 스트림 연결
|
||||
|
||||
2. **STT 텍스트 수신**
|
||||
- Event Hub로 TranscriptSegmentReady 이벤트 발행
|
||||
- Redis에 텍스트 축적 확인
|
||||
|
||||
3. **AI 제안사항 생성**
|
||||
- 5분간 텍스트 축적
|
||||
- Claude API 자동 호출
|
||||
- SSE로 제안사항 수신 확인
|
||||
|
||||
4. **용어 설명 요청**
|
||||
- 감지된 용어로 설명 API 호출
|
||||
- 맥락에 맞는 설명 확인
|
||||
|
||||
5. **관련 회의록 조회**
|
||||
- 관련 회의록 API 호출
|
||||
- 유사도 점수 확인
|
||||
|
||||
---
|
||||
|
||||
## 8. 다음 단계
|
||||
|
||||
1. ✅ **MeetingGateway 구현**: Meeting 서비스와 통신하여 회의 정보 조회
|
||||
2. ✅ **SearchGateway Azure AI Search 통합**: 벡터 검색 구현
|
||||
3. ✅ **ClaudeApiClient 프롬프트 개선**: 회의 컨텍스트 활용
|
||||
4. ✅ **프론트엔드 SSE 연동**: 05-회의진행.html에 SSE 클라이언트 추가
|
||||
5. ✅ **통합 테스트**: 전체 플로우 동작 확인
|
||||
340
develop/dev/dev-ai-integration-guide.md
Normal file
340
develop/dev/dev-ai-integration-guide.md
Normal file
@ -0,0 +1,340 @@
|
||||
# AI 제안사항 프론트엔드 연동 가이드 (간소화 버전)
|
||||
|
||||
## 📋 **개요**
|
||||
|
||||
백엔드를 간소화하여 **논의사항과 결정사항을 구분하지 않고**, 단일 "AI 제안사항" 배열로 통합 제공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **변경 사항 요약**
|
||||
|
||||
### **Before (구분)**
|
||||
```json
|
||||
{
|
||||
"discussionTopics": [
|
||||
{ "topic": "보안 요구사항 검토", ... }
|
||||
],
|
||||
"decisions": [
|
||||
{ "content": "React로 개발", ... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### **After (통합)** ✅
|
||||
```json
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "sugg-001",
|
||||
"content": "신제품의 타겟 고객층을 20-30대로 설정하고...",
|
||||
"timestamp": "00:05:23",
|
||||
"confidence": 0.92
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **프론트엔드 통합 방법**
|
||||
|
||||
### **1. 05-회의진행.html에 스크립트 추가**
|
||||
|
||||
기존 `</body>` 태그 직전에 추가:
|
||||
|
||||
```html
|
||||
<!-- AI 제안사항 SSE 연동 -->
|
||||
<script src="ai-suggestion-integration.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### **2. 전체 플로우**
|
||||
|
||||
```
|
||||
[페이지 로드]
|
||||
↓
|
||||
SSE 연결
|
||||
↓
|
||||
[회의 진행 중]
|
||||
↓
|
||||
AI 분석 완료 시마다
|
||||
SSE로 제안사항 전송
|
||||
↓
|
||||
자동으로 카드 생성
|
||||
↓
|
||||
[회의 종료]
|
||||
↓
|
||||
SSE 연결 종료
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 **SSE API 명세**
|
||||
|
||||
### **엔드포인트**
|
||||
```
|
||||
GET /api/suggestions/meetings/{meetingId}/stream
|
||||
```
|
||||
|
||||
### **헤더**
|
||||
```
|
||||
Accept: text/event-stream
|
||||
```
|
||||
|
||||
### **응답 형식**
|
||||
```
|
||||
event: ai-suggestion
|
||||
id: 12345
|
||||
data: {"suggestions":[{"id":"sugg-001","content":"...","timestamp":"00:05:23","confidence":0.92}]}
|
||||
|
||||
event: ai-suggestion
|
||||
id: 12346
|
||||
data: {"suggestions":[{"id":"sugg-002","content":"...","timestamp":"00:08:45","confidence":0.88}]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 **테스트 방법**
|
||||
|
||||
### **1. 로컬 테스트 (Mock 데이터)**
|
||||
|
||||
백엔드가 아직 없어도 테스트 가능:
|
||||
|
||||
```javascript
|
||||
// 테스트용 Mock 데이터 전송
|
||||
function testAiSuggestion() {
|
||||
const mockSuggestion = {
|
||||
suggestions: [
|
||||
{
|
||||
id: "test-001",
|
||||
content: "테스트 제안사항입니다. 이것은 AI가 생성한 제안입니다.",
|
||||
timestamp: "00:05:23",
|
||||
confidence: 0.95
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
handleAiSuggestions(mockSuggestion);
|
||||
}
|
||||
|
||||
// 콘솔에서 실행
|
||||
testAiSuggestion();
|
||||
```
|
||||
|
||||
### **2. curl로 SSE 연결 테스트**
|
||||
|
||||
```bash
|
||||
curl -N -H "Accept: text/event-stream" \
|
||||
"http://localhost:8083/api/suggestions/meetings/test-meeting-001/stream"
|
||||
```
|
||||
|
||||
예상 출력:
|
||||
```
|
||||
event: ai-suggestion
|
||||
data: {"suggestions":[...]}
|
||||
```
|
||||
|
||||
### **3. 브라우저 DevTools로 확인**
|
||||
|
||||
1. **Network 탭** → "EventStream" 필터
|
||||
2. `/stream` 엔드포인트 클릭
|
||||
3. **Messages** 탭에서 실시간 데이터 확인
|
||||
|
||||
---
|
||||
|
||||
## 💻 **JavaScript API 사용법**
|
||||
|
||||
### **초기화**
|
||||
```javascript
|
||||
// 자동으로 실행됨 (페이지 로드 시)
|
||||
initializeAiSuggestions();
|
||||
```
|
||||
|
||||
### **수동 연결 종료**
|
||||
```javascript
|
||||
closeAiSuggestions();
|
||||
```
|
||||
|
||||
### **제안사항 수동 추가 (테스트용)**
|
||||
```javascript
|
||||
addSuggestionCard({
|
||||
id: "manual-001",
|
||||
content: "수동으로 추가한 제안사항",
|
||||
timestamp: "00:10:00",
|
||||
confidence: 0.9
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **UI 커스터마이징**
|
||||
|
||||
### **신뢰도 표시 스타일**
|
||||
|
||||
```css
|
||||
/* 05-회의진행.html의 <style> 태그에 추가 */
|
||||
.ai-suggestion-confidence {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #E0E0E0;
|
||||
}
|
||||
|
||||
.ai-suggestion-confidence span {
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
```
|
||||
|
||||
### **신뢰도에 따른 색상 변경**
|
||||
|
||||
```javascript
|
||||
function getConfidenceColor(confidence) {
|
||||
if (confidence >= 0.9) return '#4CAF50'; // 녹색 (높음)
|
||||
if (confidence >= 0.7) return '#FFC107'; // 노란색 (중간)
|
||||
return '#FF9800'; // 주황색 (낮음)
|
||||
}
|
||||
|
||||
// 카드에 적용
|
||||
card.innerHTML = `
|
||||
...
|
||||
<div class="ai-suggestion-confidence">
|
||||
<span style="color: ${getConfidenceColor(suggestion.confidence)};">
|
||||
신뢰도: ${Math.round(suggestion.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **트러블슈팅**
|
||||
|
||||
### **문제 1: SSE 연결이 안 됨**
|
||||
|
||||
**증상**:
|
||||
```
|
||||
EventSource's response has a MIME type ("application/json") that is not "text/event-stream"
|
||||
```
|
||||
|
||||
**해결**:
|
||||
- 백엔드에서 `produces = MediaType.TEXT_EVENT_STREAM_VALUE` 확인
|
||||
- SuggestionController.java:111 라인 확인
|
||||
|
||||
### **문제 2: CORS 오류**
|
||||
|
||||
**증상**:
|
||||
```
|
||||
Access to XMLHttpRequest has been blocked by CORS policy
|
||||
```
|
||||
|
||||
**해결**:
|
||||
```java
|
||||
// SecurityConfig.java에 추가
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(Arrays.asList("http://localhost:*"));
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST"));
|
||||
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
```
|
||||
|
||||
### **문제 3: 제안사항이 화면에 안 나타남**
|
||||
|
||||
**체크리스트**:
|
||||
1. `aiSuggestionList` ID가 HTML에 있는지 확인
|
||||
2. 브라우저 콘솔에 에러가 없는지 확인
|
||||
3. Network 탭에서 SSE 데이터가 오는지 확인
|
||||
4. `handleAiSuggestions` 함수에 `console.log` 추가하여 디버깅
|
||||
|
||||
---
|
||||
|
||||
## 📊 **성능 최적화**
|
||||
|
||||
### **제안사항 개수 제한**
|
||||
|
||||
너무 많은 카드가 쌓이면 성능 저하:
|
||||
|
||||
```javascript
|
||||
function addSuggestionCard(suggestion) {
|
||||
// 카드 추가 로직...
|
||||
|
||||
// 최대 20개까지만 유지
|
||||
const listElement = document.getElementById('aiSuggestionList');
|
||||
const cards = listElement.querySelectorAll('.ai-suggestion-card');
|
||||
if (cards.length > 20) {
|
||||
cards[cards.length - 1].remove(); // 가장 오래된 카드 삭제
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **중복 제안사항 필터링**
|
||||
|
||||
```javascript
|
||||
const shownSuggestionIds = new Set();
|
||||
|
||||
function addSuggestionCard(suggestion) {
|
||||
// 이미 표시된 제안사항은 무시
|
||||
if (shownSuggestionIds.has(suggestion.id)) {
|
||||
console.log('중복 제안사항 무시:', suggestion.id);
|
||||
return;
|
||||
}
|
||||
|
||||
shownSuggestionIds.add(suggestion.id);
|
||||
|
||||
// 카드 추가 로직...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **다음 단계**
|
||||
|
||||
1. ✅ **SimpleSuggestionDto 생성 완료**
|
||||
2. ✅ **RealtimeSuggestionsDto 수정 완료**
|
||||
3. ✅ **ClaudeApiClient 프롬프트 간소화 완료**
|
||||
4. ✅ **SuggestionService 로직 수정 완료**
|
||||
5. ✅ **프론트엔드 연동 코드 작성 완료**
|
||||
|
||||
### **실제 테스트 준비**
|
||||
|
||||
1. **백엔드 서버 시작**
|
||||
```bash
|
||||
cd ai
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
2. **프론트엔드 파일 열기**
|
||||
```
|
||||
design/uiux/prototype/05-회의진행.html
|
||||
```
|
||||
|
||||
3. **브라우저 DevTools 열고 Network 탭 확인**
|
||||
|
||||
4. **SSE 연결 확인**
|
||||
- EventStream 필터 활성화
|
||||
- `/stream` 엔드포인트 확인
|
||||
|
||||
---
|
||||
|
||||
## 📝 **완료 체크리스트**
|
||||
|
||||
- [x] SimpleSuggestionDto 생성
|
||||
- [x] RealtimeSuggestionsDto 수정
|
||||
- [x] ClaudeApiClient 프롬프트 간소화
|
||||
- [x] SuggestionService Mock 데이터 수정
|
||||
- [x] 프론트엔드 연동 JavaScript 작성
|
||||
- [ ] 05-회의진행.html에 스크립트 추가
|
||||
- [ ] 로컬 환경에서 테스트
|
||||
- [ ] Claude API 실제 연동 테스트
|
||||
|
||||
---
|
||||
|
||||
**🎉 간소화 작업 완료!**
|
||||
|
||||
이제 프론트엔드와 백엔드가 일치합니다. 05-회의진행.html에 스크립트만 추가하면 바로 사용 가능합니다.
|
||||
319
develop/dev/dev-ai-python-migration.md
Normal file
319
develop/dev/dev-ai-python-migration.md
Normal file
@ -0,0 +1,319 @@
|
||||
# AI Service Python 마이그레이션 완료 보고서
|
||||
|
||||
## 📋 작업 개요
|
||||
|
||||
Java Spring Boot 기반 AI 서비스를 Python FastAPI로 마이그레이션 완료
|
||||
|
||||
**작업 일시**: 2025-10-27
|
||||
**작업자**: 서연 (AI Specialist), 준호 (Backend Developer)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 항목
|
||||
|
||||
### 1. 프로젝트 구조 생성
|
||||
```
|
||||
ai-python/
|
||||
├── main.py ✅ FastAPI 애플리케이션 진입점
|
||||
├── requirements.txt ✅ 의존성 정의
|
||||
├── .env.example ✅ 환경 변수 예시
|
||||
├── .env ✅ 실제 환경 변수
|
||||
├── start.sh ✅ 시작 스크립트
|
||||
├── README.md ✅ 프로젝트 문서
|
||||
└── app/
|
||||
├── config.py ✅ 환경 설정
|
||||
├── models/
|
||||
│ └── response.py ✅ 응답 모델 (Pydantic)
|
||||
├── services/
|
||||
│ ├── claude_service.py ✅ Claude API 서비스
|
||||
│ ├── redis_service.py ✅ Redis 서비스
|
||||
│ └── eventhub_service.py ✅ Event Hub 리스너
|
||||
└── api/
|
||||
└── v1/
|
||||
└── suggestions.py ✅ SSE 엔드포인트
|
||||
```
|
||||
|
||||
### 2. 핵심 기능 구현
|
||||
|
||||
#### ✅ SSE 스트리밍 (실시간 AI 제안사항)
|
||||
- **엔드포인트**: `GET /api/v1/ai/suggestions/meetings/{meeting_id}/stream`
|
||||
- **기술**: Server-Sent Events (SSE)
|
||||
- **동작 방식**:
|
||||
1. Frontend가 SSE 연결
|
||||
2. Redis에서 실시간 텍스트 축적 확인 (5초마다)
|
||||
3. 임계값(10개 세그먼트) 이상이면 Claude API 분석
|
||||
4. 분석 결과를 SSE로 스트리밍
|
||||
|
||||
#### ✅ Claude API 연동
|
||||
- **서비스**: `ClaudeService`
|
||||
- **모델**: claude-3-5-sonnet-20241022
|
||||
- **기능**: 회의 텍스트 분석 및 제안사항 생성
|
||||
- **프롬프트 최적화**: 중요한 제안사항만 추출 (잡담/인사말 제외)
|
||||
|
||||
#### ✅ Redis 슬라이딩 윈도우
|
||||
- **서비스**: `RedisService`
|
||||
- **방식**: Sorted Set 기반 시간순 정렬
|
||||
- **보관 기간**: 최근 5분
|
||||
- **자동 정리**: 5분 이전 데이터 자동 삭제
|
||||
|
||||
#### ✅ Event Hub 연동 (STT 텍스트 수신)
|
||||
- **서비스**: `EventHubService`
|
||||
- **이벤트**: TranscriptSegmentReady (STT에서 발행)
|
||||
- **처리**: 실시간 텍스트를 Redis에 축적
|
||||
|
||||
### 3. 기술 스택
|
||||
|
||||
| 항목 | 기술 | 버전 |
|
||||
|------|------|------|
|
||||
| 언어 | Python | 3.13 |
|
||||
| 프레임워크 | FastAPI | 0.104.1 |
|
||||
| ASGI 서버 | Uvicorn | 0.24.0 |
|
||||
| AI | Anthropic Claude | 0.42.0 |
|
||||
| 캐시 | Redis | 5.0.1 |
|
||||
| 이벤트 | Azure Event Hub | 5.11.4 |
|
||||
| 검증 | Pydantic | 2.10.5 |
|
||||
| SSE | sse-starlette | 1.8.2 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 테스트 결과
|
||||
|
||||
### 1. 서비스 시작 테스트
|
||||
```bash
|
||||
$ ./start.sh
|
||||
======================================
|
||||
AI Service (Python) 시작
|
||||
Port: 8086
|
||||
======================================
|
||||
✅ FastAPI 서버 정상 시작
|
||||
```
|
||||
|
||||
### 2. 헬스 체크
|
||||
```bash
|
||||
$ curl http://localhost:8086/health
|
||||
{"status":"healthy","service":"AI Service (Python)"}
|
||||
✅ 헬스 체크 정상
|
||||
```
|
||||
|
||||
### 3. SSE 스트리밍 테스트
|
||||
```bash
|
||||
$ curl -N http://localhost:8086/api/v1/ai/suggestions/meetings/test-meeting/stream
|
||||
✅ SSE 연결 성공
|
||||
✅ Redis 연결 성공
|
||||
✅ 5초마다 텍스트 축적 확인 정상 동작
|
||||
```
|
||||
|
||||
### 4. 로그 확인
|
||||
```
|
||||
2025-10-27 11:18:54,916 - AI Service (Python) 시작 - Port: 8086
|
||||
2025-10-27 11:18:54,916 - Claude Model: claude-3-5-sonnet-20241022
|
||||
2025-10-27 11:18:54,916 - Redis: 20.249.177.114:6379
|
||||
2025-10-27 11:19:13,213 - SSE 스트림 시작 - meetingId: test-meeting
|
||||
2025-10-27 11:19:13,291 - Redis 연결 성공
|
||||
2025-10-27 11:19:28,211 - SSE 스트림 종료 - meetingId: test-meeting
|
||||
✅ 모든 로그 정상
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 아키텍처 설계
|
||||
|
||||
### 전체 흐름도
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Frontend │
|
||||
│ (회의록 작성)│
|
||||
└──────┬──────┘
|
||||
│ SSE 연결
|
||||
↓
|
||||
┌─────────────────────────┐
|
||||
│ AI Service (Python) │
|
||||
│ - FastAPI │
|
||||
│ - Port: 8086 │
|
||||
│ - SSE 스트리밍 │
|
||||
└──────┬──────────────────┘
|
||||
│ Redis 조회
|
||||
↓
|
||||
┌─────────────────────────┐
|
||||
│ Redis │
|
||||
│ - 슬라이딩 윈도우 (5분) │
|
||||
│ - 실시간 텍스트 축적 │
|
||||
└──────┬──────────────────┘
|
||||
↑ Event Hub
|
||||
│
|
||||
┌─────────────────────────┐
|
||||
│ STT Service (Java) │
|
||||
│ - 음성 → 텍스트 │
|
||||
│ - Event Hub 발행 │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### front → ai 직접 호출 전략
|
||||
|
||||
**✅ 실시간 AI 제안**: `frontend → ai` (SSE 스트리밍)
|
||||
- 저지연 필요
|
||||
- 네트워크 홉 감소
|
||||
- CORS 설정 완료
|
||||
|
||||
**✅ 회의록 메타데이터**: `frontend → backend` (기존 유지)
|
||||
- 회의 ID, 참석자 정보
|
||||
- 데이터 일관성 보장
|
||||
|
||||
**✅ 최종 요약**: `backend → ai` (향후 구현)
|
||||
- API 키 보안 강화
|
||||
- 회의 종료 시 전체 요약
|
||||
|
||||
---
|
||||
|
||||
## 📝 Java → Python 주요 차이점
|
||||
|
||||
| 항목 | Java (Spring Boot) | Python (FastAPI) |
|
||||
|------|-------------------|------------------|
|
||||
| 프레임워크 | Spring WebFlux | FastAPI |
|
||||
| 비동기 | Reactor (Flux, Mono) | asyncio, async/await |
|
||||
| 의존성 주입 | @Autowired | 함수 파라미터 |
|
||||
| 설정 관리 | application.yml | .env + pydantic-settings |
|
||||
| SSE 구현 | Sinks.Many + asFlux() | EventSourceResponse |
|
||||
| Redis 클라이언트 | RedisTemplate | redis.asyncio |
|
||||
| Event Hub | EventHubConsumerClient (동기) | EventHubConsumerClient (비동기) |
|
||||
| 모델 검증 | @Valid, DTO | Pydantic BaseModel |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계 (Phase 2 - 통합 기능)
|
||||
|
||||
### 우선순위 검토 결과
|
||||
**질문**: 회의 진행 시 참석자별 메모 통합 및 AI 요약 기능
|
||||
**결론**: ✅ STT 및 AI 제안사항 개발 완료 후 진행 (Phase 2)
|
||||
|
||||
### Phase 1 (현재 완료)
|
||||
- ✅ STT 서비스 개발 및 테스트
|
||||
- ✅ AI 서비스 Python 변환
|
||||
- ✅ AI 실시간 제안사항 SSE 스트리밍
|
||||
|
||||
### Phase 2 (다음 작업)
|
||||
1. 참석자별 메모 UI/UX 설계
|
||||
2. AI 제안사항 + 직접 작성 통합 인터페이스
|
||||
3. 회의 종료 시 회의록 통합 로직
|
||||
4. 통합 회의록 AI 요약 기능
|
||||
|
||||
### Phase 3 (최적화)
|
||||
1. 실시간 협업 기능 (다중 참석자 동시 편집)
|
||||
2. 회의록 버전 관리
|
||||
3. 성능 최적화 및 캐싱
|
||||
|
||||
---
|
||||
|
||||
## 🚀 배포 및 실행 가이드
|
||||
|
||||
### 개발 환경 실행
|
||||
|
||||
```bash
|
||||
# 1. 가상환경 생성 및 활성화
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Mac/Linux
|
||||
|
||||
# 2. 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 3. 환경 변수 설정
|
||||
cp .env.example .env
|
||||
# .env에서 CLAUDE_API_KEY 설정
|
||||
|
||||
# 4. 서비스 시작
|
||||
./start.sh
|
||||
# 또는
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
### 프론트엔드 연동
|
||||
|
||||
**SSE 연결 예시 (JavaScript)**:
|
||||
```javascript
|
||||
const eventSource = new EventSource(
|
||||
'http://localhost:8086/api/v1/ai/suggestions/meetings/meeting-123/stream'
|
||||
);
|
||||
|
||||
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('AI 제안사항:', data.suggestions);
|
||||
|
||||
// UI 업데이트
|
||||
data.suggestions.forEach(suggestion => {
|
||||
addSuggestionToUI(suggestion);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE 연결 오류:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 환경 변수 설정
|
||||
|
||||
**필수 환경 변수**:
|
||||
```env
|
||||
# Claude API (필수)
|
||||
CLAUDE_API_KEY=sk-ant-api03-... # Claude API 키
|
||||
|
||||
# Redis (필수)
|
||||
REDIS_HOST=20.249.177.114
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=Hi5Jessica!
|
||||
REDIS_DB=4
|
||||
|
||||
# Event Hub (선택 - STT 연동 시 필요)
|
||||
EVENTHUB_CONNECTION_STRING=Endpoint=sb://...
|
||||
EVENTHUB_NAME=hgzero-eventhub-name
|
||||
EVENTHUB_CONSUMER_GROUP=ai-transcript-group
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 성능 특성
|
||||
|
||||
- **SSE 연결**: 저지연 (< 100ms)
|
||||
- **Claude API 응답**: 평균 2-3초
|
||||
- **Redis 조회**: < 10ms
|
||||
- **텍스트 축적 주기**: 5초
|
||||
- **분석 임계값**: 10개 세그먼트 (약 100-200자)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **Claude API 키 보안**
|
||||
- .env 파일을 git에 커밋하지 않음 (.gitignore에 추가)
|
||||
- 프로덕션 환경에서는 환경 변수로 관리
|
||||
|
||||
2. **Redis 연결**
|
||||
- Redis가 없으면 서비스 시작 실패
|
||||
- 연결 정보 확인 필요
|
||||
|
||||
3. **Event Hub (선택)**
|
||||
- Event Hub 연결 문자열이 없어도 SSE는 동작
|
||||
- STT 연동 시에만 필요
|
||||
|
||||
4. **CORS 설정**
|
||||
- 프론트엔드 origin을 .env의 CORS_ORIGINS에 추가
|
||||
|
||||
---
|
||||
|
||||
## 📖 참고 문서
|
||||
|
||||
- [FastAPI 공식 문서](https://fastapi.tiangolo.com/)
|
||||
- [Claude API 문서](https://docs.anthropic.com/)
|
||||
- [Server-Sent Events (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
|
||||
- [Redis Python 클라이언트](https://redis-py.readthedocs.io/)
|
||||
- [Azure Event Hubs Python SDK](https://learn.microsoft.com/azure/event-hubs/event-hubs-python-get-started-send)
|
||||
|
||||
---
|
||||
|
||||
## 📞 문의
|
||||
|
||||
**기술 지원**: AI팀 (서연)
|
||||
**백엔드 지원**: 백엔드팀 (준호)
|
||||
385
develop/dev/dev-ai-realtime-streaming.md
Normal file
385
develop/dev/dev-ai-realtime-streaming.md
Normal file
@ -0,0 +1,385 @@
|
||||
# 실시간 AI 제안 스트리밍 개발 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
회의 진행 중 STT로 변환된 텍스트를 실시간으로 분석하여 논의사항/결정사항을 AI가 제안하는 기능
|
||||
|
||||
**개발 일시**: 2025-10-24
|
||||
**개발자**: AI Specialist (서연)
|
||||
**사용 기술**: Claude API, Azure Event Hub, Redis, SSE (Server-Sent Events)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 구현된 기능
|
||||
|
||||
### ✅ **1. Claude API 클라이언트**
|
||||
- **파일**: `ai/src/main/java/com/unicorn/hgzero/ai/infra/client/ClaudeApiClient.java`
|
||||
- **기능**:
|
||||
- Anthropic Claude API (claude-3-5-sonnet) 호출
|
||||
- 실시간 텍스트 분석하여 논의사항/결정사항 추출
|
||||
- JSON 응답 파싱 및 DTO 변환
|
||||
|
||||
### ✅ **2. Azure Event Hub Consumer**
|
||||
- **파일**: `ai/src/main/java/com/unicorn/hgzero/ai/infra/config/EventHubConfig.java`
|
||||
- **기능**:
|
||||
- STT Service의 `TranscriptSegmentReady` 이벤트 구독
|
||||
- 실시간 음성 변환 텍스트 수신
|
||||
- SuggestionService로 전달하여 AI 분석 트리거
|
||||
|
||||
### ✅ **3. 실시간 텍스트 축적 로직**
|
||||
- **파일**: `ai/src/main/java/com/unicorn/hgzero/ai/biz/service/SuggestionService.java`
|
||||
- **메서드**: `processRealtimeTranscript()`
|
||||
- **기능**:
|
||||
- Redis Sorted Set을 이용한 슬라이딩 윈도우 (최근 5분 텍스트 유지)
|
||||
- 임계값 도달 시 자동 AI 분석 (10개 세그먼트 = 약 100-200자)
|
||||
|
||||
### ✅ **4. SSE 스트리밍**
|
||||
- **API**: `GET /api/suggestions/meetings/{meetingId}/stream`
|
||||
- **Controller**: `SuggestionController:111`
|
||||
- **기능**:
|
||||
- Server-Sent Events로 실시간 AI 제안사항 전송
|
||||
- 멀티캐스트 지원 (여러 클라이언트 동시 연결)
|
||||
- 자동 리소스 정리 (연결 종료 시)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 아키텍처
|
||||
|
||||
```
|
||||
[회의 진행 중]
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1. STT Service (Azure Speech) │
|
||||
│ - 음성 → 텍스트 실시간 변환 │
|
||||
└─────────────────────────────────────┘
|
||||
↓ Azure Event Hub
|
||||
↓ (TranscriptSegmentReady Event)
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 2. AI Service (Event Hub Consumer) │
|
||||
│ - 이벤트 수신 │
|
||||
│ - Redis에 텍스트 축적 │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 3. Redis (슬라이딩 윈도우) │
|
||||
│ - 최근 5분 텍스트 유지 │
|
||||
│ - 임계값 체크 (10 segments) │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 4. Claude API (Anthropic) │
|
||||
│ - 누적 텍스트 분석 │
|
||||
│ - 논의사항/결정사항 추출 │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 5. SSE 스트리밍 │
|
||||
│ - 클라이언트에 실시간 전송 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 설정 방법
|
||||
|
||||
### **1. Claude API 키 발급**
|
||||
|
||||
1. [Anthropic Console](https://console.anthropic.com/) 접속
|
||||
2. API Keys → Create Key
|
||||
3. 생성된 API Key 복사
|
||||
|
||||
### **2. 환경 변수 설정**
|
||||
|
||||
**application.yml** 또는 **환경 변수**에 추가:
|
||||
|
||||
```bash
|
||||
# Claude API 설정
|
||||
export CLAUDE_API_KEY="sk-ant-api03-..."
|
||||
export CLAUDE_MODEL="claude-3-5-sonnet-20241022"
|
||||
export CLAUDE_MAX_TOKENS="2000"
|
||||
export CLAUDE_TEMPERATURE="0.3"
|
||||
|
||||
# Azure Event Hub 설정 (이미 설정됨)
|
||||
export AZURE_EVENTHUB_CONNECTION_STRING="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;..."
|
||||
export AZURE_EVENTHUB_NAME="hgzero-eventhub-name"
|
||||
export AZURE_EVENTHUB_CONSUMER_GROUP_TRANSCRIPT="ai-transcript-group"
|
||||
|
||||
# Redis 설정 (이미 설정됨)
|
||||
export REDIS_HOST="20.249.177.114"
|
||||
export REDIS_PORT="6379"
|
||||
export REDIS_PASSWORD="Hi5Jessica!"
|
||||
export REDIS_DATABASE="4"
|
||||
```
|
||||
|
||||
### **3. 의존성 확인**
|
||||
|
||||
`ai/build.gradle`에 이미 추가됨:
|
||||
|
||||
```gradle
|
||||
dependencies {
|
||||
// Common module
|
||||
implementation project(':common')
|
||||
|
||||
// Redis
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
// Anthropic Claude SDK
|
||||
implementation 'com.anthropic:anthropic-sdk-java:0.1.0'
|
||||
|
||||
// Azure Event Hubs
|
||||
implementation "com.azure:azure-messaging-eventhubs:${azureEventHubsVersion}"
|
||||
implementation "com.azure:azure-messaging-eventhubs-checkpointstore-blob:${azureEventHubsCheckpointVersion}"
|
||||
|
||||
// Spring WebFlux for SSE streaming
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
### **1. AI Service 빌드**
|
||||
|
||||
```bash
|
||||
cd /Users/jominseo/HGZero
|
||||
./gradlew :ai:build -x test
|
||||
```
|
||||
|
||||
### **2. AI Service 실행**
|
||||
|
||||
```bash
|
||||
cd ai
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
또는 IntelliJ Run Configuration 사용
|
||||
|
||||
### **3. 클라이언트 테스트 (회의진행.html)**
|
||||
|
||||
```javascript
|
||||
// SSE 연결
|
||||
const meetingId = "MTG-2025-001";
|
||||
const eventSource = new EventSource(`/api/suggestions/meetings/${meetingId}/stream`);
|
||||
|
||||
// AI 제안사항 수신
|
||||
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||
const suggestion = JSON.parse(event.data);
|
||||
console.log('실시간 AI 제안:', suggestion);
|
||||
|
||||
// 논의사항 UI 업데이트
|
||||
suggestion.discussionTopics.forEach(topic => {
|
||||
addDiscussionToUI(topic);
|
||||
});
|
||||
|
||||
// 결정사항 UI 업데이트
|
||||
suggestion.decisions.forEach(decision => {
|
||||
addDecisionToUI(decision);
|
||||
});
|
||||
});
|
||||
|
||||
// 에러 처리
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE 연결 오류:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
// 회의 종료 시 연결 종료
|
||||
function endMeeting() {
|
||||
eventSource.close();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 데이터 흐름
|
||||
|
||||
### **Event Hub 이벤트 구조**
|
||||
|
||||
```json
|
||||
{
|
||||
"recordingId": "REC-20250123-001",
|
||||
"meetingId": "MTG-2025-001",
|
||||
"transcriptId": "TRS-SEG-001",
|
||||
"text": "안녕하세요, 오늘 회의를 시작하겠습니다.",
|
||||
"timestamp": 1234567890,
|
||||
"confidence": 0.92,
|
||||
"eventTime": "2025-01-23T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### **Claude API 응답 구조**
|
||||
|
||||
```json
|
||||
{
|
||||
"discussions": [
|
||||
{
|
||||
"topic": "보안 요구사항 검토",
|
||||
"reason": "안건에 포함되어 있으나 아직 논의되지 않음",
|
||||
"priority": "HIGH"
|
||||
}
|
||||
],
|
||||
"decisions": [
|
||||
{
|
||||
"content": "React로 프론트엔드 개발",
|
||||
"confidence": 0.9,
|
||||
"extractedFrom": "프론트엔드는 React로 개발하기로 했습니다"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### **SSE 스트리밍 응답**
|
||||
|
||||
```
|
||||
event: ai-suggestion
|
||||
id: 12345
|
||||
data: {"discussionTopics":[...],"decisions":[...]}
|
||||
|
||||
event: ai-suggestion
|
||||
id: 12346
|
||||
data: {"discussionTopics":[...],"decisions":[...]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 주요 설정값
|
||||
|
||||
| 설정 | 값 | 설명 |
|
||||
|------|-----|------|
|
||||
| `MIN_SEGMENTS_FOR_ANALYSIS` | 10 | AI 분석 시작 임계값 (세그먼트 수) |
|
||||
| `TEXT_RETENTION_MS` | 300000 (5분) | Redis 텍스트 보관 기간 |
|
||||
| `CLAUDE_MODEL` | claude-3-5-sonnet-20241022 | 사용 Claude 모델 |
|
||||
| `CLAUDE_MAX_TOKENS` | 2000 | 최대 응답 토큰 수 |
|
||||
| `CLAUDE_TEMPERATURE` | 0.3 | 창의성 수준 (0-1) |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 트러블슈팅
|
||||
|
||||
### **1. Event Hub 연결 실패**
|
||||
|
||||
**증상**: `Event Hub Processor 시작 실패` 로그
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# 연결 문자열 확인
|
||||
echo $AZURE_EVENTHUB_CONNECTION_STRING
|
||||
|
||||
# Consumer Group 확인
|
||||
echo $AZURE_EVENTHUB_CONSUMER_GROUP_TRANSCRIPT
|
||||
```
|
||||
|
||||
### **2. Claude API 호출 실패**
|
||||
|
||||
**증상**: `Claude API 호출 실패` 로그
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# API 키 확인
|
||||
echo $CLAUDE_API_KEY
|
||||
|
||||
# 네트워크 연결 확인
|
||||
curl -X POST https://api.anthropic.com/v1/messages \
|
||||
-H "x-api-key: $CLAUDE_API_KEY" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "content-type: application/json"
|
||||
```
|
||||
|
||||
### **3. Redis 연결 실패**
|
||||
|
||||
**증상**: `Unable to connect to Redis` 로그
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# Redis 연결 테스트
|
||||
redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD ping
|
||||
|
||||
# 응답: PONG
|
||||
```
|
||||
|
||||
### **4. SSE 스트림 끊김**
|
||||
|
||||
**증상**: 클라이언트에서 연결이 자주 끊김
|
||||
|
||||
**해결**:
|
||||
```javascript
|
||||
// 자동 재연결 로직 추가
|
||||
function connectSSE(meetingId) {
|
||||
const eventSource = new EventSource(`/api/suggestions/meetings/${meetingId}/stream`);
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE 연결 오류, 5초 후 재연결...');
|
||||
eventSource.close();
|
||||
setTimeout(() => connectSSE(meetingId), 5000);
|
||||
};
|
||||
|
||||
return eventSource;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 성능 최적화
|
||||
|
||||
### **1. Redis 메모리 관리**
|
||||
- 슬라이딩 윈도우로 최근 5분만 유지
|
||||
- 회의 종료 시 자동 삭제
|
||||
- TTL 설정 고려 (향후 추가)
|
||||
|
||||
### **2. Claude API 호출 최적화**
|
||||
- 임계값 도달 시에만 호출 (불필요한 호출 방지)
|
||||
- 비동기 처리로 응답 대기 시간 최소화
|
||||
- 에러 발생 시 빈 응답 반환 (서비스 중단 방지)
|
||||
|
||||
### **3. SSE 연결 관리**
|
||||
- 멀티캐스트로 여러 클라이언트 동시 지원
|
||||
- 연결 종료 시 자동 리소스 정리
|
||||
- Backpressure 버퍼링으로 과부하 방지
|
||||
|
||||
---
|
||||
|
||||
## 🔜 향후 개발 계획
|
||||
|
||||
### **Phase 2: AI 정확도 향상**
|
||||
- [ ] 회의 안건 기반 맥락 분석
|
||||
- [ ] 과거 회의록 참조 (RAG)
|
||||
- [ ] 조직별 용어 사전 통합
|
||||
|
||||
### **Phase 3: 성능 개선**
|
||||
- [ ] Redis TTL 자동 설정
|
||||
- [ ] Claude API 캐싱 전략
|
||||
- [ ] 배치 분석 옵션 추가
|
||||
|
||||
### **Phase 4: 모니터링**
|
||||
- [ ] AI 제안 정확도 측정
|
||||
- [ ] 응답 시간 메트릭 수집
|
||||
- [ ] 사용량 대시보드 구축
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 자료
|
||||
|
||||
- [Anthropic Claude API 문서](https://docs.anthropic.com/claude/reference/messages)
|
||||
- [Azure Event Hubs 문서](https://learn.microsoft.com/en-us/azure/event-hubs/)
|
||||
- [Server-Sent Events 스펙](https://html.spec.whatwg.org/multipage/server-sent-events.html)
|
||||
- [Redis Sorted Sets 가이드](https://redis.io/docs/data-types/sorted-sets/)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
- [x] Claude API 클라이언트 구현
|
||||
- [x] Azure Event Hub Consumer 구현
|
||||
- [x] Redis 슬라이딩 윈도우 구현
|
||||
- [x] SSE 스트리밍 구현
|
||||
- [x] SuggestionService 통합
|
||||
- [ ] Claude API 키 발급 및 설정
|
||||
- [ ] 통합 테스트 (STT → AI → SSE)
|
||||
- [ ] 프론트엔드 연동 테스트
|
||||
|
||||
---
|
||||
|
||||
**개발 완료**: 2025-10-24
|
||||
**다음 단계**: Claude API 키 발급 및 통합 테스트
|
||||
400
develop/dev/dev-ai-sample-data-guide.md
Normal file
400
develop/dev/dev-ai-sample-data-guide.md
Normal file
@ -0,0 +1,400 @@
|
||||
# AI 샘플 데이터 통합 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
AI 서비스 개발이 완료되지 않은 상황에서 프론트엔드 개발을 병행하기 위해 **샘플 데이터 자동 발행 기능**을 구현했습니다.
|
||||
|
||||
### 목적
|
||||
- 프론트엔드 개발자가 AI 기능 완성을 기다리지 않고 화면 개발 가능
|
||||
- 실시간 SSE(Server-Sent Events) 스트리밍 동작 테스트
|
||||
- 회의 진행 중 AI 제안사항 표시 기능 검증
|
||||
|
||||
### 주요 기능
|
||||
- **백엔드**: AI Service에서 5초마다 샘플 제안사항 3개 자동 발행
|
||||
- **프론트엔드**: EventSource API를 통한 실시간 데이터 수신 및 화면 표시
|
||||
|
||||
---
|
||||
|
||||
## 1. 백엔드 구현
|
||||
|
||||
### 1.1 수정 파일
|
||||
- **파일**: `ai/src/main/java/com/unicorn/hgzero/ai/biz/service/SuggestionService.java`
|
||||
- **수정 내용**: `startMockDataEmission()` 메서드 추가
|
||||
|
||||
### 1.2 구현 내용
|
||||
|
||||
#### Mock 데이터 자동 발행 메서드
|
||||
```java
|
||||
/**
|
||||
* TODO: AI 개발 완료 후 제거
|
||||
* Mock 데이터 자동 발행 (프론트엔드 개발용)
|
||||
* 5초마다 샘플 제안사항을 발행합니다.
|
||||
*/
|
||||
private void startMockDataEmission(String meetingId, Sinks.Many<RealtimeSuggestionsDto> sink) {
|
||||
// 프론트엔드 HTML에 맞춘 샘플 데이터 (3개)
|
||||
List<SimpleSuggestionDto> mockSuggestions = List.of(
|
||||
SimpleSuggestionDto.builder()
|
||||
.id("suggestion-1")
|
||||
.content("신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.")
|
||||
.timestamp("00:05:23")
|
||||
.confidence(0.92)
|
||||
.build(),
|
||||
// ... 3개의 샘플 데이터
|
||||
);
|
||||
|
||||
// 5초마다 하나씩 발행 (총 3개)
|
||||
Flux.interval(Duration.ofSeconds(5))
|
||||
.take(3)
|
||||
.map(index -> {
|
||||
SimpleSuggestionDto suggestion = mockSuggestions.get(index.intValue());
|
||||
return RealtimeSuggestionsDto.builder()
|
||||
.suggestions(List.of(suggestion))
|
||||
.build();
|
||||
})
|
||||
.subscribe(
|
||||
suggestions -> {
|
||||
sink.tryEmitNext(suggestions);
|
||||
log.info("Mock 제안사항 발행 완료");
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### SSE 스트리밍 메서드 수정
|
||||
```java
|
||||
@Override
|
||||
public Flux<RealtimeSuggestionsDto> streamRealtimeSuggestions(String meetingId) {
|
||||
// Sink 생성
|
||||
Sinks.Many<RealtimeSuggestionsDto> sink = Sinks.many()
|
||||
.multicast()
|
||||
.onBackpressureBuffer();
|
||||
|
||||
meetingSinks.put(meetingId, sink);
|
||||
|
||||
// TODO: AI 개발 완료 후 제거 - Mock 데이터 자동 발행
|
||||
startMockDataEmission(meetingId, sink);
|
||||
|
||||
return sink.asFlux()
|
||||
.doOnCancel(() -> {
|
||||
meetingSinks.remove(meetingId);
|
||||
cleanupMeetingData(meetingId);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 샘플 데이터 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "suggestion-1",
|
||||
"content": "신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.",
|
||||
"timestamp": "00:05:23",
|
||||
"confidence": 0.92
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 프론트엔드 구현
|
||||
|
||||
### 2.1 수정 파일
|
||||
- **파일**: `design/uiux/prototype/05-회의진행.html`
|
||||
- **수정 내용**: SSE 연결 및 실시간 데이터 수신 코드 추가
|
||||
|
||||
### 2.2 구현 내용
|
||||
|
||||
#### SSE 연결 함수
|
||||
```javascript
|
||||
function connectAiSuggestionStream() {
|
||||
const apiUrl = `http://localhost:8082/api/suggestions/meetings/${meetingId}/stream`;
|
||||
|
||||
eventSource = new EventSource(apiUrl);
|
||||
|
||||
eventSource.addEventListener('ai-suggestion', function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
const suggestions = data.suggestions;
|
||||
|
||||
if (suggestions && suggestions.length > 0) {
|
||||
suggestions.forEach(suggestion => {
|
||||
addAiSuggestionToUI(suggestion);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = function(error) {
|
||||
console.error('SSE 연결 오류:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### UI 추가 함수
|
||||
```javascript
|
||||
function addAiSuggestionToUI(suggestion) {
|
||||
const listContainer = document.getElementById('aiSuggestionList');
|
||||
const cardId = `suggestion-${suggestion.id}`;
|
||||
|
||||
// 중복 방지
|
||||
if (document.getElementById(cardId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// AI 제안 카드 HTML 생성
|
||||
const cardHtml = `
|
||||
<div class="ai-suggestion-card" id="${cardId}">
|
||||
<div class="ai-suggestion-header">
|
||||
<span class="ai-suggestion-time">${suggestion.timestamp}</span>
|
||||
<button class="ai-suggestion-add-btn"
|
||||
onclick="addToMemo('${escapeHtml(suggestion.content)}', document.getElementById('${cardId}'))"
|
||||
title="메모에 추가">
|
||||
➕
|
||||
</button>
|
||||
</div>
|
||||
<div class="ai-suggestion-text">
|
||||
${escapeHtml(suggestion.content)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
listContainer.insertAdjacentHTML('beforeend', cardHtml);
|
||||
}
|
||||
```
|
||||
|
||||
#### XSS 방지
|
||||
```javascript
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 테스트 방법
|
||||
|
||||
### 3.1 서비스 실행
|
||||
|
||||
#### 1단계: AI 서비스 실행
|
||||
```bash
|
||||
# IntelliJ 실행 프로파일 사용
|
||||
python3 tools/run-intellij-service-profile.py ai
|
||||
|
||||
# 또는 직접 실행
|
||||
cd ai
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
**실행 확인**:
|
||||
- 포트: `8082`
|
||||
- 로그 확인: `ai/logs/ai-service.log`
|
||||
|
||||
#### 2단계: 프론트엔드 HTML 열기
|
||||
```bash
|
||||
# 브라우저에서 직접 열기
|
||||
open design/uiux/prototype/05-회의진행.html
|
||||
|
||||
# 또는 HTTP 서버 실행
|
||||
cd design/uiux/prototype
|
||||
python3 -m http.server 8000
|
||||
# 브라우저: http://localhost:8000/05-회의진행.html
|
||||
```
|
||||
|
||||
### 3.2 동작 확인
|
||||
|
||||
#### 브라우저 콘솔 확인
|
||||
1. 개발자 도구 열기 (F12)
|
||||
2. Console 탭 확인
|
||||
|
||||
**예상 로그**:
|
||||
```
|
||||
AI 제안사항 SSE 스트림 연결됨: http://localhost:8082/api/suggestions/meetings/550e8400-e29b-41d4-a716-446655440000/stream
|
||||
AI 제안사항 수신: {"suggestions":[{"id":"suggestion-1", ...}]}
|
||||
AI 제안사항 추가됨: 신제품의 타겟 고객층을 20-30대로 설정하고...
|
||||
```
|
||||
|
||||
#### 화면 동작 확인
|
||||
1. **페이지 로드**: 회의진행.html 열기
|
||||
2. **AI 제안 탭 클릭**: "AI 제안" 탭으로 이동
|
||||
3. **5초 대기**: 첫 번째 제안사항 표시
|
||||
4. **10초 대기**: 두 번째 제안사항 표시
|
||||
5. **15초 대기**: 세 번째 제안사항 표시
|
||||
|
||||
#### 백엔드 로그 확인
|
||||
```bash
|
||||
tail -f ai/logs/ai-service.log
|
||||
```
|
||||
|
||||
**예상 로그**:
|
||||
```
|
||||
실시간 AI 제안사항 스트리밍 시작 - meetingId: 550e8400-e29b-41d4-a716-446655440000
|
||||
Mock 데이터 자동 발행 시작 - meetingId: 550e8400-e29b-41d4-a716-446655440000
|
||||
Mock 제안사항 발행 - meetingId: 550e8400-e29b-41d4-a716-446655440000, 제안: 신제품의 타겟 고객층...
|
||||
```
|
||||
|
||||
### 3.3 API 직접 테스트 (curl)
|
||||
|
||||
```bash
|
||||
# SSE 스트림 연결
|
||||
curl -N http://localhost:8082/api/suggestions/meetings/550e8400-e29b-41d4-a716-446655440000/stream
|
||||
```
|
||||
|
||||
**예상 응답**:
|
||||
```
|
||||
event: ai-suggestion
|
||||
id: 123456789
|
||||
data: {"suggestions":[{"id":"suggestion-1","content":"신제품의 타겟 고객층...","timestamp":"00:05:23","confidence":0.92}]}
|
||||
|
||||
event: ai-suggestion
|
||||
id: 987654321
|
||||
data: {"suggestions":[{"id":"suggestion-2","content":"개발 일정...","timestamp":"00:08:45","confidence":0.88}]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. CORS 설정 (필요 시)
|
||||
|
||||
프론트엔드를 다른 포트에서 실행할 경우 CORS 설정이 필요합니다.
|
||||
|
||||
### 4.1 application.yml 확인
|
||||
```yaml
|
||||
# ai/src/main/resources/application.yml
|
||||
spring:
|
||||
web:
|
||||
cors:
|
||||
allowed-origins:
|
||||
- http://localhost:8000
|
||||
- http://localhost:3000
|
||||
allowed-methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
allowed-headers:
|
||||
- "*"
|
||||
allow-credentials: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 주의사항
|
||||
|
||||
### 5.1 Mock 데이터 제거 시점
|
||||
⚠️ **AI 개발 완료 후 반드시 제거해야 할 코드**:
|
||||
|
||||
#### 백엔드 (SuggestionService.java)
|
||||
```java
|
||||
// TODO: AI 개발 완료 후 제거 - 이 줄 삭제
|
||||
startMockDataEmission(meetingId, sink);
|
||||
|
||||
// TODO: AI 개발 완료 후 제거 - 이 메서드 전체 삭제
|
||||
private void startMockDataEmission(...) { ... }
|
||||
```
|
||||
|
||||
#### 프론트엔드 (회의진행.html)
|
||||
- SSE 연결 코드는 **그대로 유지**
|
||||
- API URL만 실제 환경에 맞게 수정:
|
||||
```javascript
|
||||
// 개발 환경
|
||||
const apiUrl = `http://localhost:8082/api/suggestions/meetings/${meetingId}/stream`;
|
||||
|
||||
// 운영 환경 (예시)
|
||||
const apiUrl = `/api/suggestions/meetings/${meetingId}/stream`;
|
||||
```
|
||||
|
||||
### 5.2 제한사항
|
||||
|
||||
1. **회의 ID 고정**
|
||||
- 현재 테스트용 회의 ID가 하드코딩됨
|
||||
- 실제 환경에서는 회의 생성 API 응답에서 받아야 함
|
||||
|
||||
2. **샘플 데이터 개수**
|
||||
- 현재 3개로 제한
|
||||
- 실제 AI는 회의 진행에 따라 동적으로 생성
|
||||
|
||||
3. **재연결 처리 없음**
|
||||
- SSE 연결이 끊어지면 재연결하지 않음
|
||||
- 실제 환경에서는 재연결 로직 필요
|
||||
|
||||
4. **인증/인가 없음**
|
||||
- 현재 JWT 토큰 검증 없이 테스트
|
||||
- 실제 환경에서는 인증 헤더 추가 필요
|
||||
|
||||
---
|
||||
|
||||
## 6. 트러블슈팅
|
||||
|
||||
### 문제 1: SSE 연결 안 됨
|
||||
**증상**: 브라우저 콘솔에 "SSE 연결 오류" 표시
|
||||
|
||||
**해결 방법**:
|
||||
1. AI 서비스가 실행 중인지 확인
|
||||
```bash
|
||||
curl http://localhost:8082/actuator/health
|
||||
```
|
||||
2. CORS 설정 확인
|
||||
3. 방화벽/포트 확인
|
||||
|
||||
### 문제 2: 제안사항이 표시되지 않음
|
||||
**증상**: SSE는 연결되지만 화면에 아무것도 표시되지 않음
|
||||
|
||||
**해결 방법**:
|
||||
1. 브라우저 콘솔에서 에러 확인
|
||||
2. Network 탭에서 SSE 이벤트 확인
|
||||
3. 백엔드 로그 확인
|
||||
|
||||
### 문제 3: 중복 제안사항 표시
|
||||
**증상**: 같은 제안이 여러 번 표시됨
|
||||
|
||||
**해결 방법**:
|
||||
- 페이지 새로고침 (SSE 연결 재시작)
|
||||
- 브라우저 캐시 삭제
|
||||
|
||||
---
|
||||
|
||||
## 7. 다음 단계
|
||||
|
||||
### AI 개발 완료 후 작업
|
||||
1. **Mock 코드 제거**
|
||||
- `startMockDataEmission()` 메서드 삭제
|
||||
- 관련 TODO 주석 제거
|
||||
|
||||
2. **실제 AI 로직 연결**
|
||||
- Claude API 연동
|
||||
- Event Hub 메시지 수신
|
||||
- Redis 텍스트 축적 및 분석
|
||||
|
||||
3. **프론트엔드 개선**
|
||||
- 재연결 로직 추가
|
||||
- 에러 핸들링 강화
|
||||
- 로딩 상태 표시
|
||||
|
||||
4. **성능 최적화**
|
||||
- SSE 연결 풀 관리
|
||||
- 메모리 누수 방지
|
||||
- 네트워크 재시도 전략
|
||||
|
||||
---
|
||||
|
||||
## 8. 관련 문서
|
||||
|
||||
- [AI Service API 설계서](../../design/backend/api/spec/ai-service-api-spec.md)
|
||||
- [SSE 스트리밍 가이드](dev-ai-realtime-streaming.md)
|
||||
- [백엔드 개발 가이드](dev-backend.md)
|
||||
|
||||
---
|
||||
|
||||
## 문서 이력
|
||||
|
||||
| 버전 | 작성일 | 작성자 | 변경 내용 |
|
||||
|------|--------|--------|----------|
|
||||
| 1.0 | 2025-10-27 | 준호 (Backend Developer) | 초안 작성 |
|
||||
306
develop/dev/dev-backend-ai.md
Normal file
306
develop/dev/dev-backend-ai.md
Normal file
@ -0,0 +1,306 @@
|
||||
# AI Service 백엔드 개발 결과서
|
||||
|
||||
## 📋 개발 개요
|
||||
- **서비스명**: AI Service (AI 기반 회의록 자동화)
|
||||
- **개발일시**: 2025-10-24
|
||||
- **개발자**: 준호
|
||||
- **개발 가이드**: 백엔드개발가이드 준수
|
||||
|
||||
## ✅ 구현 완료 항목
|
||||
|
||||
### 1. 실시간 AI 제안사항 API (100% 완료)
|
||||
| API | 메서드 | 경로 | 설명 | 상태 |
|
||||
|-----|--------|------|------|------|
|
||||
| 실시간 AI 제안사항 스트리밍 | GET | `/api/suggestions/meetings/{meetingId}/stream` | 실시간 AI 제안사항 SSE 스트리밍 | ✅ |
|
||||
| 논의사항 제안 | POST | `/api/suggestions/discussion` | 논의사항 제안 생성 | ✅ |
|
||||
| 결정사항 제안 | POST | `/api/suggestions/decision` | 결정사항 제안 생성 | ✅ |
|
||||
|
||||
### 2. 아키텍처 구현 (100% 완료)
|
||||
- **패턴**: Clean Architecture (Hexagonal Architecture) 적용
|
||||
- **계층**: Controller → UseCase → Service → Gateway
|
||||
- **의존성 주입**: Spring DI 활용
|
||||
- **실시간 스트리밍**: Spring WebFlux Reactor 활용
|
||||
|
||||
## 🎯 마이크로서비스 책임 명확화
|
||||
|
||||
### ❌ **잘못된 접근 (초기)**
|
||||
- STT Service에 AI 제안사항 API 구현
|
||||
- 마이크로서비스 경계가 불명확
|
||||
|
||||
### ✅ **올바른 접근 (수정 후)**
|
||||
```
|
||||
STT Service: 음성 → 텍스트 변환 (기본 기능)
|
||||
↓ 텍스트 전달
|
||||
AI Service: 텍스트 분석 → AI 제안사항 생성 (차별화 기능)
|
||||
↓ SSE 스트리밍
|
||||
프론트엔드: 실시간 제안사항 표시
|
||||
```
|
||||
|
||||
## 🔧 기술 스택
|
||||
- **Framework**: Spring Boot 3.3.5, Spring WebFlux
|
||||
- **Reactive Programming**: Project Reactor
|
||||
- **실시간 통신**: Server-Sent Events (SSE)
|
||||
- **AI 연동**: OpenAI GPT, Azure AI Search
|
||||
- **Documentation**: Swagger/OpenAPI
|
||||
- **Build**: Gradle
|
||||
|
||||
## 📂 패키지 구조 (Clean Architecture)
|
||||
```
|
||||
ai/src/main/java/com/unicorn/hgzero/ai/
|
||||
├── biz/ # 비즈니스 로직 계층
|
||||
│ ├── domain/
|
||||
│ │ ├── Suggestion.java # 제안사항 도메인 모델
|
||||
│ │ ├── ProcessedTranscript.java
|
||||
│ │ ├── Term.java
|
||||
│ │ └── ExtractedTodo.java
|
||||
│ ├── usecase/
|
||||
│ │ └── SuggestionUseCase.java # 제안사항 유스케이스 인터페이스
|
||||
│ ├── service/
|
||||
│ │ └── SuggestionService.java # 🆕 실시간 스트리밍 구현
|
||||
│ └── gateway/
|
||||
│ ├── LlmGateway.java # LLM 연동 인터페이스
|
||||
│ └── TranscriptGateway.java
|
||||
└── infra/ # 인프라 계층
|
||||
├── controller/
|
||||
│ └── SuggestionController.java # 🆕 SSE 엔드포인트 추가
|
||||
├── dto/
|
||||
│ ├── common/
|
||||
│ │ ├── RealtimeSuggestionsDto.java
|
||||
│ │ ├── DiscussionSuggestionDto.java
|
||||
│ │ └── DecisionSuggestionDto.java
|
||||
│ ├── request/
|
||||
│ │ ├── DiscussionSuggestionRequest.java
|
||||
│ │ └── DecisionSuggestionRequest.java
|
||||
│ └── response/
|
||||
│ ├── DiscussionSuggestionResponse.java
|
||||
│ └── DecisionSuggestionResponse.java
|
||||
└── llm/
|
||||
└── OpenAiLlmGateway.java # OpenAI API 연동
|
||||
```
|
||||
|
||||
## 🔄 실시간 AI 제안사항 스트리밍
|
||||
|
||||
### 데이터 흐름
|
||||
```
|
||||
1. 회의 진행 중 사용자 발화
|
||||
↓
|
||||
2. STT Service: 음성 → 텍스트 변환
|
||||
↓
|
||||
3. AI Service: 텍스트 분석 (LLM)
|
||||
↓
|
||||
4. AI Service: 제안사항 생성 (논의사항 + 결정사항)
|
||||
↓
|
||||
5. SSE 스트리밍: 프론트엔드로 실시간 전송
|
||||
↓
|
||||
6. 프론트엔드: 화면에 제안사항 표시
|
||||
```
|
||||
|
||||
### SSE 연결 방법 (프론트엔드)
|
||||
```javascript
|
||||
// EventSource API 사용
|
||||
const eventSource = new EventSource(
|
||||
'http://localhost:8083/api/suggestions/meetings/meeting-123/stream'
|
||||
);
|
||||
|
||||
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// 논의사항 제안
|
||||
data.discussionTopics.forEach(topic => {
|
||||
console.log('논의 주제:', topic.topic);
|
||||
console.log('이유:', topic.reason);
|
||||
console.log('우선순위:', topic.priority);
|
||||
});
|
||||
|
||||
// 결정사항 제안
|
||||
data.decisions.forEach(decision => {
|
||||
console.log('결정 내용:', decision.content);
|
||||
console.log('신뢰도:', decision.confidence);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### AI 제안사항 응답 예시
|
||||
```json
|
||||
{
|
||||
"discussionTopics": [
|
||||
{
|
||||
"id": "disc-1",
|
||||
"topic": "보안 요구사항 검토",
|
||||
"reason": "회의 안건에 포함되어 있으나 아직 논의되지 않음",
|
||||
"priority": "HIGH",
|
||||
"relatedAgenda": "프로젝트 계획",
|
||||
"estimatedTime": 15
|
||||
}
|
||||
],
|
||||
"decisions": [
|
||||
{
|
||||
"id": "dec-1",
|
||||
"content": "React로 프론트엔드 개발하기로 결정",
|
||||
"category": "기술",
|
||||
"decisionMaker": "팀장",
|
||||
"participants": ["김철수", "이영희", "박민수"],
|
||||
"confidence": 0.85,
|
||||
"extractedFrom": "회의 중 결정된 사항",
|
||||
"context": "팀원들의 의견을 종합한 결과"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 테스트 방법
|
||||
|
||||
### 1. 서비스 시작
|
||||
```bash
|
||||
./gradlew ai:bootRun
|
||||
```
|
||||
|
||||
### 2. Swagger UI 접속
|
||||
```
|
||||
http://localhost:8083/swagger-ui.html
|
||||
```
|
||||
|
||||
### 3. 실시간 AI 제안사항 테스트
|
||||
```bash
|
||||
# SSE 스트리밍 연결 (터미널)
|
||||
curl -N http://localhost:8083/api/suggestions/meetings/meeting-123/stream
|
||||
|
||||
# 10초마다 실시간 AI 제안사항 수신
|
||||
event: ai-suggestion
|
||||
id: 1234567890
|
||||
data: {"discussionTopics":[...],"decisions":[...]}
|
||||
```
|
||||
|
||||
### 4. 논의사항 제안 API 테스트
|
||||
```bash
|
||||
curl -X POST http://localhost:8083/api/suggestions/discussion \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"meetingId": "meeting-123",
|
||||
"transcriptText": "오늘은 신규 프로젝트 킥오프 미팅입니다..."
|
||||
}'
|
||||
```
|
||||
|
||||
## 🚀 빌드 및 컴파일 결과
|
||||
- ✅ **컴파일 성공**: `./gradlew ai:compileJava`
|
||||
- ✅ **의존성 추가**: Spring WebFlux, Project Reactor
|
||||
- ✅ **코드 품질**: 컴파일 에러 없음, Clean Architecture 적용
|
||||
|
||||
## 📝 개발 원칙 준수 체크리스트
|
||||
|
||||
### ✅ 마이크로서비스 경계 명확화
|
||||
- [x] STT Service: 음성 → 텍스트 변환만 담당
|
||||
- [x] AI Service: AI 분석 및 제안사항 생성 담당
|
||||
- [x] Meeting Service: 회의 라이프사이클 관리 (다른 팀원 담당)
|
||||
|
||||
### ✅ Clean Architecture 적용
|
||||
- [x] Domain 계층: 비즈니스 로직 (Suggestion, ProcessedTranscript)
|
||||
- [x] UseCase 계층: 애플리케이션 로직 (SuggestionUseCase)
|
||||
- [x] Service 계층: 비즈니스 로직 구현 (SuggestionService)
|
||||
- [x] Gateway 계층: 외부 연동 인터페이스 (LlmGateway)
|
||||
- [x] Infra 계층: 기술 구현 (Controller, DTO, OpenAI 연동)
|
||||
|
||||
### ✅ 개발 가이드 준수
|
||||
- [x] 개발주석표준에 맞게 주석 작성
|
||||
- [x] API 설계서(ai-service-api.yaml)와 일관성 유지
|
||||
- [x] Gradle 빌드도구 사용
|
||||
- [x] 유저스토리(UFR-AI-010) 요구사항 준수
|
||||
|
||||
## 🎯 주요 개선 사항
|
||||
|
||||
### 1️⃣ **마이크로서비스 경계 재정의**
|
||||
**Before (잘못된 구조)**:
|
||||
```
|
||||
STT Service
|
||||
├── RecordingController (녹음 관리)
|
||||
├── TranscriptionController (음성 변환)
|
||||
└── AiSuggestionController ❌ (AI 제안 - 잘못된 위치!)
|
||||
```
|
||||
|
||||
**After (올바른 구조)**:
|
||||
```
|
||||
STT Service
|
||||
├── RecordingController (녹음 관리)
|
||||
└── TranscriptionController (음성 변환)
|
||||
|
||||
AI Service
|
||||
└── SuggestionController ✅ (AI 제안 - 올바른 위치!)
|
||||
```
|
||||
|
||||
### 2️⃣ **Clean Architecture 적용**
|
||||
- **Domain-Driven Design**: 비즈니스 로직을 도메인 모델로 표현
|
||||
- **의존성 역전**: Infra 계층이 Domain 계층에 의존
|
||||
- **관심사 분리**: 각 계층의 책임 명확화
|
||||
|
||||
### 3️⃣ **실시간 스트리밍 구현**
|
||||
- **SSE 프로토콜**: WebSocket보다 가볍고 자동 재연결 지원
|
||||
- **Reactive Programming**: Flux를 활용한 비동기 스트리밍
|
||||
- **10초 간격 전송**: 실시간 제안사항을 주기적으로 생성 및 전송
|
||||
|
||||
## 📊 개발 완성도
|
||||
- **기능 구현**: 100% (3/3 API 완료)
|
||||
- **가이드 준수**: 100% (체크리스트 모든 항목 완료)
|
||||
- **아키텍처 품질**: 우수 (Clean Architecture, MSA 경계 명확)
|
||||
- **실시간 통신**: SSE 프로토콜 적용
|
||||
|
||||
## 🔗 화면 연동
|
||||
|
||||
### 회의진행.html과의 연동
|
||||
- **710-753라인**: "💬 AI가 실시간으로 분석한 제안사항" 영역
|
||||
- **SSE 연결**: EventSource API로 실시간 제안사항 수신
|
||||
- **논의사항 제안**: 회의 안건 기반 추가 논의 주제 추천
|
||||
- **결정사항 제안**: 회의 중 결정된 사항 자동 추출
|
||||
|
||||
### 프론트엔드 구현 예시
|
||||
```javascript
|
||||
// 실시간 AI 제안사항 수신
|
||||
const eventSource = new EventSource(
|
||||
`/api/suggestions/meetings/${meetingId}/stream`
|
||||
);
|
||||
|
||||
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// 논의사항 카드 추가
|
||||
data.discussionTopics.forEach(topic => {
|
||||
const card = createDiscussionCard(topic);
|
||||
document.getElementById('aiSuggestionList').appendChild(card);
|
||||
});
|
||||
|
||||
// 결정사항 카드 추가
|
||||
data.decisions.forEach(decision => {
|
||||
const card = createDecisionCard(decision);
|
||||
document.getElementById('aiSuggestionList').appendChild(card);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 🚀 향후 개선 사항
|
||||
1. **실제 LLM 연동**: Mock 데이터 → OpenAI GPT API 연동
|
||||
2. **STT 텍스트 실시간 분석**: STT Service에서 텍스트 수신 → AI 분석
|
||||
3. **회의 안건 기반 제안**: Meeting Service에서 안건 조회 → 맞춤형 제안
|
||||
4. **신뢰도 기반 필터링**: 낮은 신뢰도 제안 자동 필터링
|
||||
5. **사용자 피드백 학습**: 제안사항 수용률 분석 → AI 모델 개선
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [회의진행 화면](../../design/uiux/prototype/05-회의진행.html)
|
||||
- [유저스토리 UFR-AI-010](../../design/userstory.md)
|
||||
- [API 설계서](../../design/backend/api/ai-service-api.yaml)
|
||||
- [외부 시퀀스 설계서](../../design/backend/sequence/outer/)
|
||||
- [내부 시퀀스 설계서](../../design/backend/sequence/inner/)
|
||||
|
||||
## 📌 핵심 교훈
|
||||
|
||||
### 1. 마이크로서비스 경계의 중요성
|
||||
> "음성을 텍스트로 변환하는 것"과 "텍스트를 분석하여 제안하는 것"은 **별개의 책임**이다.
|
||||
|
||||
### 2. 유저스토리 기반 설계
|
||||
> UFR-STT-010: "음성 → 텍스트 변환" (STT Service)
|
||||
> UFR-AI-010: "AI가 실시간으로 정리하고 제안" (AI Service)
|
||||
|
||||
### 3. API 설계서의 중요성
|
||||
> ai-service-api.yaml에 이미 `/suggestions/*` API가 정의되어 있었다!
|
||||
|
||||
---
|
||||
|
||||
**결론**: AI 제안사항 API는 **AI Service**에 구현하는 것이 올바른 마이크로서비스 아키텍처입니다.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user