mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-13 09:29:10 +00:00
STT-AI 통합 작업 진행 중 변경사항 커밋
- AI 서비스 CORS 설정 업데이트 - 회의 진행 프로토타입 수정 - 빌드 리포트 및 로그 파일 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,16 +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.dto.common.DecisionSuggestionDto;
|
||||
import com.unicorn.hgzero.ai.infra.dto.common.DiscussionSuggestionDto;
|
||||
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
|
||||
@@ -24,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) {
|
||||
@@ -76,22 +91,152 @@ public class SuggestionService implements SuggestionUseCase {
|
||||
public Flux<RealtimeSuggestionsDto> streamRealtimeSuggestions(String meetingId) {
|
||||
log.info("실시간 AI 제안사항 스트리밍 시작 - meetingId: {}", meetingId);
|
||||
|
||||
// 실시간으로 AI 제안사항을 생성하는 스트림 (10초 간격)
|
||||
return Flux.interval(Duration.ofSeconds(10))
|
||||
.map(sequence -> generateRealtimeSuggestions(meetingId, sequence))
|
||||
.doOnNext(suggestions ->
|
||||
log.debug("AI 제안사항 생성 - meetingId: {}, 논의사항: {}, 결정사항: {}",
|
||||
meetingId,
|
||||
suggestions.getDiscussionTopics() != null ? suggestions.getDiscussionTopics().size() : 0,
|
||||
suggestions.getDecisions() != null ? suggestions.getDecisions().size() : 0))
|
||||
.doOnError(error ->
|
||||
log.error("AI 제안사항 스트리밍 오류 - meetingId: {}", meetingId, error))
|
||||
.doOnComplete(() ->
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 AI 제안사항 생성 (Mock)
|
||||
* 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
|
||||
@@ -100,63 +245,43 @@ public class SuggestionService implements SuggestionUseCase {
|
||||
*/
|
||||
private RealtimeSuggestionsDto generateRealtimeSuggestions(String meetingId, Long sequence) {
|
||||
// Mock 데이터 - 실제로는 LLM을 통해 STT 텍스트 분석 후 생성
|
||||
List<DiscussionSuggestionDto> discussionTopics = List.of(
|
||||
DiscussionSuggestionDto.builder()
|
||||
.id("disc-" + sequence)
|
||||
.topic(getMockDiscussionTopic(sequence))
|
||||
.reason("회의 안건에 포함되어 있으나 아직 논의되지 않음")
|
||||
.priority(sequence % 2 == 0 ? "HIGH" : "MEDIUM")
|
||||
.relatedAgenda("프로젝트 계획")
|
||||
.estimatedTime(15)
|
||||
.build()
|
||||
);
|
||||
|
||||
List<DecisionSuggestionDto> decisions = List.of(
|
||||
DecisionSuggestionDto.builder()
|
||||
.id("dec-" + sequence)
|
||||
.content(getMockDecisionContent(sequence))
|
||||
.category("기술")
|
||||
.decisionMaker("팀장")
|
||||
.participants(List.of("김철수", "이영희", "박민수"))
|
||||
List<SimpleSuggestionDto> suggestions = List.of(
|
||||
SimpleSuggestionDto.builder()
|
||||
.id("sugg-" + sequence)
|
||||
.content(getMockSuggestionContent(sequence))
|
||||
.timestamp(getCurrentTimestamp())
|
||||
.confidence(0.85 + (sequence % 15) * 0.01)
|
||||
.extractedFrom("회의 중 결정된 사항")
|
||||
.context("팀원들의 의견을 종합한 결과")
|
||||
.build()
|
||||
);
|
||||
|
||||
return RealtimeSuggestionsDto.builder()
|
||||
.discussionTopics(discussionTopics)
|
||||
.decisions(decisions)
|
||||
.suggestions(suggestions)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 논의사항 주제 생성
|
||||
* Mock 제안사항 내용 생성
|
||||
*/
|
||||
private String getMockDiscussionTopic(Long sequence) {
|
||||
String[] topics = {
|
||||
"보안 요구사항 검토",
|
||||
"데이터베이스 스키마 설계",
|
||||
"API 인터페이스 정의",
|
||||
"테스트 전략 수립",
|
||||
"배포 일정 조율",
|
||||
"성능 최적화 방안"
|
||||
private String getMockSuggestionContent(Long sequence) {
|
||||
String[] suggestions = {
|
||||
"신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.",
|
||||
"개발 일정: 1차 프로토타입은 11월 15일까지 완성, 2차 베타는 12월 1일 론칭",
|
||||
"마케팅 예산 배분에 대해 SNS 광고 60%, 인플루언서 마케팅 40%로 의견이 나왔으나 추가 검토 필요",
|
||||
"보안 요구사항 검토가 필요하며, 데이터 암호화 방식에 대한 논의가 진행 중입니다.",
|
||||
"React로 프론트엔드 개발하기로 결정되었으며, TypeScript 사용을 권장합니다.",
|
||||
"데이터베이스는 PostgreSQL을 메인으로 사용하고, Redis를 캐시로 활용하기로 했습니다."
|
||||
};
|
||||
return topics[(int) (sequence % topics.length)];
|
||||
return suggestions[(int) (sequence % suggestions.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 결정사항 내용 생성
|
||||
* 현재 타임스탬프 생성 (HH:MM:SS 형식)
|
||||
*/
|
||||
private String getMockDecisionContent(Long sequence) {
|
||||
String[] decisions = {
|
||||
"React로 프론트엔드 개발하기로 결정",
|
||||
"PostgreSQL을 메인 데이터베이스로 사용",
|
||||
"JWT 토큰 기반 인증 방식 채택",
|
||||
"Docker를 활용한 컨테이너화 진행",
|
||||
"주 1회 스프린트 회고 진행",
|
||||
"코드 리뷰 필수화"
|
||||
};
|
||||
return decisions[(int) (sequence % decisions.length)];
|
||||
private String getCurrentTimestamp() {
|
||||
java.time.LocalTime now = java.time.LocalTime.now();
|
||||
return String.format("%02d:%02d:%02d",
|
||||
now.getHour(),
|
||||
now.getMinute(),
|
||||
now.getSecond());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,132 @@
|
||||
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() {
|
||||
// Checkpoint Storage가 설정되지 않은 경우 Event Hub 기능 비활성화
|
||||
if (checkpointStorageConnectionString == null || checkpointStorageConnectionString.isEmpty()) {
|
||||
log.warn("Event Hub Processor 비활성화 - checkpoint storage 설정이 없습니다. " +
|
||||
"개발 환경에서는 Event Hub 없이 실행 가능하며, 운영 환경에서는 AZURE_CHECKPOINT_STORAGE_CONNECTION_STRING 환경 변수를 설정해야 합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Event Hub Processor 시작 - eventhub: {}, consumerGroup: {}",
|
||||
eventHubName, consumerGroup);
|
||||
|
||||
// Blob Checkpoint Store 생성 (체크포인트 저장소)
|
||||
BlobContainerAsyncClient blobContainerAsyncClient = new BlobContainerClientBuilder()
|
||||
.connectionString(checkpointStorageConnectionString)
|
||||
.containerName(checkpointContainer)
|
||||
.buildAsyncClient();
|
||||
|
||||
// Event Processor Client 빌드
|
||||
eventProcessorClient = new EventProcessorClientBuilder()
|
||||
.connectionString(connectionString, eventHubName)
|
||||
.consumerGroup(consumerGroup)
|
||||
.checkpointStore(new BlobCheckpointStore(blobContainerAsyncClient))
|
||||
.processEvent(this::processEvent)
|
||||
.processError(this::processError)
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ public class SecurityConfig {
|
||||
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
|
||||
// Health check
|
||||
.requestMatchers("/health").permitAll()
|
||||
// TODO: AI 개발 완료 후 제거 - 개발 중 테스트를 위한 SSE 엔드포인트 인증 해제
|
||||
.requestMatchers("/api/suggestions/meetings/*/stream").permitAll()
|
||||
// All other requests require authentication
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -73,6 +73,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 +149,6 @@ logging:
|
||||
max-file-size: ${LOG_MAX_FILE_SIZE:10MB}
|
||||
max-history: ${LOG_MAX_HISTORY:7}
|
||||
total-size-cap: ${LOG_TOTAL_SIZE_CAP:100MB}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user