mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 07:56:24 +00:00
Feat: AI 서비스 및 STT 서비스 기능 개선
- AI 서비스: Redis 캐싱 및 EventHub 통합 개선 - STT 서비스: 오디오 버퍼링 및 변환 기능 추가 - 설정 파일 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ad287de176
commit
032842cf53
@ -116,21 +116,45 @@ async def stream_ai_suggestions(meeting_id: str):
|
|||||||
if accumulated_text:
|
if accumulated_text:
|
||||||
logger.info(f"텍스트 누적 완료 - meetingId: {meeting_id}, 길이: {len(accumulated_text)}")
|
logger.info(f"텍스트 누적 완료 - meetingId: {meeting_id}, 길이: {len(accumulated_text)}")
|
||||||
|
|
||||||
|
# 이미 생성된 제안사항 조회
|
||||||
|
existing_suggestions = await redis_service.get_generated_suggestions(meeting_id)
|
||||||
|
|
||||||
# Claude API로 분석
|
# Claude API로 분석
|
||||||
suggestions = await claude_service.analyze_suggestions(accumulated_text)
|
suggestions = await claude_service.analyze_suggestions(accumulated_text)
|
||||||
|
|
||||||
if suggestions.suggestions:
|
if suggestions.suggestions:
|
||||||
# SSE 이벤트 전송
|
# 중복 제거: 새로운 제안사항만 필터링
|
||||||
yield {
|
new_suggestions = [
|
||||||
"event": "ai-suggestion",
|
s for s in suggestions.suggestions
|
||||||
"id": str(current_count),
|
if s.content not in existing_suggestions
|
||||||
"data": suggestions.json()
|
]
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
if new_suggestions:
|
||||||
f"AI 제안사항 발행 - meetingId: {meeting_id}, "
|
# 새로운 제안사항만 SSE 이벤트 전송
|
||||||
f"개수: {len(suggestions.suggestions)}"
|
from app.models import RealtimeSuggestionsResponse
|
||||||
)
|
filtered_response = RealtimeSuggestionsResponse(suggestions=new_suggestions)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"event": "ai-suggestion",
|
||||||
|
"id": str(current_count),
|
||||||
|
"data": filtered_response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redis에 새로운 제안사항 저장
|
||||||
|
for suggestion in new_suggestions:
|
||||||
|
await redis_service.add_generated_suggestion(
|
||||||
|
meeting_id,
|
||||||
|
suggestion.content
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"AI 제안사항 발행 - meetingId: {meeting_id}, "
|
||||||
|
f"전체: {len(suggestions.suggestions)}, 신규: {len(new_suggestions)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"중복 제거 후 신규 제안사항 없음 - meetingId: {meeting_id}"
|
||||||
|
)
|
||||||
|
|
||||||
previous_count = current_count
|
previous_count = current_count
|
||||||
|
|
||||||
@ -160,8 +184,6 @@ async def stream_ai_suggestions(meeting_id: str):
|
|||||||
headers={
|
headers={
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
"Access-Control-Allow-Origin": "http://localhost:8888",
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -36,14 +36,15 @@ class Settings(BaseSettings):
|
|||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:8888",
|
"http://127.0.0.1:8888",
|
||||||
"http://127.0.0.1:8080",
|
"http://127.0.0.1:8080",
|
||||||
"http://127.0.0.1:3000"
|
"http://127.0.0.1:3000",
|
||||||
|
"http://localhost:*" # 모든 localhost 포트 허용
|
||||||
]
|
]
|
||||||
|
|
||||||
# 로깅
|
# 로깅
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|
||||||
# 분석 임계값 (충분한 맥락 확보)
|
# 분석 임계값 (실시간 응답을 위해 낮춤)
|
||||||
min_segments_for_analysis: int = 4 # 4개 세그먼트 (약 60초, 제안사항 추출에 충분한 맥락)
|
min_segments_for_analysis: int = 2 # 2개 세그먼트 (약 30초, 빠른 피드백)
|
||||||
text_retention_seconds: int = 300 # 5분
|
text_retention_seconds: int = 300 # 5분
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@ -1,98 +1,432 @@
|
|||||||
"""AI 제안사항 추출 프롬프트 (MVP 최적화)"""
|
"""AI 제안사항 추출 프롬프트 (회의록 작성 MVP 최적화)"""
|
||||||
|
|
||||||
|
|
||||||
def get_suggestions_prompt(transcript_text: str) -> tuple[str, str]:
|
def get_suggestions_prompt(transcript_text: str) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
회의 텍스트에서 AI 제안사항을 추출하는 프롬프트 생성 (MVP용)
|
회의 텍스트에서 AI 제안사항을 추출하는 프롬프트 생성 (회의록 MVP용)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(system_prompt, user_prompt) 튜플
|
(system_prompt, user_prompt) 튜플
|
||||||
"""
|
"""
|
||||||
|
|
||||||
system_prompt = """당신은 회의 내용에서 실행 가능한 액션 아이템을 찾는 전문가입니다.
|
system_prompt = """당신은 실시간 회의록 작성 AI 비서입니다.
|
||||||
복잡한 분석보다는, 명확하게 "해야 할 일"이 언급된 부분을 빠르게 찾아내는 것이 목표입니다."""
|
|
||||||
|
|
||||||
user_prompt = f"""다음 회의 대화에서 **실행해야 할 제안사항**을 찾아주세요.
|
**핵심 역할**:
|
||||||
|
회의 중 발언되는 내용을 실시간으로 분석하여, 회의록 작성자가 놓칠 수 있는 중요한 정보를 즉시 메모로 제공합니다.
|
||||||
|
|
||||||
|
**작업 방식**:
|
||||||
|
1. 회의 안건, 결정 사항, 이슈, 액션 아이템을 자동으로 분류
|
||||||
|
2. 담당자, 기한, 우선순위 등 구조화된 정보로 정리
|
||||||
|
3. 단순 발언 반복이 아닌, 실무에 바로 사용 가능한 형식으로 요약
|
||||||
|
4. 회의록 작성 시간을 70% 단축시키는 것이 목표
|
||||||
|
|
||||||
|
**핵심 원칙**:
|
||||||
|
- 인사말, 반복, 불필요한 추임새는 완전히 제거
|
||||||
|
- 실제 회의록에 들어갈 내용만 추출
|
||||||
|
- 명확하고 간결하게 (20-50자)
|
||||||
|
- 구어체 종결어미(~다, ~요, ~습니다) 제거하고 명사형으로 정리"""
|
||||||
|
|
||||||
|
user_prompt = f"""다음 회의 대화를 실시간으로 분석하여 **회의록 메모**를 작성하세요.
|
||||||
|
|
||||||
# 회의 내용
|
# 회의 내용
|
||||||
{transcript_text}
|
{transcript_text}
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 제안사항을 찾는 간단한 방법
|
# 회의록 항목별 패턴 학습
|
||||||
|
|
||||||
아래 패턴이 포함된 문장을 찾으세요:
|
## 📋 1. 회의 안건 (Agenda)
|
||||||
|
|
||||||
## ✅ 명확한 액션 패턴
|
### 패턴 인식
|
||||||
- "~해야 한다", "~해야 할 것 같다"
|
- "오늘 회의 안건은 ~"
|
||||||
- "~하기로 했다", "~하기로 결정"
|
- "논의할 주제는 ~"
|
||||||
- "~할 예정이다", "~할 계획이다"
|
- "다룰 내용은 ~"
|
||||||
- "~해주세요", "~부탁드립니다"
|
- "검토할 사항은 ~"
|
||||||
- "~하도록 하겠습니다", "~진행하겠습니다"
|
|
||||||
- "~확인해 보겠습니다", "~검토하겠습니다"
|
|
||||||
|
|
||||||
## ⏰ 시간 관련 표현
|
### ✅ 좋은 예시
|
||||||
- "다음 주까지", "이번 주 금요일까지"
|
**입력**: "오늘 회의 안건은 신제품 출시 일정과 마케팅 전략입니다."
|
||||||
- "내일", "오늘 중으로"
|
**출력**:
|
||||||
- "회의 전까지", "발표 전에"
|
```json
|
||||||
|
{{
|
||||||
|
"content": "📋 회의 안건: 신제품 출시 일정, 마케팅 전략",
|
||||||
|
"confidence": 0.95
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
## 👤 담당자 관련 표현
|
**입력**: "다음 주 프로젝트 킥오프에 대해 논의하겠습니다."
|
||||||
- "김 대리가", "박 과장님이"
|
**출력**:
|
||||||
- "우리 팀에서", "마케팅팀이"
|
```json
|
||||||
- "제가", "저희가"
|
{{
|
||||||
|
"content": "📋 회의 안건: 다음 주 프로젝트 킥오프",
|
||||||
|
"confidence": 0.90
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
# 실제 회의 예시로 학습하기
|
### ❌ 나쁜 예시
|
||||||
|
**입력**: "오늘 회의 안건은 신제품 출시 일정입니다."
|
||||||
|
**나쁜 출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "오늘 회의 안건은 신제품 출시 일정입니다", ❌ 구어체 그대로 반복
|
||||||
|
"confidence": 0.90
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
**이유**: 구어체 종결어미(~입니다) 그대로 반복. "📋 회의 안건: 신제품 출시 일정"으로 구조화해야 함
|
||||||
|
|
||||||
## 예시 1
|
---
|
||||||
**회의 내용**: "마케팅 예산안을 김 팀장님이 다음 주 수요일까지 검토해서 공유해 주시기로 했습니다."
|
|
||||||
**추출**: "마케팅 예산안을 다음 주 수요일까지 검토하여 공유" (담당: 김 팀장)
|
|
||||||
|
|
||||||
## 예시 2
|
## ✅ 2. 결정 사항 (Decisions)
|
||||||
**회의 내용**: "그럼 제가 내일 오전에 고객사에 연락해서 미팅 일정을 잡도록 하겠습니다."
|
|
||||||
**추출**: "고객사에 연락하여 미팅 일정 조율" (시간: 내일 오전)
|
|
||||||
|
|
||||||
## 예시 3
|
### 패턴 인식
|
||||||
**회의 내용**: "법무팀과 계약서 검토를 이번 주 내로 끝내야 할 것 같아요."
|
- "결정 사항은 ~", "~로 결정했습니다"
|
||||||
**추출**: "법무팀과 계약서 검토 진행" (기한: 이번 주 내)
|
- "~하기로 했습니다", "~로 합의했습니다"
|
||||||
|
- "~로 확정됐습니다"
|
||||||
|
- "최종 결론은 ~"
|
||||||
|
|
||||||
|
### ✅ 좋은 예시
|
||||||
|
**입력**: "회의 결과, 신규 프로젝트는 다음 달부터 착수하기로 결정했습니다."
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "✅ 결정사항: 신규 프로젝트 다음 달 착수",
|
||||||
|
"confidence": 0.95
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**입력**: "최종 결론은 외주 개발사와 계약하기로 합의했습니다."
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "✅ 결정사항: 외주 개발사와 계약 진행",
|
||||||
|
"confidence": 0.92
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 나쁜 예시
|
||||||
|
**입력**: "신규 프로젝트는 다음 달부터 착수하기로 결정했습니다."
|
||||||
|
**나쁜 출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "신규 프로젝트는 다음 달부터 착수하기로 결정했습니다", ❌ 원문 그대로
|
||||||
|
"confidence": 0.90
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
**이유**: 발언을 그대로 반복. "✅ 결정사항: 신규 프로젝트 다음 달 착수"로 구조화해야 함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 3. 액션 아이템 (Action Items)
|
||||||
|
|
||||||
|
### 패턴 인식
|
||||||
|
- "~팀에서 ~해 주세요"
|
||||||
|
- "~님이 ~까지 ~하기로 했습니다"
|
||||||
|
- "~을 ~까지 완료하겠습니다"
|
||||||
|
- "~을 검토해 보겠습니다"
|
||||||
|
|
||||||
|
### ✅ 좋은 예시
|
||||||
|
**입력**: "개발팀에서 API 문서를 이번 주 금요일까지 작성해 주세요."
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "🎯 개발팀: API 문서 작성 (기한: 이번 주 금요일)",
|
||||||
|
"confidence": 0.95
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**입력**: "김 팀장님이 내일까지 견적서를 검토해서 회신하기로 했습니다."
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "🎯 김 팀장: 견적서 검토 및 회신 (기한: 내일)",
|
||||||
|
"confidence": 0.93
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**입력**: "제가 고객사에 연락해서 미팅 일정 잡도록 하겠습니다."
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "🎯 고객사 미팅 일정 조율 예정",
|
||||||
|
"confidence": 0.85
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 나쁜 예시
|
||||||
|
**입력**: "개발팀에서 API 문서를 이번 주 금요일까지 작성해 주세요."
|
||||||
|
**나쁜 출력 1**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "개발팀에서 API 문서를 이번 주 금요일까지 작성해 주세요", ❌ 원문 반복
|
||||||
|
"confidence": 0.90
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
**나쁜 출력 2**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "API 문서 작성", ❌ 담당자와 기한 누락
|
||||||
|
"confidence": 0.80
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
**이유**: "🎯 개발팀: API 문서 작성 (기한: 이번 주 금요일)" 형식으로 구조화해야 함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 4. 이슈/문제점 (Issues)
|
||||||
|
|
||||||
|
### 패턴 인식
|
||||||
|
- "문제가 있습니다", "이슈가 발생했습니다"
|
||||||
|
- "우려되는 점은 ~"
|
||||||
|
- "해결이 필요한 부분은 ~"
|
||||||
|
- "리스크가 있습니다"
|
||||||
|
|
||||||
|
### ✅ 좋은 예시
|
||||||
|
**입력**: "현재 서버 성능 이슈가 발생해서 긴급 점검이 필요합니다."
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "⚠️ 이슈: 서버 성능 문제 발생, 긴급 점검 필요",
|
||||||
|
"confidence": 0.92
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**입력**: "예산이 부족할 것 같다는 우려가 있습니다."
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "⚠️ 이슈: 예산 부족 우려",
|
||||||
|
"confidence": 0.80
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 나쁜 예시
|
||||||
|
**입력**: "현재 서버 성능 이슈가 발생했습니다."
|
||||||
|
**나쁜 출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "현재 서버 성능 이슈가 발생했습니다", ❌ 구어체 그대로
|
||||||
|
"confidence": 0.85
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
**이유**: "⚠️ 이슈: 서버 성능 문제 발생"으로 구조화하고 구어체 제거해야 함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 5. 아이디어/제안 (Suggestions)
|
||||||
|
|
||||||
|
### 패턴 인식
|
||||||
|
- "제안하는 바는 ~"
|
||||||
|
- "~하는 것이 좋을 것 같습니다"
|
||||||
|
- "~을 고려해 볼 필요가 있습니다"
|
||||||
|
|
||||||
|
### ✅ 좋은 예시
|
||||||
|
**입력**: "자동화 테스트를 도입하는 것을 검토해 보면 좋을 것 같습니다."
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "💡 제안: 자동화 테스트 도입 검토",
|
||||||
|
"confidence": 0.85
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 6. 진행 상황/보고 (Progress)
|
||||||
|
|
||||||
|
### 패턴 인식
|
||||||
|
- "~까지 완료했습니다"
|
||||||
|
- "현재 ~% 진행 중입니다"
|
||||||
|
- "~단계까지 진행됐습니다"
|
||||||
|
|
||||||
|
### ✅ 좋은 예시
|
||||||
|
**입력**: "현재 설계 단계는 80% 완료됐고, 다음 주부터 개발 착수 가능합니다."
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"content": "📊 진행상황: 설계 80% 완료, 다음 주 개발 착수 예정",
|
||||||
|
"confidence": 0.90
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ 제외해야 할 내용 (반드시 제외)
|
||||||
|
|
||||||
|
### 인사말
|
||||||
|
**입력**: "안녕하세요, 여러분. 회의 시작하겠습니다."
|
||||||
|
**출력**: (메모 없음 - 인사말은 제외)
|
||||||
|
|
||||||
|
### 단순 반복
|
||||||
|
**입력**: "녹음을 시작합니다. 녹음을 시작합니다."
|
||||||
|
**출력**: (메모 없음 - 형식적 발언 제외)
|
||||||
|
|
||||||
|
### 추임새/불필요한 발언
|
||||||
|
**입력**: "음, 그러니까, 네 네, 저기요..."
|
||||||
|
**출력**: (메모 없음 - 추임새 제외)
|
||||||
|
|
||||||
|
### 형식적 마무리
|
||||||
|
**입력**: "수고하셨습니다. 회의를 마치겠습니다."
|
||||||
|
**출력**: (메모 없음 - 형식적 마무리 제외)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 실전 회의 시뮬레이션
|
||||||
|
|
||||||
|
## 예시 1: 프로젝트 킥오프 회의
|
||||||
|
|
||||||
|
**입력**:
|
||||||
|
"안녕하세요. 오늘 회의 안건은 신규 프로젝트 킥오프입니다. 프로젝트명은 HGZero이고, 목표는 회의록 자동화입니다. 개발팀에서 다음 주 월요일까지 기술 스택을 검토해 주세요. 예산은 5천만원으로 확정됐습니다."
|
||||||
|
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"suggestions": [
|
||||||
|
{{
|
||||||
|
"content": "📋 회의 안건: 신규 프로젝트(HGZero) 킥오프 - 회의록 자동화",
|
||||||
|
"confidence": 0.95
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"content": "🎯 개발팀: 기술 스택 검토 (기한: 다음 주 월요일)",
|
||||||
|
"confidence": 0.93
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"content": "✅ 결정사항: 프로젝트 예산 5천만원 확정",
|
||||||
|
"confidence": 0.95
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 예시 2: 이슈 대응 회의
|
||||||
|
|
||||||
|
**입력**:
|
||||||
|
"현재 프로덕션 서버에서 성능 저하가 발생하고 있습니다. 인프라팀에서 긴급 점검을 진행하기로 했고, 오늘 오후 3시까지 원인 파악하겠습니다. 고객사에는 임시로 사과 공지를 게시하기로 결정했습니다."
|
||||||
|
|
||||||
|
**출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"suggestions": [
|
||||||
|
{{
|
||||||
|
"content": "⚠️ 이슈: 프로덕션 서버 성능 저하 발생",
|
||||||
|
"confidence": 0.95
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"content": "🎯 인프라팀: 긴급 점검 및 원인 파악 (기한: 오늘 오후 3시)",
|
||||||
|
"confidence": 0.93
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"content": "✅ 결정사항: 고객사 사과 공지 게시",
|
||||||
|
"confidence": 0.90
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 예시 3: 일반 업무 회의 (나쁜 예시 포함)
|
||||||
|
|
||||||
|
**입력**:
|
||||||
|
"안녕하세요, 안녕하세요. 녹음을 시작합니다. 음, 그러니까 마케팅 캠페인을 다음 달에 진행하기로 했습니다. 김 과장님이 기획안을 이번 주까지 작성해 주세요. 감사합니다."
|
||||||
|
|
||||||
|
**❌ 나쁜 출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"suggestions": [
|
||||||
|
{{
|
||||||
|
"content": "안녕하세요", ❌ 인사말 포함
|
||||||
|
"confidence": 0.50
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"content": "녹음을 시작합니다", ❌ 형식적 발언
|
||||||
|
"confidence": 0.60
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"content": "마케팅 캠페인을 다음 달에 진행하기로 했습니다", ❌ 구어체 그대로
|
||||||
|
"confidence": 0.80
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ 좋은 출력**:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"suggestions": [
|
||||||
|
{{
|
||||||
|
"content": "✅ 결정사항: 마케팅 캠페인 다음 달 진행",
|
||||||
|
"confidence": 0.92
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"content": "🎯 김 과장: 캠페인 기획안 작성 (기한: 이번 주)",
|
||||||
|
"confidence": 0.93
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 출력 형식
|
# 출력 형식
|
||||||
|
|
||||||
반드시 아래 JSON 형식으로만 응답하세요:
|
반드시 아래 JSON 형식으로만 응답하세요:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{{
|
{{
|
||||||
"suggestions": [
|
"suggestions": [
|
||||||
{{
|
{{
|
||||||
"content": "제안사항 내용 (구체적이고 실행 가능하게, 50자 이상 작성)",
|
"content": "📋/✅/🎯/⚠️/💡/📊 분류: 구체적인 내용 (담당자/기한 포함)",
|
||||||
"confidence": 0.85 (이 제안사항의 중요도/확실성, 0.7-1.0 사이)
|
"confidence": 0.85
|
||||||
}},
|
|
||||||
{{
|
|
||||||
"content": "또 다른 제안사항",
|
|
||||||
"confidence": 0.92
|
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
# MVP 추출 규칙 (쉽고 명확하게)
|
---
|
||||||
|
|
||||||
1. **위에 제시된 패턴을 먼저 찾으세요**
|
# 최종 작성 규칙
|
||||||
- "~해야", "~하기로", "~할 예정", "~부탁" 등
|
|
||||||
|
|
||||||
2. **실제로 언급된 내용만 추출** (추측 금지)
|
## ✅ 반드시 지켜야 할 규칙
|
||||||
|
|
||||||
3. **1개 이상 추출** (없으면 빈 배열 반환)
|
1. **이모지 분류 필수**
|
||||||
|
- 📋 회의 안건
|
||||||
|
- ✅ 결정사항
|
||||||
|
- 🎯 액션 아이템
|
||||||
|
- ⚠️ 이슈/문제점
|
||||||
|
- 💡 제안/아이디어
|
||||||
|
- 📊 진행상황
|
||||||
|
|
||||||
4. **confidence 기준 완화**: 0.6 이상이면 OK
|
2. **구조화 필수**
|
||||||
|
- 담당자가 있으면 반드시 명시
|
||||||
|
- 기한이 있으면 반드시 포함
|
||||||
|
- 형식: "담당자: 업무 내용 (기한: XX)"
|
||||||
|
|
||||||
5. **길이 제한 완화**: 20자 이상이면 OK
|
3. **구어체 종결어미 제거**
|
||||||
|
- ❌ "~입니다", "~했습니다", "~해요", "~합니다"
|
||||||
|
- ✅ 명사형 종결: "~ 진행", "~ 완료", "~ 확정", "~ 검토"
|
||||||
|
|
||||||
6. **JSON만 출력** (```json, 주석, 설명 모두 금지)
|
4. **반드시 제외**
|
||||||
|
- 인사말 ("안녕하세요", "감사합니다", "수고하셨습니다")
|
||||||
|
- 반복/추임새 ("네 네", "음 음", "그러니까", "저기")
|
||||||
|
- 형식적 발언 ("녹음 시작", "회의 종료", "회의 시작")
|
||||||
|
|
||||||
|
5. **길이**
|
||||||
|
- 20-70자 (너무 짧거나 길지 않게)
|
||||||
|
|
||||||
|
6. **confidence 기준**
|
||||||
|
- 0.90-1.0: 명확한 결정사항, 기한 포함
|
||||||
|
- 0.80-0.89: 일반적인 액션 아이템
|
||||||
|
- 0.70-0.79: 암묵적이거나 추측 필요
|
||||||
|
|
||||||
|
7. **출력**
|
||||||
|
- JSON만 출력 (주석, 설명, ```json 모두 금지)
|
||||||
|
- 최소 1개 이상 추출 (의미 있는 내용이 없으면 빈 배열)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
이제 위 회의 내용에서 제안사항을 JSON 형식으로 추출하세요.
|
이제 위 회의 내용을 분석하여 **회의록 메모**를 JSON 형식으로 작성하세요.
|
||||||
명확한 액션 패턴("~해야", "~하기로" 등)이 있는 문장을 찾아 추출하면 됩니다."""
|
학습한 패턴을 활용하여 회의 안건, 결정사항, 액션 아이템, 이슈 등을 자동으로 분류하고 구조화하세요.
|
||||||
|
반드시 구어체 종결어미(~다, ~요, ~습니다)를 제거하고 명사형으로 정리하세요."""
|
||||||
|
|
||||||
return system_prompt, user_prompt
|
return system_prompt, user_prompt
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
from azure.eventhub.aio import EventHubConsumerClient
|
from azure.eventhub.aio import EventHubConsumerClient
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
@ -63,12 +64,30 @@ class EventHubService:
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 이벤트 원본 데이터 로깅
|
# 이벤트 원본 데이터 추출
|
||||||
raw_body = event.body_as_str()
|
try:
|
||||||
logger.info(f"수신한 이벤트 원본 (처음 300자): {raw_body[:300]}")
|
# Event Hub 데이터는 bytes 또는 str일 수 있음
|
||||||
|
if hasattr(event, 'body_as_str'):
|
||||||
|
raw_body = event.body_as_str()
|
||||||
|
elif hasattr(event, 'body'):
|
||||||
|
raw_body = event.body.decode('utf-8') if isinstance(event.body, bytes) else str(event.body)
|
||||||
|
else:
|
||||||
|
logger.error(f"이벤트 타입 미지원: {type(event)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"수신한 이벤트 원본 (처음 300자): {raw_body[:300]}")
|
||||||
|
logger.debug(f"이벤트 전체 길이: {len(raw_body)}자")
|
||||||
|
except Exception as extract_error:
|
||||||
|
logger.error(f"이벤트 데이터 추출 실패: {extract_error}", exc_info=True)
|
||||||
|
return
|
||||||
|
|
||||||
# 이벤트 데이터 파싱
|
# 이벤트 데이터 파싱
|
||||||
event_data = json.loads(raw_body)
|
try:
|
||||||
|
event_data = json.loads(raw_body)
|
||||||
|
except json.JSONDecodeError as json_error:
|
||||||
|
logger.error(f"JSON 파싱 실패 - 전체 데이터: {raw_body}")
|
||||||
|
logger.error(f"파싱 에러: {json_error}")
|
||||||
|
return
|
||||||
|
|
||||||
event_type = event_data.get("eventType")
|
event_type = event_data.get("eventType")
|
||||||
meeting_id = event_data.get("meetingId")
|
meeting_id = event_data.get("meetingId")
|
||||||
@ -78,7 +97,6 @@ class EventHubService:
|
|||||||
# timestamp 변환: LocalDateTime 배열 → Unix timestamp (ms)
|
# timestamp 변환: LocalDateTime 배열 → Unix timestamp (ms)
|
||||||
# Java LocalDateTime은 [year, month, day, hour, minute, second, nano] 형식
|
# Java LocalDateTime은 [year, month, day, hour, minute, second, nano] 형식
|
||||||
if isinstance(timestamp_raw, list) and len(timestamp_raw) >= 3:
|
if isinstance(timestamp_raw, list) and len(timestamp_raw) >= 3:
|
||||||
from datetime import datetime
|
|
||||||
year, month, day = timestamp_raw[0:3]
|
year, month, day = timestamp_raw[0:3]
|
||||||
hour = timestamp_raw[3] if len(timestamp_raw) > 3 else 0
|
hour = timestamp_raw[3] if len(timestamp_raw) > 3 else 0
|
||||||
minute = timestamp_raw[4] if len(timestamp_raw) > 4 else 0
|
minute = timestamp_raw[4] if len(timestamp_raw) > 4 else 0
|
||||||
|
|||||||
@ -105,6 +105,34 @@ class RedisService:
|
|||||||
count = await self.redis_client.zcard(key)
|
count = await self.redis_client.zcard(key)
|
||||||
return count if count else 0
|
return count if count else 0
|
||||||
|
|
||||||
|
async def add_generated_suggestion(self, meeting_id: str, suggestion_content: str):
|
||||||
|
"""
|
||||||
|
생성된 제안사항 저장 (중복 방지용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meeting_id: 회의 ID
|
||||||
|
suggestion_content: 제안사항 내용
|
||||||
|
"""
|
||||||
|
key = f"meeting:{meeting_id}:suggestions"
|
||||||
|
await self.redis_client.sadd(key, suggestion_content)
|
||||||
|
# TTL 설정 (1시간)
|
||||||
|
await self.redis_client.expire(key, 3600)
|
||||||
|
logger.debug(f"제안사항 저장 - meetingId: {meeting_id}")
|
||||||
|
|
||||||
|
async def get_generated_suggestions(self, meeting_id: str) -> set:
|
||||||
|
"""
|
||||||
|
이미 생성된 제안사항 목록 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meeting_id: 회의 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
제안사항 set
|
||||||
|
"""
|
||||||
|
key = f"meeting:{meeting_id}:suggestions"
|
||||||
|
suggestions = await self.redis_client.smembers(key)
|
||||||
|
return suggestions if suggestions else set()
|
||||||
|
|
||||||
async def cleanup_meeting_data(self, meeting_id: str):
|
async def cleanup_meeting_data(self, meeting_id: str):
|
||||||
"""
|
"""
|
||||||
회의 종료 시 데이터 정리
|
회의 종료 시 데이터 정리
|
||||||
@ -112,6 +140,10 @@ class RedisService:
|
|||||||
Args:
|
Args:
|
||||||
meeting_id: 회의 ID
|
meeting_id: 회의 ID
|
||||||
"""
|
"""
|
||||||
key = f"meeting:{meeting_id}:transcript"
|
transcript_key = f"meeting:{meeting_id}:transcript"
|
||||||
await self.redis_client.delete(key)
|
suggestions_key = f"meeting:{meeting_id}:suggestions"
|
||||||
|
|
||||||
|
await self.redis_client.delete(transcript_key)
|
||||||
|
await self.redis_client.delete(suggestions_key)
|
||||||
|
|
||||||
logger.info(f"회의 데이터 정리 완료 - meetingId: {meeting_id}")
|
logger.info(f"회의 데이터 정리 완료 - meetingId: {meeting_id}")
|
||||||
|
|||||||
@ -28,13 +28,24 @@ app = FastAPI(
|
|||||||
openapi_url="/api/openapi.json"
|
openapi_url="/api/openapi.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS 미들웨어 설정
|
# CORS 미들웨어 설정 (SSE 지원)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=["*"], # 개발 환경에서는 모든 origin 허용
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=[
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"X-Requested-With",
|
||||||
|
"Accept",
|
||||||
|
"Origin",
|
||||||
|
"Access-Control-Request-Method",
|
||||||
|
"Access-Control-Request-Headers",
|
||||||
|
"Cache-Control",
|
||||||
|
"X-Accel-Buffering"
|
||||||
|
],
|
||||||
|
expose_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# API 라우터 등록
|
# API 라우터 등록
|
||||||
|
|||||||
@ -2,7 +2,7 @@ package com.unicorn.hgzero.stt.controller;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.unicorn.hgzero.stt.dto.AudioChunkDto;
|
import com.unicorn.hgzero.stt.dto.AudioChunkDto;
|
||||||
import com.unicorn.hgzero.stt.service.AudioBufferService;
|
import com.unicorn.hgzero.stt.service.InMemoryAudioBufferService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@ -24,7 +24,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AudioWebSocketHandler extends AbstractWebSocketHandler {
|
public class AudioWebSocketHandler extends AbstractWebSocketHandler {
|
||||||
|
|
||||||
private final AudioBufferService audioBufferService;
|
private final InMemoryAudioBufferService audioBufferService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
// 세션별 회의 ID 매핑
|
// 세션별 회의 ID 매핑
|
||||||
|
|||||||
@ -1,74 +1,118 @@
|
|||||||
package com.unicorn.hgzero.stt.event.publisher;
|
package com.unicorn.hgzero.stt.event.publisher;
|
||||||
|
|
||||||
|
import com.azure.messaging.eventhubs.EventData;
|
||||||
|
import com.azure.messaging.eventhubs.EventDataBatch;
|
||||||
|
import com.azure.messaging.eventhubs.EventHubClientBuilder;
|
||||||
|
import com.azure.messaging.eventhubs.EventHubProducerClient;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Azure Event Hub 이벤트 발행자 구현체
|
* Azure Event Hub 이벤트 발행자 구현체
|
||||||
* Azure Event Hubs를 통한 이벤트 발행 기능
|
* Azure Event Hubs를 통한 실제 이벤트 발행 기능
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class EventHubPublisher implements EventPublisher {
|
public class EventHubPublisher implements EventPublisher {
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${azure.eventhub.connection-string}")
|
||||||
|
private String connectionString;
|
||||||
|
|
||||||
|
@Value("${azure.eventhub.name}")
|
||||||
|
private String eventHubName;
|
||||||
|
|
||||||
|
private EventHubProducerClient producerClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event Hub Producer Client 초기화
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public void initialize() {
|
||||||
|
if (connectionString == null || connectionString.isEmpty()) {
|
||||||
|
log.warn("Event Hub 연결 문자열이 설정되지 않음 - Event Hub 발행 비활성화");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
producerClient = new EventHubClientBuilder()
|
||||||
|
.connectionString(connectionString, eventHubName)
|
||||||
|
.buildProducerClient();
|
||||||
|
|
||||||
|
log.info("Event Hub Producer Client 초기화 완료 - EventHub: {}", eventHubName);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Event Hub Producer Client 초기화 실패", e);
|
||||||
|
throw new RuntimeException("Event Hub 연결에 실패했습니다", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 애플리케이션 종료 시 Producer Client 정리
|
||||||
|
*/
|
||||||
|
@PreDestroy
|
||||||
|
public void cleanup() {
|
||||||
|
if (producerClient != null) {
|
||||||
|
try {
|
||||||
|
producerClient.close();
|
||||||
|
log.info("Event Hub Producer Client 종료 완료");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Event Hub Producer Client 종료 중 오류", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void publish(String topic, Object event) {
|
public void publish(String topic, Object event) {
|
||||||
|
if (producerClient == null) {
|
||||||
|
log.warn("Event Hub가 연결되지 않음 - 이벤트 발행 건너뜀");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String eventData = objectMapper.writeValueAsString(event);
|
String eventData = objectMapper.writeValueAsString(event);
|
||||||
|
|
||||||
// 실제로는 Azure Event Hubs SDK 사용
|
// Event Data Batch 생성 및 전송
|
||||||
// EventHubProducerClient producer = createProducer(topic);
|
EventDataBatch batch = producerClient.createBatch();
|
||||||
// EventDataBatch batch = producer.createBatch();
|
|
||||||
// batch.tryAdd(new EventData(eventData));
|
// 이벤트 추가
|
||||||
// producer.send(batch);
|
boolean added = batch.tryAdd(new EventData(eventData));
|
||||||
|
if (!added) {
|
||||||
// 시뮬레이션
|
log.error("이벤트가 배치 크기를 초과하여 추가 실패 - 데이터 길이: {}", eventData.length());
|
||||||
simulateEventHubPublish(topic, eventData);
|
throw new RuntimeException("이벤트 배치 추가 실패");
|
||||||
|
}
|
||||||
log.info("이벤트 발행 완료 - topic: {}, event: {}", topic, event.getClass().getSimpleName());
|
|
||||||
|
// Event Hub로 전송
|
||||||
|
producerClient.send(batch);
|
||||||
|
|
||||||
|
log.info("✅ 이벤트 발행 완료 - EventHub: {}, EventType: {}, 데이터 길이: {}자",
|
||||||
|
eventHubName, event.getClass().getSimpleName(), eventData.length());
|
||||||
|
log.debug("발행된 데이터: {}", eventData);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이벤트 발행 실패 - topic: {}, event: {}", topic, event.getClass().getSimpleName(), e);
|
log.error("이벤트 발행 실패 - EventHub: {}, EventType: {}",
|
||||||
|
eventHubName, event.getClass().getSimpleName(), e);
|
||||||
throw new RuntimeException("이벤트 발행에 실패했습니다", e);
|
throw new RuntimeException("이벤트 발행에 실패했습니다", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void publishAsync(String topic, Object event) {
|
public void publishAsync(String topic, Object event) {
|
||||||
CompletableFuture.runAsync(() -> {
|
CompletableFuture.runAsync(() -> {
|
||||||
try {
|
try {
|
||||||
publish(topic, event);
|
publish(topic, event);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("비동기 이벤트 발행 실패 - topic: {}, event: {}", topic, event.getClass().getSimpleName(), e);
|
log.error("비동기 이벤트 발행 실패 - EventHub: {}, EventType: {}",
|
||||||
|
eventHubName, event.getClass().getSimpleName(), e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Azure Event Hub 발행 시뮬레이션
|
|
||||||
*/
|
|
||||||
private void simulateEventHubPublish(String topic, String eventData) {
|
|
||||||
log.debug("Event Hub 발행 시뮬레이션:");
|
|
||||||
log.debug("Topic: {}", topic);
|
|
||||||
log.debug("Event Data: {}", eventData);
|
|
||||||
|
|
||||||
// 실제로는 다음과 같은 Azure Event Hubs 코드 사용:
|
|
||||||
/*
|
|
||||||
EventHubProducerClient producer = new EventHubClientBuilder()
|
|
||||||
.connectionString(connectionString, eventHubName)
|
|
||||||
.buildProducerClient();
|
|
||||||
|
|
||||||
EventDataBatch batch = producer.createBatch();
|
|
||||||
batch.tryAdd(new EventData(eventData));
|
|
||||||
producer.send(batch);
|
|
||||||
producer.close();
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -15,30 +15,44 @@ import java.util.Set;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 오디오 배치 프로세서
|
* 오디오 배치 프로세서 (2단계 처리)
|
||||||
* 15초마다 Redis에 축적된 오디오를 처리하여 텍스트로 변환
|
|
||||||
*
|
*
|
||||||
* Note: STT 결과는 DB에 저장하지 않고, Event Hub와 WebSocket으로만 전송
|
* 1단계 (STT): 5초마다 짧은 세그먼트 변환
|
||||||
* 최종 회의록은 AI 서비스에서 저장
|
* - 빠른 음성 인식으로 실시간 피드백
|
||||||
|
* - WebSocket으로 클라이언트에 즉시 표시
|
||||||
|
* - Event Hub로 AI 서비스에 전송 (누적)
|
||||||
|
*
|
||||||
|
* 2단계 (AI): AI 서비스에서 1분치 세그먼트로 제안사항 생성
|
||||||
|
* - Redis 누적 텍스트 (4-5개 세그먼트) 분석
|
||||||
|
* - SSE로 실시간 제안사항 전송
|
||||||
|
*
|
||||||
|
* Note: STT 결과는 DB 저장 없음, Event Hub와 WebSocket으로만 전송
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AudioBatchProcessor {
|
public class AudioBatchProcessor {
|
||||||
|
|
||||||
private final AudioBufferService audioBufferService;
|
private final InMemoryAudioBufferService audioBufferService;
|
||||||
private final AzureSpeechService azureSpeechService;
|
private final AzureSpeechService azureSpeechService;
|
||||||
private final EventPublisher eventPublisher;
|
private final EventPublisher eventPublisher;
|
||||||
private final AudioWebSocketHandler webSocketHandler;
|
private final AudioWebSocketHandler webSocketHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 15초마다 오디오 배치 처리
|
* 7초마다 오디오 배치 처리 (실시간 STT)
|
||||||
* - Redis에서 오디오 청크 조회
|
* - Redis에서 오디오 청크 조회
|
||||||
* - Azure Speech로 텍스트 변환
|
* - Azure Speech로 텍스트 변환 (적절한 길이의 세그먼트)
|
||||||
* - Event Hub 이벤트 발행 (AI 서비스로 전송)
|
* - Event Hub 이벤트 발행 (AI 서비스로 전송, 누적됨)
|
||||||
* - WebSocket 실시간 전송 (클라이언트 표시)
|
* - WebSocket 실시간 전송 (클라이언트에 즉시 표시)
|
||||||
|
*
|
||||||
|
* 7초 선택 이유:
|
||||||
|
* - 문장 완성도: 대부분의 발화가 완료되는 시간
|
||||||
|
* - 실시간성: 사용자가 즉각 피드백 확인 가능
|
||||||
|
* - Azure Speech 호환: recognizeOnceAsync() 최대 15초 이내
|
||||||
|
*
|
||||||
|
* AI 제안사항은 AI 서비스에서 별도로 생성 (약 1분치 누적 분석)
|
||||||
*/
|
*/
|
||||||
@Scheduled(fixedDelay = 15000, initialDelay = 15000) // 15초마다 실행, 최초 15초 후 시작
|
@Scheduled(fixedDelay = 7000, initialDelay = 7000) // 7초마다 실행
|
||||||
public void processAudioBatch() {
|
public void processAudioBatch() {
|
||||||
try {
|
try {
|
||||||
// 활성 회의 목록 조회
|
// 활성 회의 목록 조회
|
||||||
@ -68,7 +82,7 @@ public class AudioBatchProcessor {
|
|||||||
*/
|
*/
|
||||||
private void processOneMeeting(String meetingId) {
|
private void processOneMeeting(String meetingId) {
|
||||||
try {
|
try {
|
||||||
// Redis에서 최근 15초 오디오 청크 조회
|
// Redis에서 최근 7초 오디오 청크 조회
|
||||||
List<AudioChunkDto> chunks = audioBufferService.getAudioChunks(meetingId);
|
List<AudioChunkDto> chunks = audioBufferService.getAudioChunks(meetingId);
|
||||||
|
|
||||||
if (chunks.isEmpty()) {
|
if (chunks.isEmpty()) {
|
||||||
@ -78,7 +92,7 @@ public class AudioBatchProcessor {
|
|||||||
|
|
||||||
log.info("오디오 청크 조회 완료 - meetingId: {}, chunks: {}개", meetingId, chunks.size());
|
log.info("오디오 청크 조회 완료 - meetingId: {}, chunks: {}개", meetingId, chunks.size());
|
||||||
|
|
||||||
// 오디오 청크 병합 (15초 분량)
|
// 오디오 청크 병합 (7초 분량)
|
||||||
byte[] mergedAudio = audioBufferService.mergeAudioChunks(chunks);
|
byte[] mergedAudio = audioBufferService.mergeAudioChunks(chunks);
|
||||||
|
|
||||||
if (mergedAudio.length == 0) {
|
if (mergedAudio.length == 0) {
|
||||||
@ -102,7 +116,7 @@ public class AudioBatchProcessor {
|
|||||||
// WebSocket으로 실시간 결과 전송 (클라이언트 표시)
|
// WebSocket으로 실시간 결과 전송 (클라이언트 표시)
|
||||||
sendTranscriptToClients(meetingId, result);
|
sendTranscriptToClients(meetingId, result);
|
||||||
|
|
||||||
// Redis 정리
|
// 처리 완료된 청크 삭제 (중복 처리 방지)
|
||||||
audioBufferService.clearProcessedChunks(meetingId);
|
audioBufferService.clearProcessedChunks(meetingId);
|
||||||
|
|
||||||
log.info("회의 처리 완료 - meetingId: {}, text: {}", meetingId, result.getText());
|
log.info("회의 처리 완료 - meetingId: {}, text: {}", meetingId, result.getText());
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import com.microsoft.cognitiveservices.speech.*;
|
|||||||
import com.microsoft.cognitiveservices.speech.audio.AudioConfig;
|
import com.microsoft.cognitiveservices.speech.audio.AudioConfig;
|
||||||
import com.microsoft.cognitiveservices.speech.audio.AudioInputStream;
|
import com.microsoft.cognitiveservices.speech.audio.AudioInputStream;
|
||||||
import com.microsoft.cognitiveservices.speech.audio.PushAudioInputStream;
|
import com.microsoft.cognitiveservices.speech.audio.PushAudioInputStream;
|
||||||
|
import com.unicorn.hgzero.stt.util.AudioConverter;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -43,9 +44,15 @@ public class AzureSpeechService {
|
|||||||
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region);
|
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region);
|
||||||
speechConfig.setSpeechRecognitionLanguage(language);
|
speechConfig.setSpeechRecognitionLanguage(language);
|
||||||
|
|
||||||
// 연속 인식 설정 최적화
|
// 연속 인식 설정 최적화 - 회의록에 적합하게 조정
|
||||||
speechConfig.setProperty(PropertyId.SpeechServiceConnection_EndSilenceTimeoutMs, "3000");
|
speechConfig.setProperty(PropertyId.SpeechServiceConnection_EndSilenceTimeoutMs, "5000"); // 5초로 증가
|
||||||
speechConfig.setProperty(PropertyId.SpeechServiceConnection_InitialSilenceTimeoutMs, "10000");
|
speechConfig.setProperty(PropertyId.SpeechServiceConnection_InitialSilenceTimeoutMs, "15000"); // 15초로 증가
|
||||||
|
|
||||||
|
// 중간 결과 활성화 (연속 인식 시 유용)
|
||||||
|
speechConfig.setProperty(PropertyId.SpeechServiceResponse_RequestDetailedResultTrueFalse, "true");
|
||||||
|
|
||||||
|
// 음성 인식 품질 향상
|
||||||
|
speechConfig.setProperty(PropertyId.SpeechServiceConnection_RecoLanguage, language);
|
||||||
|
|
||||||
log.info("Azure Speech Service 초기화 완료 - Region: {}, Language: {}", region, language);
|
log.info("Azure Speech Service 초기화 완료 - Region: {}, Language: {}", region, language);
|
||||||
|
|
||||||
@ -58,7 +65,7 @@ public class AzureSpeechService {
|
|||||||
/**
|
/**
|
||||||
* 오디오 데이터를 텍스트로 변환 (배치 처리용)
|
* 오디오 데이터를 텍스트로 변환 (배치 처리용)
|
||||||
*
|
*
|
||||||
* @param audioData 병합된 오디오 데이터 (5초 분량)
|
* @param audioData 병합된 오디오 데이터 (15초 분량)
|
||||||
* @return 인식 결과
|
* @return 인식 결과
|
||||||
*/
|
*/
|
||||||
public RecognitionResult recognizeAudio(byte[] audioData) {
|
public RecognitionResult recognizeAudio(byte[] audioData) {
|
||||||
@ -67,10 +74,69 @@ public class AzureSpeechService {
|
|||||||
return createSimulationResult();
|
return createSimulationResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 오디오 데이터 품질 검증
|
||||||
|
if (!AudioConverter.isValidAudioData(audioData)) {
|
||||||
|
log.warn("유효하지 않은 오디오 데이터 - 인식 건너뜀");
|
||||||
|
return new RecognitionResult("", 0.0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오디오 통계 로깅 (디버깅용)
|
||||||
|
AudioConverter.AudioStats stats = AudioConverter.calculateStats(audioData);
|
||||||
|
log.debug("오디오 통계: {}", stats);
|
||||||
|
|
||||||
|
// 재시도 로직 (최대 3회)
|
||||||
|
int maxRetries = 3;
|
||||||
|
int retryDelay = 1000; // 1초
|
||||||
|
|
||||||
|
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
RecognitionResult result = recognizeAudioInternal(audioData);
|
||||||
|
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
log.info("음성 인식 성공 (시도 {}/{})", attempt, maxRetries);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoMatch 결과일 경우 재시도
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
log.warn("음성 인식 실패 (NoMatch) - 재시도 {}/{}", attempt, maxRetries);
|
||||||
|
Thread.sleep(retryDelay);
|
||||||
|
retryDelay *= 2; // 지수 백오프
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
log.error("재시도 중단됨", e);
|
||||||
|
break;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("음성 인식 실패 (시도 {}/{})", attempt, maxRetries, e);
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(retryDelay);
|
||||||
|
retryDelay *= 2;
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("음성 인식 최종 실패 - 최대 재시도 횟수 초과");
|
||||||
|
return new RecognitionResult("", 0.0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 음성 인식 수행 (내부 메서드)
|
||||||
|
*/
|
||||||
|
private RecognitionResult recognizeAudioInternal(byte[] audioData) throws Exception {
|
||||||
PushAudioInputStream pushStream = null;
|
PushAudioInputStream pushStream = null;
|
||||||
SpeechRecognizer recognizer = null;
|
SpeechRecognizer recognizer = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// WAV 형식으로 변환
|
||||||
|
byte[] wavData = AudioConverter.convertToWav(audioData);
|
||||||
|
|
||||||
// Push 오디오 스트림 생성
|
// Push 오디오 스트림 생성
|
||||||
pushStream = AudioInputStream.createPushStream();
|
pushStream = AudioInputStream.createPushStream();
|
||||||
AudioConfig audioConfig = AudioConfig.fromStreamInput(pushStream);
|
AudioConfig audioConfig = AudioConfig.fromStreamInput(pushStream);
|
||||||
@ -79,7 +145,7 @@ public class AzureSpeechService {
|
|||||||
recognizer = new SpeechRecognizer(speechConfig, audioConfig);
|
recognizer = new SpeechRecognizer(speechConfig, audioConfig);
|
||||||
|
|
||||||
// 오디오 데이터 전송
|
// 오디오 데이터 전송
|
||||||
pushStream.write(audioData);
|
pushStream.write(wavData);
|
||||||
pushStream.close();
|
pushStream.close();
|
||||||
|
|
||||||
// 인식 실행 (동기 방식)
|
// 인식 실행 (동기 방식)
|
||||||
@ -88,10 +154,6 @@ public class AzureSpeechService {
|
|||||||
// 결과 처리
|
// 결과 처리
|
||||||
return processRecognitionResult(result);
|
return processRecognitionResult(result);
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("음성 인식 실패", e);
|
|
||||||
return new RecognitionResult("", 0.0, false);
|
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
// 리소스 정리
|
// 리소스 정리
|
||||||
if (recognizer != null) {
|
if (recognizer != null) {
|
||||||
@ -106,19 +168,25 @@ public class AzureSpeechService {
|
|||||||
private RecognitionResult processRecognitionResult(SpeechRecognitionResult result) {
|
private RecognitionResult processRecognitionResult(SpeechRecognitionResult result) {
|
||||||
if (result.getReason() == ResultReason.RecognizedSpeech) {
|
if (result.getReason() == ResultReason.RecognizedSpeech) {
|
||||||
String text = result.getText();
|
String text = result.getText();
|
||||||
double confidence = calculateConfidence(text);
|
|
||||||
|
|
||||||
log.info("음성 인식 성공: {}, 신뢰도: {:.2f}", text, confidence);
|
// Azure Speech SDK의 실제 신뢰도 사용 (있는 경우)
|
||||||
|
// Note: Java SDK는 confidence를 직접 제공하지 않으므로 추정치 사용
|
||||||
|
double confidence = calculateConfidence(text, result);
|
||||||
|
|
||||||
|
log.info("음성 인식 성공: {}, 신뢰도: {}", text, confidence);
|
||||||
return new RecognitionResult(text, confidence, true);
|
return new RecognitionResult(text, confidence, true);
|
||||||
|
|
||||||
} else if (result.getReason() == ResultReason.NoMatch) {
|
} else if (result.getReason() == ResultReason.NoMatch) {
|
||||||
log.debug("음성 인식 실패 - NoMatch (무음 또는 인식 불가)");
|
NoMatchDetails noMatch = NoMatchDetails.fromResult(result);
|
||||||
|
log.debug("음성 인식 실패 - NoMatch, Reason: {}", noMatch.getReason());
|
||||||
return new RecognitionResult("", 0.0, false);
|
return new RecognitionResult("", 0.0, false);
|
||||||
|
|
||||||
} else if (result.getReason() == ResultReason.Canceled) {
|
} else if (result.getReason() == ResultReason.Canceled) {
|
||||||
CancellationDetails cancellation = CancellationDetails.fromResult(result);
|
CancellationDetails cancellation = CancellationDetails.fromResult(result);
|
||||||
log.error("음성 인식 취소 - Reason: {}, Details: {}",
|
log.error("음성 인식 취소 - Reason: {}, ErrorCode: {}, Details: {}",
|
||||||
cancellation.getReason(), cancellation.getErrorDetails());
|
cancellation.getReason(),
|
||||||
|
cancellation.getErrorCode(),
|
||||||
|
cancellation.getErrorDetails());
|
||||||
return new RecognitionResult("", 0.0, false);
|
return new RecognitionResult("", 0.0, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,20 +194,45 @@ public class AzureSpeechService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 신뢰도 계산 (추정)
|
* 신뢰도 계산 (개선된 추정 알고리즘)
|
||||||
* Azure Speech는 confidence를 직접 제공하지 않으므로 텍스트 길이 기반 추정
|
* Azure Speech Java SDK는 confidence를 직접 제공하지 않으므로 여러 지표 기반 추정
|
||||||
*/
|
*/
|
||||||
private double calculateConfidence(String text) {
|
private double calculateConfidence(String text, SpeechRecognitionResult result) {
|
||||||
if (text == null || text.trim().isEmpty()) {
|
if (text == null || text.trim().isEmpty()) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 텍스트 길이 기반 휴리스틱
|
double confidence = 0.7; // 기본값
|
||||||
|
|
||||||
|
// 1. 텍스트 길이 기반 (더 긴 텍스트 = 높은 신뢰도)
|
||||||
int length = text.length();
|
int length = text.length();
|
||||||
if (length > 50) return 0.95;
|
if (length > 50) {
|
||||||
if (length > 20) return 0.85;
|
confidence += 0.15;
|
||||||
if (length > 10) return 0.75;
|
} else if (length > 20) {
|
||||||
return 0.65;
|
confidence += 0.10;
|
||||||
|
} else if (length > 10) {
|
||||||
|
confidence += 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 단어 수 기반 (더 많은 단어 = 높은 신뢰도)
|
||||||
|
String[] words = text.trim().split("\\s+");
|
||||||
|
if (words.length > 10) {
|
||||||
|
confidence += 0.10;
|
||||||
|
} else if (words.length > 5) {
|
||||||
|
confidence += 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 특수문자 비율 (낮을수록 높은 신뢰도)
|
||||||
|
long specialCharCount = text.chars()
|
||||||
|
.filter(c -> !Character.isLetterOrDigit(c) && !Character.isWhitespace(c))
|
||||||
|
.count();
|
||||||
|
double specialCharRatio = (double) specialCharCount / length;
|
||||||
|
if (specialCharRatio < 0.05) {
|
||||||
|
confidence += 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최대값 제한
|
||||||
|
return Math.min(confidence, 0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,165 @@
|
|||||||
|
package com.unicorn.hgzero.stt.service;
|
||||||
|
|
||||||
|
import com.unicorn.hgzero.stt.dto.AudioChunkDto;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인메모리 오디오 버퍼 서비스 (Redis 대체)
|
||||||
|
*
|
||||||
|
* 7초 배치 처리를 위한 임시 저장소
|
||||||
|
* - 빠른 속도: 네트워크 없음, 메모리 직접 접근
|
||||||
|
* - 타임아웃 없음: Azure Redis 타임아웃 문제 해결
|
||||||
|
* - 간단함: Redis 설정 불필요
|
||||||
|
*
|
||||||
|
* Note: 서버 재시작 시 데이터 손실 가능 (7초 분량만 손실되므로 MVP에서는 허용)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class InMemoryAudioBufferService {
|
||||||
|
|
||||||
|
// 회의 ID별 오디오 청크 저장소
|
||||||
|
private final Map<String, List<AudioChunkDto>> audioChunksMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// 활성 회의 목록
|
||||||
|
private final Set<String> activeMeetings = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 청크 버퍼링 (인메모리 저장)
|
||||||
|
*/
|
||||||
|
public void bufferAudioChunk(AudioChunkDto chunk) {
|
||||||
|
try {
|
||||||
|
String meetingId = chunk.getMeetingId();
|
||||||
|
|
||||||
|
// 회의별 청크 리스트 가져오기 (없으면 생성)
|
||||||
|
List<AudioChunkDto> chunks = audioChunksMap.computeIfAbsent(
|
||||||
|
meetingId,
|
||||||
|
k -> Collections.synchronizedList(new ArrayList<>())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 청크 추가
|
||||||
|
chunks.add(chunk);
|
||||||
|
|
||||||
|
// 활성 회의 목록에 추가
|
||||||
|
activeMeetings.add(meetingId);
|
||||||
|
|
||||||
|
log.debug("오디오 청크 버퍼링 완료 (인메모리) - meetingId: {}, chunkIndex: {}, 총: {}개",
|
||||||
|
meetingId, chunk.getChunkIndex(), chunks.size());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("오디오 청크 버퍼링 실패 - meetingId: {}", chunk.getMeetingId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 회의 목록 조회
|
||||||
|
*/
|
||||||
|
public Set<String> getActiveMeetings() {
|
||||||
|
return new HashSet<>(activeMeetings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의의 모든 오디오 청크 조회 (배치 처리용)
|
||||||
|
*/
|
||||||
|
public List<AudioChunkDto> getAudioChunks(String meetingId) {
|
||||||
|
List<AudioChunkDto> chunks = audioChunksMap.get(meetingId);
|
||||||
|
|
||||||
|
if (chunks == null || chunks.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 복사본 반환 (thread-safe)
|
||||||
|
synchronized (chunks) {
|
||||||
|
return new ArrayList<>(chunks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 완료된 청크 삭제
|
||||||
|
*/
|
||||||
|
public void clearProcessedChunks(String meetingId) {
|
||||||
|
try {
|
||||||
|
List<AudioChunkDto> chunks = audioChunksMap.get(meetingId);
|
||||||
|
|
||||||
|
if (chunks != null) {
|
||||||
|
synchronized (chunks) {
|
||||||
|
int removedCount = chunks.size();
|
||||||
|
chunks.clear();
|
||||||
|
log.debug("오디오 청크 삭제 완료 - meetingId: {}, 삭제된 청크: {}개",
|
||||||
|
meetingId, removedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("오디오 청크 삭제 실패 - meetingId: {}", meetingId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 청크 병합 (7초 분량)
|
||||||
|
*/
|
||||||
|
public byte[] mergeAudioChunks(List<AudioChunkDto> chunks) {
|
||||||
|
if (chunks == null || chunks.isEmpty()) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 청크 인덱스 순서로 정렬
|
||||||
|
List<AudioChunkDto> sortedChunks = chunks.stream()
|
||||||
|
.sorted(Comparator.comparing(AudioChunkDto::getChunkIndex))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 전체 크기 계산
|
||||||
|
int totalSize = sortedChunks.stream()
|
||||||
|
.mapToInt(chunk -> chunk.getAudioData().length)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
// 병합
|
||||||
|
byte[] mergedAudio = new byte[totalSize];
|
||||||
|
int position = 0;
|
||||||
|
|
||||||
|
for (AudioChunkDto chunk : sortedChunks) {
|
||||||
|
byte[] chunkData = chunk.getAudioData();
|
||||||
|
System.arraycopy(chunkData, 0, mergedAudio, position, chunkData.length);
|
||||||
|
position += chunkData.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("오디오 청크 병합 완료 - 청크 수: {}, 총 크기: {} bytes",
|
||||||
|
sortedChunks.size(), totalSize);
|
||||||
|
|
||||||
|
return mergedAudio;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("오디오 청크 병합 실패", e);
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의 종료 시 데이터 정리
|
||||||
|
*/
|
||||||
|
public void cleanupMeeting(String meetingId) {
|
||||||
|
audioChunksMap.remove(meetingId);
|
||||||
|
activeMeetings.remove(meetingId);
|
||||||
|
log.info("회의 데이터 정리 완료 (인메모리) - meetingId: {}", meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 통계 조회 (모니터링용)
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getStatistics() {
|
||||||
|
int totalChunks = audioChunksMap.values().stream()
|
||||||
|
.mapToInt(List::size)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"activeMeetings", activeMeetings.size(),
|
||||||
|
"totalChunks", totalChunks,
|
||||||
|
"meetingsWithData", audioChunksMap.size()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
package com.unicorn.hgzero.stt.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import javax.sound.sampled.*;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 형식 변환 유틸리티
|
||||||
|
* WebM → WAV(PCM 16bit 16kHz) 변환
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class AudioConverter {
|
||||||
|
|
||||||
|
private static final int TARGET_SAMPLE_RATE = 16000;
|
||||||
|
private static final int TARGET_SAMPLE_SIZE_IN_BITS = 16;
|
||||||
|
private static final int TARGET_CHANNELS = 1; // Mono
|
||||||
|
private static final boolean TARGET_SIGNED = true;
|
||||||
|
private static final boolean TARGET_BIG_ENDIAN = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebM/기타 형식을 WAV(PCM 16bit 16kHz mono)로 변환
|
||||||
|
*
|
||||||
|
* @param audioData 원본 오디오 데이터
|
||||||
|
* @return WAV 형식 오디오 데이터
|
||||||
|
*/
|
||||||
|
public static byte[] convertToWav(byte[] audioData) {
|
||||||
|
if (audioData == null || audioData.length == 0) {
|
||||||
|
log.warn("변환할 오디오 데이터 없음");
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// WebM은 JavaSound API가 직접 지원하지 않으므로
|
||||||
|
// 이미 PCM 데이터라고 가정하고 WAV 헤더만 추가
|
||||||
|
// (실제 WebM 디코딩은 FFmpeg 필요)
|
||||||
|
|
||||||
|
// 최소 데이터 크기 검증 (1초 = 16000 samples * 2 bytes = 32KB)
|
||||||
|
if (audioData.length < 16000) {
|
||||||
|
log.warn("오디오 데이터가 너무 작음 ({}bytes) - 최소 16KB 필요", audioData.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAV 헤더 생성 및 데이터 결합
|
||||||
|
byte[] wavData = addWavHeader(audioData);
|
||||||
|
|
||||||
|
log.debug("오디오 형식 변환 완료 - 원본: {}bytes → WAV: {}bytes",
|
||||||
|
audioData.length, wavData.length);
|
||||||
|
|
||||||
|
return wavData;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("오디오 형식 변환 실패", e);
|
||||||
|
return audioData; // 실패 시 원본 반환
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PCM 데이터에 WAV 헤더 추가
|
||||||
|
* Format: PCM 16bit 16kHz Mono
|
||||||
|
*/
|
||||||
|
private static byte[] addWavHeader(byte[] pcmData) throws IOException {
|
||||||
|
ByteArrayOutputStream wavStream = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
// RIFF 헤더
|
||||||
|
wavStream.write("RIFF".getBytes());
|
||||||
|
wavStream.write(intToByteArray(36 + pcmData.length), 0, 4); // ChunkSize
|
||||||
|
wavStream.write("WAVE".getBytes());
|
||||||
|
|
||||||
|
// fmt 청크
|
||||||
|
wavStream.write("fmt ".getBytes());
|
||||||
|
wavStream.write(intToByteArray(16), 0, 4); // Subchunk1Size (PCM = 16)
|
||||||
|
wavStream.write(shortToByteArray((short) 1), 0, 2); // AudioFormat (1 = PCM)
|
||||||
|
wavStream.write(shortToByteArray((short) TARGET_CHANNELS), 0, 2); // NumChannels
|
||||||
|
wavStream.write(intToByteArray(TARGET_SAMPLE_RATE), 0, 4); // SampleRate
|
||||||
|
|
||||||
|
int byteRate = TARGET_SAMPLE_RATE * TARGET_CHANNELS * TARGET_SAMPLE_SIZE_IN_BITS / 8;
|
||||||
|
wavStream.write(intToByteArray(byteRate), 0, 4); // ByteRate
|
||||||
|
|
||||||
|
int blockAlign = TARGET_CHANNELS * TARGET_SAMPLE_SIZE_IN_BITS / 8;
|
||||||
|
wavStream.write(shortToByteArray((short) blockAlign), 0, 2); // BlockAlign
|
||||||
|
wavStream.write(shortToByteArray((short) TARGET_SAMPLE_SIZE_IN_BITS), 0, 2); // BitsPerSample
|
||||||
|
|
||||||
|
// data 청크
|
||||||
|
wavStream.write("data".getBytes());
|
||||||
|
wavStream.write(intToByteArray(pcmData.length), 0, 4); // Subchunk2Size
|
||||||
|
wavStream.write(pcmData);
|
||||||
|
|
||||||
|
return wavStream.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* int를 little-endian byte array로 변환
|
||||||
|
*/
|
||||||
|
private static byte[] intToByteArray(int value) {
|
||||||
|
return new byte[] {
|
||||||
|
(byte) (value & 0xff),
|
||||||
|
(byte) ((value >> 8) & 0xff),
|
||||||
|
(byte) ((value >> 16) & 0xff),
|
||||||
|
(byte) ((value >> 24) & 0xff)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* short를 little-endian byte array로 변환
|
||||||
|
*/
|
||||||
|
private static byte[] shortToByteArray(short value) {
|
||||||
|
return new byte[] {
|
||||||
|
(byte) (value & 0xff),
|
||||||
|
(byte) ((value >> 8) & 0xff)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 품질 검증
|
||||||
|
*
|
||||||
|
* @param audioData 검증할 오디오 데이터
|
||||||
|
* @return 최소 품질 기준 충족 여부
|
||||||
|
*/
|
||||||
|
public static boolean isValidAudioData(byte[] audioData) {
|
||||||
|
if (audioData == null || audioData.length == 0) {
|
||||||
|
log.warn("오디오 데이터 없음");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최소 크기: 0.5초 = 16000 samples/sec * 0.5 sec * 2 bytes = 16KB
|
||||||
|
int minSize = TARGET_SAMPLE_RATE * TARGET_SAMPLE_SIZE_IN_BITS / 8 / 2;
|
||||||
|
|
||||||
|
if (audioData.length < minSize) {
|
||||||
|
log.warn("오디오 데이터가 너무 작음 - size: {}bytes, 최소: {}bytes",
|
||||||
|
audioData.length, minSize);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 무음 여부 검증 (모든 샘플이 0이면 무음)
|
||||||
|
boolean allZero = true;
|
||||||
|
for (byte b : audioData) {
|
||||||
|
if (b != 0) {
|
||||||
|
allZero = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allZero) {
|
||||||
|
log.warn("무음 데이터 감지");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 데이터 통계 계산 (디버깅용)
|
||||||
|
*/
|
||||||
|
public static AudioStats calculateStats(byte[] audioData) {
|
||||||
|
if (audioData == null || audioData.length == 0) {
|
||||||
|
return new AudioStats(0, 0.0, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
double sum = 0;
|
||||||
|
double sumSquares = 0;
|
||||||
|
int max = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < audioData.length - 1; i += 2) {
|
||||||
|
// 16bit PCM 샘플 읽기 (little-endian)
|
||||||
|
short sample = (short) ((audioData[i + 1] << 8) | (audioData[i] & 0xff));
|
||||||
|
int absSample = Math.abs(sample);
|
||||||
|
|
||||||
|
sum += absSample;
|
||||||
|
sumSquares += absSample * absSample;
|
||||||
|
max = Math.max(max, absSample);
|
||||||
|
}
|
||||||
|
|
||||||
|
int numSamples = audioData.length / 2;
|
||||||
|
double avg = sum / numSamples;
|
||||||
|
double rms = Math.sqrt(sumSquares / numSamples);
|
||||||
|
|
||||||
|
return new AudioStats(numSamples, avg, rms, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 통계 정보
|
||||||
|
*/
|
||||||
|
public static class AudioStats {
|
||||||
|
public final int numSamples;
|
||||||
|
public final double avgAmplitude;
|
||||||
|
public final double rmsAmplitude;
|
||||||
|
public final double maxAmplitude;
|
||||||
|
|
||||||
|
public AudioStats(int numSamples, double avgAmplitude, double rmsAmplitude, double maxAmplitude) {
|
||||||
|
this.numSamples = numSamples;
|
||||||
|
this.avgAmplitude = avgAmplitude;
|
||||||
|
this.rmsAmplitude = rmsAmplitude;
|
||||||
|
this.maxAmplitude = maxAmplitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("AudioStats{samples=%d, avg=%.1f, rms=%.1f, max=%.1f}",
|
||||||
|
numSamples, avgAmplitude, rmsAmplitude, maxAmplitude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,13 +37,15 @@ spring:
|
|||||||
host: ${REDIS_HOST:localhost}
|
host: ${REDIS_HOST:localhost}
|
||||||
port: ${REDIS_PORT:6379}
|
port: ${REDIS_PORT:6379}
|
||||||
password: ${REDIS_PASSWORD:}
|
password: ${REDIS_PASSWORD:}
|
||||||
timeout: 2000ms
|
timeout: 30000ms # 30초로 증가 (Azure 원격 Redis 대응)
|
||||||
|
connect-timeout: 10000ms # 연결 타임아웃 명시
|
||||||
lettuce:
|
lettuce:
|
||||||
pool:
|
pool:
|
||||||
max-active: 8
|
max-active: 20 # 연결 풀 크기 증가 (8 → 20)
|
||||||
max-idle: 8
|
max-idle: 10
|
||||||
min-idle: 0
|
min-idle: 2 # 최소 유지 연결 (0 → 2)
|
||||||
max-wait: -1ms
|
max-wait: 5000ms # 연결 대기 최대 시간 (-1ms → 5000ms)
|
||||||
|
shutdown-timeout: 2000ms
|
||||||
database: ${REDIS_DATABASE:3}
|
database: ${REDIS_DATABASE:3}
|
||||||
|
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user