Merge feature/stt-ai into main

주요 변경사항:
- EventHub 공유 액세스 정책 재설정 (send-policy, listen-policy)
- Redis DB 2번 읽기 전용 문제 해결
- AI-Python 서비스 추가 (FastAPI 기반)
- STT WebSocket 실시간 스트리밍 구현
- AI 제안사항 실시간 추출 기능 구현
- 테스트 페이지 추가 (stt-test-wav.html)
- 개발 가이드 문서 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Minseo-Jo
2025-10-29 16:01:47 +09:00
81 changed files with 10269 additions and 276 deletions
@@ -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,130 @@
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 미설정 - InMemory 모드 사용 (MVP 개발용, 재시작 시 처음부터 읽음)");
builder.checkpointStore(new InMemoryCheckpointStore());
}
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;
}
+6
View File
@@ -77,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}
@@ -150,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}