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:
Minseo-Jo
2025-10-27 13:17:47 +09:00
parent 9bf3597cec
commit 14d03dcacf
31 changed files with 9531 additions and 1036 deletions
+19
View File
@@ -3,12 +3,28 @@ bootJar {
}
dependencies {
// Common module
implementation project(':common')
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// PostgreSQL
runtimeOnly 'org.postgresql:postgresql'
// OpenAI
implementation "com.theokanning.openai-gpt3-java:service:${openaiVersion}"
// Anthropic Claude SDK
implementation 'com.anthropic:anthropic-java:2.1.0'
// Azure AI Search
implementation "com.azure:azure-search-documents:${azureAiSearchVersion}"
// Azure Event Hubs
implementation "com.azure:azure-messaging-eventhubs:${azureEventHubsVersion}"
implementation "com.azure:azure-messaging-eventhubs-checkpointstore-blob:${azureEventHubsCheckpointVersion}"
// Feign (for external API calls)
implementation "io.github.openfeign:feign-jackson:${feignJacksonVersion}"
implementation "io.github.openfeign:feign-okhttp:${feignJacksonVersion}"
@@ -16,6 +32,9 @@ dependencies {
// Spring WebFlux for SSE streaming
implementation 'org.springframework.boot:spring-boot-starter-webflux'
// Springdoc OpenAPI
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
// H2 Database for local development
runtimeOnly 'com.h2database:h2'
}
+1703 -280
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
@@ -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;
}
+6
View File
@@ -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}