mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 07:56:24 +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:
parent
9bf3597cec
commit
14d03dcacf
10
CLAUDE.md
10
CLAUDE.md
@ -562,4 +562,14 @@ Product Designer (UI/UX 전문가)
|
|||||||
- "@design-help": "설계실행프롬프트 내용을 터미널에 출력"
|
- "@design-help": "설계실행프롬프트 내용을 터미널에 출력"
|
||||||
- "@develop-help": "개발실행프롬프트 내용을 터미널에 출력"
|
- "@develop-help": "개발실행프롬프트 내용을 터미널에 출력"
|
||||||
- "@deploy-help": "배포실행프롬프트 내용을 터미널에 출력"
|
- "@deploy-help": "배포실행프롬프트 내용을 터미널에 출력"
|
||||||
|
|
||||||
|
### Spring Boot 설정 관리
|
||||||
|
- **설정 파일 구조**: `application.yml` + IntelliJ 실행 프로파일(`.run/*.run.xml`)로 관리
|
||||||
|
- **금지 사항**: `application-{profile}.yml` 같은 프로파일별 설정 파일 생성 금지
|
||||||
|
- **환경 변수 관리**: IntelliJ 실행 프로파일의 `<option name="env">` 섹션에서 관리
|
||||||
|
- **application.yml 작성**: 환경 변수 플레이스홀더 사용 (`${DB_HOST:default}` 형식)
|
||||||
|
- **실행 방법**:
|
||||||
|
- IntelliJ: 실행 프로파일 선택 후 실행 (환경 변수 자동 적용)
|
||||||
|
- 명령줄: 환경 변수 또는 `--args` 옵션으로 전달 (`--spring.profiles.active` 불필요)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -3,12 +3,28 @@ bootJar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Common module
|
||||||
|
implementation project(':common')
|
||||||
|
|
||||||
|
// Redis
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
|
|
||||||
|
// PostgreSQL
|
||||||
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
|
|
||||||
// OpenAI
|
// OpenAI
|
||||||
implementation "com.theokanning.openai-gpt3-java:service:${openaiVersion}"
|
implementation "com.theokanning.openai-gpt3-java:service:${openaiVersion}"
|
||||||
|
|
||||||
|
// Anthropic Claude SDK
|
||||||
|
implementation 'com.anthropic:anthropic-java:2.1.0'
|
||||||
|
|
||||||
// Azure AI Search
|
// Azure AI Search
|
||||||
implementation "com.azure:azure-search-documents:${azureAiSearchVersion}"
|
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)
|
// Feign (for external API calls)
|
||||||
implementation "io.github.openfeign:feign-jackson:${feignJacksonVersion}"
|
implementation "io.github.openfeign:feign-jackson:${feignJacksonVersion}"
|
||||||
implementation "io.github.openfeign:feign-okhttp:${feignJacksonVersion}"
|
implementation "io.github.openfeign:feign-okhttp:${feignJacksonVersion}"
|
||||||
@ -16,6 +32,9 @@ dependencies {
|
|||||||
// Spring WebFlux for SSE streaming
|
// Spring WebFlux for SSE streaming
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||||
|
|
||||||
|
// Springdoc OpenAPI
|
||||||
|
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
|
||||||
|
|
||||||
// H2 Database for local development
|
// H2 Database for local development
|
||||||
runtimeOnly 'com.h2database:h2'
|
runtimeOnly 'com.h2database:h2'
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
BIN
ai/logs/ai-service.log.2025-10-24.0.gz
Normal file
BIN
ai/logs/ai-service.log.2025-10-24.0.gz
Normal file
Binary file not shown.
@ -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.domain.Suggestion;
|
||||||
import com.unicorn.hgzero.ai.biz.gateway.LlmGateway;
|
import com.unicorn.hgzero.ai.biz.gateway.LlmGateway;
|
||||||
import com.unicorn.hgzero.ai.biz.usecase.SuggestionUseCase;
|
import com.unicorn.hgzero.ai.biz.usecase.SuggestionUseCase;
|
||||||
import com.unicorn.hgzero.ai.infra.dto.common.DecisionSuggestionDto;
|
import com.unicorn.hgzero.ai.infra.client.ClaudeApiClient;
|
||||||
import com.unicorn.hgzero.ai.infra.dto.common.DiscussionSuggestionDto;
|
import com.unicorn.hgzero.ai.infra.dto.common.SimpleSuggestionDto;
|
||||||
import com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto;
|
import com.unicorn.hgzero.ai.infra.dto.common.RealtimeSuggestionsDto;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Sinks;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 논의사항/결정사항 제안 Service
|
* 논의사항/결정사항 제안 Service
|
||||||
@ -24,6 +30,15 @@ import java.util.List;
|
|||||||
public class SuggestionService implements SuggestionUseCase {
|
public class SuggestionService implements SuggestionUseCase {
|
||||||
|
|
||||||
private final LlmGateway llmGateway;
|
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
|
@Override
|
||||||
public List<Suggestion> suggestDiscussions(String meetingId, String transcriptText) {
|
public List<Suggestion> suggestDiscussions(String meetingId, String transcriptText) {
|
||||||
@ -76,22 +91,152 @@ public class SuggestionService implements SuggestionUseCase {
|
|||||||
public Flux<RealtimeSuggestionsDto> streamRealtimeSuggestions(String meetingId) {
|
public Flux<RealtimeSuggestionsDto> streamRealtimeSuggestions(String meetingId) {
|
||||||
log.info("실시간 AI 제안사항 스트리밍 시작 - meetingId: {}", meetingId);
|
log.info("실시간 AI 제안사항 스트리밍 시작 - meetingId: {}", meetingId);
|
||||||
|
|
||||||
// 실시간으로 AI 제안사항을 생성하는 스트림 (10초 간격)
|
// Sink 생성 및 등록 (멀티캐스트 - 여러 클라이언트 동시 지원)
|
||||||
return Flux.interval(Duration.ofSeconds(10))
|
Sinks.Many<RealtimeSuggestionsDto> sink = Sinks.many()
|
||||||
.map(sequence -> generateRealtimeSuggestions(meetingId, sequence))
|
.multicast()
|
||||||
.doOnNext(suggestions ->
|
.onBackpressureBuffer();
|
||||||
log.debug("AI 제안사항 생성 - meetingId: {}, 논의사항: {}, 결정사항: {}",
|
|
||||||
meetingId,
|
meetingSinks.put(meetingId, sink);
|
||||||
suggestions.getDiscussionTopics() != null ? suggestions.getDiscussionTopics().size() : 0,
|
|
||||||
suggestions.getDecisions() != null ? suggestions.getDecisions().size() : 0))
|
// TODO: AI 개발 완료 후 제거 - 개발 중 프론트엔드 테스트를 위한 Mock 데이터 자동 발행
|
||||||
.doOnError(error ->
|
startMockDataEmission(meetingId, sink);
|
||||||
log.error("AI 제안사항 스트리밍 오류 - meetingId: {}", meetingId, error))
|
|
||||||
.doOnComplete(() ->
|
return sink.asFlux()
|
||||||
log.info("AI 제안사항 스트리밍 종료 - meetingId: {}", meetingId));
|
.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가 제안사항을 생성
|
* 실제로는 STT 텍스트를 분석하여 AI가 제안사항을 생성
|
||||||
*
|
*
|
||||||
* @param meetingId 회의 ID
|
* @param meetingId 회의 ID
|
||||||
@ -100,63 +245,43 @@ public class SuggestionService implements SuggestionUseCase {
|
|||||||
*/
|
*/
|
||||||
private RealtimeSuggestionsDto generateRealtimeSuggestions(String meetingId, Long sequence) {
|
private RealtimeSuggestionsDto generateRealtimeSuggestions(String meetingId, Long sequence) {
|
||||||
// Mock 데이터 - 실제로는 LLM을 통해 STT 텍스트 분석 후 생성
|
// Mock 데이터 - 실제로는 LLM을 통해 STT 텍스트 분석 후 생성
|
||||||
List<DiscussionSuggestionDto> discussionTopics = List.of(
|
List<SimpleSuggestionDto> suggestions = List.of(
|
||||||
DiscussionSuggestionDto.builder()
|
SimpleSuggestionDto.builder()
|
||||||
.id("disc-" + sequence)
|
.id("sugg-" + sequence)
|
||||||
.topic(getMockDiscussionTopic(sequence))
|
.content(getMockSuggestionContent(sequence))
|
||||||
.reason("회의 안건에 포함되어 있으나 아직 논의되지 않음")
|
.timestamp(getCurrentTimestamp())
|
||||||
.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("김철수", "이영희", "박민수"))
|
|
||||||
.confidence(0.85 + (sequence % 15) * 0.01)
|
.confidence(0.85 + (sequence % 15) * 0.01)
|
||||||
.extractedFrom("회의 중 결정된 사항")
|
|
||||||
.context("팀원들의 의견을 종합한 결과")
|
|
||||||
.build()
|
.build()
|
||||||
);
|
);
|
||||||
|
|
||||||
return RealtimeSuggestionsDto.builder()
|
return RealtimeSuggestionsDto.builder()
|
||||||
.discussionTopics(discussionTopics)
|
.suggestions(suggestions)
|
||||||
.decisions(decisions)
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock 논의사항 주제 생성
|
* Mock 제안사항 내용 생성
|
||||||
*/
|
*/
|
||||||
private String getMockDiscussionTopic(Long sequence) {
|
private String getMockSuggestionContent(Long sequence) {
|
||||||
String[] topics = {
|
String[] suggestions = {
|
||||||
"보안 요구사항 검토",
|
"신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.",
|
||||||
"데이터베이스 스키마 설계",
|
"개발 일정: 1차 프로토타입은 11월 15일까지 완성, 2차 베타는 12월 1일 론칭",
|
||||||
"API 인터페이스 정의",
|
"마케팅 예산 배분에 대해 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) {
|
private String getCurrentTimestamp() {
|
||||||
String[] decisions = {
|
java.time.LocalTime now = java.time.LocalTime.now();
|
||||||
"React로 프론트엔드 개발하기로 결정",
|
return String.format("%02d:%02d:%02d",
|
||||||
"PostgreSQL을 메인 데이터베이스로 사용",
|
now.getHour(),
|
||||||
"JWT 토큰 기반 인증 방식 채택",
|
now.getMinute(),
|
||||||
"Docker를 활용한 컨테이너화 진행",
|
now.getSecond());
|
||||||
"주 1회 스프린트 회고 진행",
|
|
||||||
"코드 리뷰 필수화"
|
|
||||||
};
|
|
||||||
return decisions[(int) (sequence % decisions.length)];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
|
||||||
// Health check
|
// Health check
|
||||||
.requestMatchers("/health").permitAll()
|
.requestMatchers("/health").permitAll()
|
||||||
|
// TODO: AI 개발 완료 후 제거 - 개발 중 테스트를 위한 SSE 엔드포인트 인증 해제
|
||||||
|
.requestMatchers("/api/suggestions/meetings/*/stream").permitAll()
|
||||||
// All other requests require authentication
|
// All other requests require authentication
|
||||||
.anyRequest().authenticated()
|
.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;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 실시간 추천사항 DTO
|
* 실시간 추천사항 DTO (간소화 버전)
|
||||||
* 논의 주제와 결정사항 제안을 포함
|
* 논의사항과 결정사항을 구분하지 않고 통합 제공
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
@Builder
|
@Builder
|
||||||
@ -18,12 +18,7 @@ import java.util.List;
|
|||||||
public class RealtimeSuggestionsDto {
|
public class RealtimeSuggestionsDto {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 논의 주제 제안 목록
|
* AI 제안사항 목록 (논의사항 + 결정사항 통합)
|
||||||
*/
|
*/
|
||||||
private List<DiscussionSuggestionDto> discussionTopics;
|
private List<SimpleSuggestionDto> suggestions;
|
||||||
|
|
||||||
/**
|
|
||||||
* 결정사항 제안 목록
|
|
||||||
*/
|
|
||||||
private List<DecisionSuggestionDto> decisions;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:
|
claude:
|
||||||
api-key: ${CLAUDE_API_KEY:}
|
api-key: ${CLAUDE_API_KEY:}
|
||||||
base-url: ${CLAUDE_BASE_URL:https://api.anthropic.com}
|
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:
|
openai:
|
||||||
api-key: ${OPENAI_API_KEY:}
|
api-key: ${OPENAI_API_KEY:}
|
||||||
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
|
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
|
||||||
@ -146,3 +149,6 @@ logging:
|
|||||||
max-file-size: ${LOG_MAX_FILE_SIZE:10MB}
|
max-file-size: ${LOG_MAX_FILE_SIZE:10MB}
|
||||||
max-history: ${LOG_MAX_HISTORY:7}
|
max-history: ${LOG_MAX_HISTORY:7}
|
||||||
total-size-cap: ${LOG_TOTAL_SIZE_CAP:100MB}
|
total-size-cap: ${LOG_TOTAL_SIZE_CAP:100MB}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -650,7 +650,7 @@ code + .copy-button {
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function configurationCacheProblems() { return (
|
function configurationCacheProblems() { return (
|
||||||
// begin-report-data
|
// begin-report-data
|
||||||
{"diagnostics":[{"locations":[{"taskPath":":stt:test"}],"problem":[{"text":"The automatic loading of test framework implementation dependencies has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 9.0."}],"contextualLabel":"The automatic loading of test framework implementation dependencies has been deprecated.","documentationLink":"https://docs.gradle.org/8.14/userguide/upgrading_version_8.html#test_framework_implementation_dependencies","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"the-automatic-loading-of-test-framework-implementation-dependencies","displayName":"The automatic loading of test framework implementation dependencies has been deprecated."}],"solutions":[[{"text":"Declare the desired test framework directly on the test suite or explicitly declare the test framework implementation dependencies on the test's runtime classpath."}]]}],"problemsReport":{"totalProblemCount":1,"buildName":"hgzero","requestedTasks":"build","documentationLink":"https://docs.gradle.org/8.14/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
|
{"diagnostics":[{"locations":[{"path":"/Users/jominseo/HGZero/common/src/main/java/com/unicorn/hgzero/common/security/JwtTokenProvider.java"},{"taskPath":":common:compileJava"}],"problem":[{"text":"/Users/jominseo/HGZero/common/src/main/java/com/unicorn/hgzero/common/security/JwtTokenProvider.java uses or overrides a deprecated API."}],"severity":"ADVICE","problemDetails":[{"text":"Note: /Users/jominseo/HGZero/common/src/main/java/com/unicorn/hgzero/common/security/JwtTokenProvider.java uses or overrides a deprecated API."}],"contextualLabel":"/Users/jominseo/HGZero/common/src/main/java/com/unicorn/hgzero/common/security/JwtTokenProvider.java uses or overrides a deprecated API.","problemId":[{"name":"java","displayName":"Java compilation"},{"name":"compilation","displayName":"Compilation"},{"name":"compiler.note.deprecated.filename","displayName":"/Users/jominseo/HGZero/common/src/main/java/com/unicorn/hgzero/common/security/JwtTokenProvider.java uses or overrides a deprecated API."}]},{"locations":[{"path":"/Users/jominseo/HGZero/common/src/main/java/com/unicorn/hgzero/common/security/JwtTokenProvider.java"},{"taskPath":":common:compileJava"}],"problem":[{"text":"Recompile with -Xlint:deprecation for details."}],"severity":"ADVICE","problemDetails":[{"text":"Note: Recompile with -Xlint:deprecation for details."}],"contextualLabel":"Recompile with -Xlint:deprecation for details.","problemId":[{"name":"java","displayName":"Java compilation"},{"name":"compilation","displayName":"Compilation"},{"name":"compiler.note.deprecated.recompile","displayName":"Recompile with -Xlint:deprecation for details."}]}],"problemsReport":{"totalProblemCount":2,"buildName":"hgzero","requestedTasks":"build","documentationLink":"https://docs.gradle.org/8.14/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
|
||||||
// end-report-data
|
// end-report-data
|
||||||
);}
|
);}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -712,7 +712,9 @@
|
|||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div id="aiSuggestionList">
|
<div id="aiSuggestionList">
|
||||||
<!-- AI 제안 1 -->
|
<!-- AI 제안사항이 실시간으로 추가됩니다 -->
|
||||||
|
|
||||||
|
<!-- 백업용 정적 샘플 데이터 (SSE 연결 실패 시 표시)
|
||||||
<div class="ai-suggestion-card" id="suggestion-1">
|
<div class="ai-suggestion-card" id="suggestion-1">
|
||||||
<div class="ai-suggestion-header">
|
<div class="ai-suggestion-header">
|
||||||
<span class="ai-suggestion-time">00:05:23</span>
|
<span class="ai-suggestion-time">00:05:23</span>
|
||||||
@ -725,7 +727,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI 제안 2 -->
|
|
||||||
<div class="ai-suggestion-card" id="suggestion-2">
|
<div class="ai-suggestion-card" id="suggestion-2">
|
||||||
<div class="ai-suggestion-header">
|
<div class="ai-suggestion-header">
|
||||||
<span class="ai-suggestion-time">00:08:45</span>
|
<span class="ai-suggestion-time">00:08:45</span>
|
||||||
@ -738,7 +739,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI 제안 3 -->
|
|
||||||
<div class="ai-suggestion-card" id="suggestion-3">
|
<div class="ai-suggestion-card" id="suggestion-3">
|
||||||
<div class="ai-suggestion-header">
|
<div class="ai-suggestion-header">
|
||||||
<span class="ai-suggestion-time">00:12:18</span>
|
<span class="ai-suggestion-time">00:12:18</span>
|
||||||
@ -750,6 +750,7 @@
|
|||||||
마케팅 예산 배분에 대해 SNS 광고 60%, 인플루언서 마케팅 40%로 의견이 나왔으나 추가 검토 필요
|
마케팅 예산 배분에 대해 SNS 광고 60%, 인플루언서 마케팅 40%로 의견이 나왔으나 추가 검토 필요
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1022,9 +1023,131 @@
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 페이지 로드 시 타이머 시작
|
// SSE로 실시간 AI 제안사항 수신
|
||||||
|
let eventSource = null;
|
||||||
|
const meetingId = '550e8400-e29b-41d4-a716-446655440000'; // 테스트용 회의 ID
|
||||||
|
|
||||||
|
function connectAiSuggestionStream() {
|
||||||
|
// EventSource를 사용하여 SSE 연결
|
||||||
|
const apiUrl = `http://localhost:8083/api/suggestions/meetings/${meetingId}/stream`;
|
||||||
|
|
||||||
|
console.log('[DEBUG] SSE 연결 시작:', apiUrl);
|
||||||
|
|
||||||
|
eventSource = new EventSource(apiUrl);
|
||||||
|
|
||||||
|
// 연결 성공
|
||||||
|
eventSource.onopen = function(event) {
|
||||||
|
console.log('[SUCCESS] SSE 연결 성공!', event);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모든 이벤트 수신 (디버깅용)
|
||||||
|
eventSource.onmessage = function(event) {
|
||||||
|
console.log('[DEBUG] 일반 메시지 수신:', event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ai-suggestion 이벤트 수신
|
||||||
|
eventSource.addEventListener('ai-suggestion', function(event) {
|
||||||
|
console.log('[SUCCESS] AI 제안사항 수신:', event.data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
const suggestions = data.suggestions;
|
||||||
|
|
||||||
|
console.log('[DEBUG] 파싱된 데이터:', data);
|
||||||
|
console.log('[DEBUG] 제안사항 개수:', suggestions ? suggestions.length : 0);
|
||||||
|
|
||||||
|
if (suggestions && suggestions.length > 0) {
|
||||||
|
suggestions.forEach(suggestion => {
|
||||||
|
console.log('[DEBUG] 제안사항 추가 중:', suggestion);
|
||||||
|
addAiSuggestionToUI(suggestion);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('[WARNING] 제안사항이 비어있음');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ERROR] AI 제안사항 파싱 오류:', error);
|
||||||
|
console.error('[ERROR] 원본 데이터:', event.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 에러 처리
|
||||||
|
eventSource.onerror = function(error) {
|
||||||
|
console.error('[ERROR] SSE 연결 오류:', error);
|
||||||
|
console.error('[ERROR] ReadyState:', eventSource.readyState);
|
||||||
|
|
||||||
|
// ReadyState: 0=CONNECTING, 1=OPEN, 2=CLOSED
|
||||||
|
if (eventSource.readyState === EventSource.CLOSED) {
|
||||||
|
console.error('[ERROR] SSE 연결이 닫혔습니다');
|
||||||
|
} else if (eventSource.readyState === EventSource.CONNECTING) {
|
||||||
|
console.warn('[WARNING] SSE 재연결 시도 중...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 발생 시 닫기
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[INFO] AI 제안사항 SSE 스트림 연결 요청 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 제안사항을 UI에 추가
|
||||||
|
function addAiSuggestionToUI(suggestion) {
|
||||||
|
const listContainer = document.getElementById('aiSuggestionList');
|
||||||
|
|
||||||
|
// 고유 ID 생성 (이미 추가된 제안인지 확인용)
|
||||||
|
const cardId = `suggestion-${suggestion.id}`;
|
||||||
|
|
||||||
|
// 이미 존재하는 제안이면 추가하지 않음
|
||||||
|
if (document.getElementById(cardId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 제안 카드 HTML 생성
|
||||||
|
const cardHtml = `
|
||||||
|
<div class="ai-suggestion-card" id="${cardId}">
|
||||||
|
<div class="ai-suggestion-header">
|
||||||
|
<span class="ai-suggestion-time">${suggestion.timestamp}</span>
|
||||||
|
<button class="ai-suggestion-add-btn"
|
||||||
|
onclick="addToMemo('${escapeHtml(suggestion.content)}', document.getElementById('${cardId}'))"
|
||||||
|
title="메모에 추가">
|
||||||
|
➕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-suggestion-text">
|
||||||
|
${escapeHtml(suggestion.content)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 리스트에 추가
|
||||||
|
listContainer.insertAdjacentHTML('beforeend', cardHtml);
|
||||||
|
|
||||||
|
console.log('AI 제안사항 추가됨:', suggestion.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML 이스케이프 (XSS 방지)
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, m => map[m]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 로드 시 타이머 시작 및 SSE 연결
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
updateTimer();
|
updateTimer();
|
||||||
|
connectAiSuggestionStream();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 페이지 종료 시 SSE 연결 해제
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
console.log('SSE 연결 종료');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
188
design/uiux/prototype/ai-suggestion-integration.js
vendored
Normal file
188
design/uiux/prototype/ai-suggestion-integration.js
vendored
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* AI 제안사항 SSE 연동 예시
|
||||||
|
* 05-회의진행.html에 추가할 JavaScript 코드
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 1. 전역 변수 선언
|
||||||
|
// ============================================
|
||||||
|
let eventSource = null;
|
||||||
|
const meetingId = "test-meeting-001"; // 실제로는 URL 파라미터에서 가져옴
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 2. SSE 연결 초기화
|
||||||
|
// ============================================
|
||||||
|
function initializeAiSuggestions() {
|
||||||
|
console.log('AI 제안사항 SSE 연결 시작 - meetingId:', meetingId);
|
||||||
|
|
||||||
|
// SSE 연결
|
||||||
|
eventSource = new EventSource(
|
||||||
|
`http://localhost:8083/api/suggestions/meetings/${meetingId}/stream`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 연결 성공
|
||||||
|
eventSource.onopen = function() {
|
||||||
|
console.log('SSE 연결 성공');
|
||||||
|
};
|
||||||
|
|
||||||
|
// AI 제안사항 수신
|
||||||
|
eventSource.addEventListener('ai-suggestion', function(event) {
|
||||||
|
console.log('AI 제안사항 수신:', event.data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleAiSuggestions(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('JSON 파싱 오류:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연결 오류
|
||||||
|
eventSource.onerror = function(error) {
|
||||||
|
console.error('SSE 연결 오류:', error);
|
||||||
|
|
||||||
|
// 자동 재연결은 브라우저가 처리
|
||||||
|
// 필요시 수동 재연결 로직 추가 가능
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 3. AI 제안사항 처리
|
||||||
|
// ============================================
|
||||||
|
function handleAiSuggestions(data) {
|
||||||
|
console.log('AI 제안사항 처리:', data);
|
||||||
|
|
||||||
|
// data 형식:
|
||||||
|
// {
|
||||||
|
// "suggestions": [
|
||||||
|
// {
|
||||||
|
// "id": "sugg-001",
|
||||||
|
// "content": "신제품의 타겟 고객층을...",
|
||||||
|
// "timestamp": "00:05:23",
|
||||||
|
// "confidence": 0.92
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (data.suggestions && data.suggestions.length > 0) {
|
||||||
|
data.suggestions.forEach(suggestion => {
|
||||||
|
addSuggestionCard(suggestion);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 4. 제안사항 카드 추가
|
||||||
|
// ============================================
|
||||||
|
function addSuggestionCard(suggestion) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'ai-suggestion-card';
|
||||||
|
card.id = 'suggestion-' + suggestion.id;
|
||||||
|
|
||||||
|
// 타임스탬프 (있으면 사용, 없으면 현재 시간)
|
||||||
|
const timestamp = suggestion.timestamp || getCurrentRecordingTime();
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="ai-suggestion-header">
|
||||||
|
<span class="ai-suggestion-time">${timestamp}</span>
|
||||||
|
<button class="ai-suggestion-add-btn"
|
||||||
|
onclick="addToMemo('${escapeHtml(suggestion.content)}', document.getElementById('suggestion-${suggestion.id}'))"
|
||||||
|
title="메모에 추가">
|
||||||
|
➕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-suggestion-text">
|
||||||
|
${escapeHtml(suggestion.content)}
|
||||||
|
</div>
|
||||||
|
${suggestion.confidence ? `
|
||||||
|
<div class="ai-suggestion-confidence">
|
||||||
|
<span style="font-size: 11px; color: var(--gray-500);">
|
||||||
|
신뢰도: ${Math.round(suggestion.confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// aiSuggestionList의 맨 위에 추가 (최신 항목이 위로)
|
||||||
|
const listElement = document.getElementById('aiSuggestionList');
|
||||||
|
if (listElement) {
|
||||||
|
listElement.insertBefore(card, listElement.firstChild);
|
||||||
|
|
||||||
|
// 부드러운 등장 애니메이션
|
||||||
|
setTimeout(() => {
|
||||||
|
card.style.opacity = '0';
|
||||||
|
card.style.transform = 'translateY(-10px)';
|
||||||
|
card.style.transition = 'all 0.3s ease';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
card.style.opacity = '1';
|
||||||
|
card.style.transform = 'translateY(0)';
|
||||||
|
}, 10);
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
console.error('aiSuggestionList 엘리먼트를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 5. 유틸리티 함수
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 녹음 시간 가져오기 (HH:MM 형식)
|
||||||
|
*/
|
||||||
|
function getCurrentRecordingTime() {
|
||||||
|
const timerElement = document.getElementById('recordingTime');
|
||||||
|
if (timerElement) {
|
||||||
|
const time = timerElement.textContent;
|
||||||
|
return time.substring(0, 5); // "00:05:23" -> "00:05"
|
||||||
|
}
|
||||||
|
return "00:00";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 이스케이프 (XSS 방지)
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE 연결 종료
|
||||||
|
*/
|
||||||
|
function closeAiSuggestions() {
|
||||||
|
if (eventSource) {
|
||||||
|
console.log('SSE 연결 종료');
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 6. 페이지 로드 시 자동 시작
|
||||||
|
// ============================================
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
console.log('페이지 로드 완료 - AI 제안사항 초기화');
|
||||||
|
|
||||||
|
// SSE 연결 시작
|
||||||
|
initializeAiSuggestions();
|
||||||
|
|
||||||
|
// 페이지 닫을 때 SSE 연결 종료
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
closeAiSuggestions();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 7. 회의 종료 시 SSE 연결 종료
|
||||||
|
// ============================================
|
||||||
|
// 기존 endMeeting 함수 수정
|
||||||
|
const originalEndMeeting = window.endMeeting;
|
||||||
|
window.endMeeting = function() {
|
||||||
|
closeAiSuggestions(); // SSE 연결 종료
|
||||||
|
if (originalEndMeeting) {
|
||||||
|
originalEndMeeting(); // 기존 로직 실행
|
||||||
|
}
|
||||||
|
};
|
||||||
832
develop/dev/dev-ai-guide.md
Normal file
832
develop/dev/dev-ai-guide.md
Normal file
@ -0,0 +1,832 @@
|
|||||||
|
# AI 서비스 개발 가이드
|
||||||
|
|
||||||
|
## 📋 **목차**
|
||||||
|
1. [AI 제안 기능 개발](#1-ai-제안-기능)
|
||||||
|
2. [용어 사전 기능 개발](#2-용어-사전-기능)
|
||||||
|
3. [관련 회의록 추천 기능 개발](#3-관련-회의록-추천-기능)
|
||||||
|
4. [백엔드 API 검증](#4-백엔드-api-검증)
|
||||||
|
5. [프롬프트 엔지니어링 가이드](#5-프롬프트-엔지니어링)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. AI 제안 기능
|
||||||
|
|
||||||
|
### 📌 **기능 개요**
|
||||||
|
- **목적**: STT로 5초마다 수신되는 회의 텍스트를 분석하여 실시간 제안사항 제공
|
||||||
|
- **입력**: Redis에 축적된 최근 5분간의 회의 텍스트
|
||||||
|
- **출력**:
|
||||||
|
- 논의사항 (Discussions): 회의와 관련있고 추가 논의가 필요한 주제
|
||||||
|
- 결정사항 (Decisions): 명확한 의사결정 패턴이 감지된 내용
|
||||||
|
|
||||||
|
### ✅ **구현 완료 사항**
|
||||||
|
|
||||||
|
1. **SSE 스트리밍 엔드포인트**
|
||||||
|
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/infra/controller/SuggestionController.java:111-131`
|
||||||
|
- 엔드포인트: `GET /api/suggestions/meetings/{meetingId}/stream`
|
||||||
|
- 프로토콜: Server-Sent Events (SSE)
|
||||||
|
|
||||||
|
2. **실시간 텍스트 처리**
|
||||||
|
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/biz/service/SuggestionService.java:120-140`
|
||||||
|
- Event Hub에서 TranscriptSegmentReady 이벤트 수신
|
||||||
|
- Redis에 최근 5분 텍스트 슬라이딩 윈도우 저장
|
||||||
|
|
||||||
|
3. **Claude API 통합**
|
||||||
|
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/infra/client/ClaudeApiClient.java`
|
||||||
|
- 모델: claude-3-5-sonnet-20241022
|
||||||
|
- 비동기 분석 및 JSON 파싱 구현
|
||||||
|
|
||||||
|
### 🔧 **개선 필요 사항**
|
||||||
|
|
||||||
|
#### 1.1 프롬프트 엔지니어링 개선
|
||||||
|
|
||||||
|
**현재 문제점**:
|
||||||
|
- 회의와 관련 없는 일상 대화도 분석될 가능성
|
||||||
|
- 회의 목적/안건 정보가 활용되지 않음
|
||||||
|
|
||||||
|
**개선 방법**:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// ClaudeApiClient.java의 analyzeSuggestions 메서드 개선
|
||||||
|
public Mono<RealtimeSuggestionsDto> analyzeSuggestions(
|
||||||
|
String transcriptText,
|
||||||
|
String meetingPurpose, // 추가
|
||||||
|
List<String> agendaItems // 추가
|
||||||
|
) {
|
||||||
|
String systemPrompt = """
|
||||||
|
당신은 비즈니스 회의록 작성 전문 AI 어시스턴트입니다.
|
||||||
|
|
||||||
|
**역할**: 실시간 회의 텍스트를 분석하여 업무 관련 핵심 내용만 추출
|
||||||
|
|
||||||
|
**분석 기준**:
|
||||||
|
1. 논의사항 (discussions):
|
||||||
|
- 회의 안건과 관련된 미결 주제
|
||||||
|
- 추가 검토가 필요한 업무 항목
|
||||||
|
- "~에 대해 논의 필요", "~검토해야 함" 등의 패턴
|
||||||
|
- **제외**: 잡담, 농담, 인사말, 회의와 무관한 대화
|
||||||
|
|
||||||
|
2. 결정사항 (decisions):
|
||||||
|
- 명확한 의사결정 표현 ("~하기로 함", "~로 결정", "~로 합의")
|
||||||
|
- 구체적인 Action Item
|
||||||
|
- 책임자와 일정이 언급된 항목
|
||||||
|
|
||||||
|
**필터링 규칙**:
|
||||||
|
- 회의 목적/안건과 무관한 내용 제외
|
||||||
|
- 단순 질의응답이나 확인 대화 제외
|
||||||
|
- 업무 맥락이 명확한 내용만 추출
|
||||||
|
|
||||||
|
**응답 형식**: 반드시 JSON만 반환
|
||||||
|
{
|
||||||
|
"discussions": [
|
||||||
|
{
|
||||||
|
"topic": "구체적인 논의 주제 (회의 안건과 직접 연관)",
|
||||||
|
"reason": "회의 안건과의 연관성 설명",
|
||||||
|
"priority": "HIGH|MEDIUM|LOW",
|
||||||
|
"relatedAgenda": "관련 안건"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decisions": [
|
||||||
|
{
|
||||||
|
"content": "결정된 내용",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"extractedFrom": "원문 인용",
|
||||||
|
"actionOwner": "담당자 (언급된 경우)",
|
||||||
|
"deadline": "일정 (언급된 경우)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
String userPrompt = String.format("""
|
||||||
|
**회의 정보**:
|
||||||
|
- 목적: %s
|
||||||
|
- 안건: %s
|
||||||
|
|
||||||
|
**회의 내용 (최근 5분)**:
|
||||||
|
%s
|
||||||
|
|
||||||
|
위 회의 목적과 안건에 맞춰 **회의와 관련된 내용만** 분석해주세요.
|
||||||
|
""",
|
||||||
|
meetingPurpose,
|
||||||
|
String.join(", ", agendaItems),
|
||||||
|
transcriptText
|
||||||
|
);
|
||||||
|
|
||||||
|
// 나머지 코드 동일
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 회의 컨텍스트 조회 기능 추가
|
||||||
|
|
||||||
|
**구현 위치**: `SuggestionService.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SuggestionService implements SuggestionUseCase {
|
||||||
|
|
||||||
|
private final MeetingGateway meetingGateway; // 추가 필요
|
||||||
|
|
||||||
|
private void analyzeAndEmitSuggestions(String meetingId) {
|
||||||
|
// 1. 회의 정보 조회
|
||||||
|
MeetingInfo meetingInfo = meetingGateway.getMeetingInfo(meetingId);
|
||||||
|
String meetingPurpose = meetingInfo.getPurpose();
|
||||||
|
List<String> agendaItems = meetingInfo.getAgendaItems();
|
||||||
|
|
||||||
|
// 2. Redis에서 최근 5분 텍스트 조회
|
||||||
|
String key = "meeting:" + meetingId + ":transcript";
|
||||||
|
Set<String> recentTexts = redisTemplate.opsForZSet().reverseRange(key, 0, -1);
|
||||||
|
|
||||||
|
if (recentTexts == null || recentTexts.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String accumulatedText = recentTexts.stream()
|
||||||
|
.map(entry -> entry.split(":", 2)[1])
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
|
||||||
|
// 3. Claude API 분석 (회의 컨텍스트 포함)
|
||||||
|
claudeApiClient.analyzeSuggestions(
|
||||||
|
accumulatedText,
|
||||||
|
meetingPurpose, // 추가
|
||||||
|
agendaItems // 추가
|
||||||
|
)
|
||||||
|
.subscribe(
|
||||||
|
suggestions -> {
|
||||||
|
Sinks.Many<RealtimeSuggestionsDto> sink = meetingSinks.get(meetingId);
|
||||||
|
if (sink != null) {
|
||||||
|
sink.tryEmitNext(suggestions);
|
||||||
|
log.info("AI 제안사항 발행 완료 - meetingId: {}, 논의사항: {}, 결정사항: {}",
|
||||||
|
meetingId,
|
||||||
|
suggestions.getDiscussionTopics().size(),
|
||||||
|
suggestions.getDecisions().size());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error -> log.error("Claude API 분석 실패 - meetingId: {}", meetingId, error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**필요한 Gateway 인터페이스**:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.unicorn.hgzero.ai.biz.gateway;
|
||||||
|
|
||||||
|
public interface MeetingGateway {
|
||||||
|
MeetingInfo getMeetingInfo(String meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class MeetingInfo {
|
||||||
|
private String meetingId;
|
||||||
|
private String purpose;
|
||||||
|
private List<String> agendaItems;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 용어 사전 기능
|
||||||
|
|
||||||
|
### 📌 **기능 개요**
|
||||||
|
- **목적**: 회의 중 언급된 전문 용어를 맥락에 맞게 설명
|
||||||
|
- **입력**: 용어, 회의 컨텍스트
|
||||||
|
- **출력**:
|
||||||
|
- 기본 정의
|
||||||
|
- 회의 맥락에 맞는 설명
|
||||||
|
- 이전 회의에서의 사용 예시 (있는 경우)
|
||||||
|
|
||||||
|
### ✅ **구현 완료 사항**
|
||||||
|
|
||||||
|
1. **용어 감지 API**
|
||||||
|
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/infra/controller/TermController.java:35-82`
|
||||||
|
- 엔드포인트: `POST /api/terms/detect`
|
||||||
|
|
||||||
|
2. **용어 설명 서비스 (Mock)**
|
||||||
|
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/biz/service/TermExplanationService.java:24-53`
|
||||||
|
|
||||||
|
### 🔧 **개선 필요 사항**
|
||||||
|
|
||||||
|
#### 2.1 RAG 기반 용어 설명 구현
|
||||||
|
|
||||||
|
**구현 방법**:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TermExplanationService implements TermExplanationUseCase {
|
||||||
|
|
||||||
|
private final SearchGateway searchGateway;
|
||||||
|
private final ClaudeApiClient claudeApiClient; // 추가
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TermExplanationResult explainTerm(
|
||||||
|
String term,
|
||||||
|
String meetingId,
|
||||||
|
String context
|
||||||
|
) {
|
||||||
|
log.info("용어 설명 생성 - term: {}, meetingId: {}", term, meetingId);
|
||||||
|
|
||||||
|
// 1. RAG 검색: 이전 회의록에서 해당 용어 사용 사례 검색
|
||||||
|
List<TermUsageExample> pastUsages = searchGateway.searchTermUsages(
|
||||||
|
term,
|
||||||
|
meetingId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Claude API로 맥락 기반 설명 생성
|
||||||
|
String explanation = generateContextualExplanation(
|
||||||
|
term,
|
||||||
|
context,
|
||||||
|
pastUsages
|
||||||
|
);
|
||||||
|
|
||||||
|
return TermExplanationResult.builder()
|
||||||
|
.term(term)
|
||||||
|
.definition(explanation)
|
||||||
|
.context(context)
|
||||||
|
.pastUsages(pastUsages.stream()
|
||||||
|
.map(TermUsageExample::getDescription)
|
||||||
|
.collect(Collectors.toList()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateContextualExplanation(
|
||||||
|
String term,
|
||||||
|
String context,
|
||||||
|
List<TermUsageExample> pastUsages
|
||||||
|
) {
|
||||||
|
String prompt = String.format("""
|
||||||
|
다음 용어를 회의 맥락에 맞게 설명해주세요:
|
||||||
|
|
||||||
|
**용어**: %s
|
||||||
|
**현재 회의 맥락**: %s
|
||||||
|
|
||||||
|
**이전 회의에서의 사용 사례**:
|
||||||
|
%s
|
||||||
|
|
||||||
|
**설명 형식**:
|
||||||
|
1. 기본 정의 (1-2문장)
|
||||||
|
2. 현재 회의 맥락에서의 의미 (1-2문장)
|
||||||
|
3. 이전 논의 참고사항 (있는 경우)
|
||||||
|
|
||||||
|
비즈니스 맥락에 맞는 실용적인 설명을 제공하세요.
|
||||||
|
""",
|
||||||
|
term,
|
||||||
|
context,
|
||||||
|
formatPastUsages(pastUsages)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Claude API 호출 (동기 방식)
|
||||||
|
return claudeApiClient.generateExplanation(prompt)
|
||||||
|
.block(); // 또는 비동기 처리
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatPastUsages(List<TermUsageExample> pastUsages) {
|
||||||
|
if (pastUsages.isEmpty()) {
|
||||||
|
return "이전 회의에서 언급된 적 없음";
|
||||||
|
}
|
||||||
|
|
||||||
|
return pastUsages.stream()
|
||||||
|
.map(usage -> String.format(
|
||||||
|
"- [%s] %s: %s",
|
||||||
|
usage.getMeetingDate(),
|
||||||
|
usage.getMeetingTitle(),
|
||||||
|
usage.getDescription()
|
||||||
|
))
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Azure AI Search 통합 (RAG)
|
||||||
|
|
||||||
|
**SearchGateway 구현**:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.unicorn.hgzero.ai.infra.search;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AzureAiSearchGateway implements SearchGateway {
|
||||||
|
|
||||||
|
@Value("${external.ai-search.endpoint}")
|
||||||
|
private String endpoint;
|
||||||
|
|
||||||
|
@Value("${external.ai-search.api-key}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Value("${external.ai-search.index-name}")
|
||||||
|
private String indexName;
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TermUsageExample> searchTermUsages(String term, String meetingId) {
|
||||||
|
// 1. 벡터 검색 쿼리 생성
|
||||||
|
Map<String, Object> searchRequest = Map.of(
|
||||||
|
"search", term,
|
||||||
|
"filter", String.format("meetingId ne '%s'", meetingId), // 현재 회의 제외
|
||||||
|
"top", 5,
|
||||||
|
"select", "meetingId,meetingTitle,meetingDate,content,score"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Azure AI Search API 호출
|
||||||
|
String response = webClient.post()
|
||||||
|
.uri(endpoint + "/indexes/" + indexName + "/docs/search?api-version=2023-11-01")
|
||||||
|
.header("api-key", apiKey)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.bodyValue(searchRequest)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block();
|
||||||
|
|
||||||
|
// 3. 응답 파싱
|
||||||
|
return parseSearchResults(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TermUsageExample> parseSearchResults(String response) {
|
||||||
|
// JSON 파싱 로직
|
||||||
|
// Azure AI Search 응답 형식에 맞춰 파싱
|
||||||
|
return List.of(); // 구현 필요
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 관련 회의록 추천 기능
|
||||||
|
|
||||||
|
### 📌 **기능 개요**
|
||||||
|
- **목적**: 현재 회의와 관련된 과거 회의록 추천
|
||||||
|
- **입력**: 회의 목적, 안건, 진행 중인 회의 내용
|
||||||
|
- **출력**: 관련도 점수와 함께 관련 회의록 목록
|
||||||
|
|
||||||
|
### ✅ **구현 완료 사항**
|
||||||
|
|
||||||
|
1. **관련 회의록 조회 API**
|
||||||
|
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/infra/controller/RelationController.java:31-63`
|
||||||
|
- 엔드포인트: `GET /api/transcripts/{meetingId}/related`
|
||||||
|
|
||||||
|
2. **벡터 검색 서비스 (Mock)**
|
||||||
|
- 파일: `ai/src/main/java/com/unicorn/hgzero/ai/biz/service/RelatedTranscriptSearchService.java:27-47`
|
||||||
|
|
||||||
|
### 🔧 **개선 필요 사항**
|
||||||
|
|
||||||
|
#### 3.1 Azure AI Search 벡터 검색 구현
|
||||||
|
|
||||||
|
**구현 방법**:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RelatedTranscriptSearchService implements RelatedTranscriptSearchUseCase {
|
||||||
|
|
||||||
|
private final SearchGateway searchGateway;
|
||||||
|
private final MeetingGateway meetingGateway;
|
||||||
|
private final ClaudeApiClient claudeApiClient;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<RelatedMinutes> findRelatedTranscripts(
|
||||||
|
String meetingId,
|
||||||
|
String transcriptId,
|
||||||
|
int limit
|
||||||
|
) {
|
||||||
|
log.info("관련 회의록 검색 - meetingId: {}, limit: {}", meetingId, limit);
|
||||||
|
|
||||||
|
// 1. 현재 회의 정보 조회
|
||||||
|
MeetingInfo currentMeeting = meetingGateway.getMeetingInfo(meetingId);
|
||||||
|
String searchQuery = buildSearchQuery(currentMeeting);
|
||||||
|
|
||||||
|
// 2. Azure AI Search로 벡터 유사도 검색
|
||||||
|
List<SearchResult> searchResults = searchGateway.searchRelatedMeetings(
|
||||||
|
searchQuery,
|
||||||
|
meetingId,
|
||||||
|
limit
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. 검색 결과를 RelatedMinutes로 변환
|
||||||
|
return searchResults.stream()
|
||||||
|
.map(this::toRelatedMinutes)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSearchQuery(MeetingInfo meeting) {
|
||||||
|
// 회의 목적 + 안건을 검색 쿼리로 변환
|
||||||
|
return String.format("%s %s",
|
||||||
|
meeting.getPurpose(),
|
||||||
|
String.join(" ", meeting.getAgendaItems())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RelatedMinutes toRelatedMinutes(SearchResult result) {
|
||||||
|
return RelatedMinutes.builder()
|
||||||
|
.transcriptId(result.getMeetingId())
|
||||||
|
.title(result.getTitle())
|
||||||
|
.date(result.getDate())
|
||||||
|
.participants(result.getParticipants())
|
||||||
|
.relevanceScore(result.getScore() * 100) // 0-1 -> 0-100
|
||||||
|
.commonKeywords(extractCommonKeywords(result))
|
||||||
|
.summary(result.getSummary())
|
||||||
|
.link("/transcripts/" + result.getMeetingId())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 임베딩 기반 검색 구현
|
||||||
|
|
||||||
|
**Azure OpenAI Embedding 활용**:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class EmbeddingService {
|
||||||
|
|
||||||
|
@Value("${azure.openai.endpoint}")
|
||||||
|
private String endpoint;
|
||||||
|
|
||||||
|
@Value("${azure.openai.api-key}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Value("${azure.openai.embedding-deployment}")
|
||||||
|
private String embeddingDeployment;
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트를 벡터로 변환
|
||||||
|
*/
|
||||||
|
public float[] generateEmbedding(String text) {
|
||||||
|
Map<String, Object> request = Map.of(
|
||||||
|
"input", text
|
||||||
|
);
|
||||||
|
|
||||||
|
String response = webClient.post()
|
||||||
|
.uri(endpoint + "/openai/deployments/" + embeddingDeployment + "/embeddings?api-version=2024-02-15-preview")
|
||||||
|
.header("api-key", apiKey)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.bodyValue(request)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block();
|
||||||
|
|
||||||
|
// 응답에서 embedding 벡터 추출
|
||||||
|
return parseEmbedding(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float[] parseEmbedding(String response) {
|
||||||
|
// JSON 파싱하여 float[] 반환
|
||||||
|
return new float[0]; // 구현 필요
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Azure AI Search 벡터 검색**:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
public List<SearchResult> searchRelatedMeetings(
|
||||||
|
String query,
|
||||||
|
String excludeMeetingId,
|
||||||
|
int limit
|
||||||
|
) {
|
||||||
|
// 1. 쿼리 텍스트를 벡터로 변환
|
||||||
|
float[] queryVector = embeddingService.generateEmbedding(query);
|
||||||
|
|
||||||
|
// 2. 벡터 검색 쿼리
|
||||||
|
Map<String, Object> searchRequest = Map.of(
|
||||||
|
"vector", Map.of(
|
||||||
|
"value", queryVector,
|
||||||
|
"fields", "contentVector",
|
||||||
|
"k", limit
|
||||||
|
),
|
||||||
|
"filter", String.format("meetingId ne '%s'", excludeMeetingId),
|
||||||
|
"select", "meetingId,title,date,participants,summary,score"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Azure AI Search API 호출
|
||||||
|
String response = webClient.post()
|
||||||
|
.uri(endpoint + "/indexes/" + indexName + "/docs/search?api-version=2023-11-01")
|
||||||
|
.header("api-key", apiKey)
|
||||||
|
.bodyValue(searchRequest)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block();
|
||||||
|
|
||||||
|
return parseSearchResults(response);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 백엔드 API 검증
|
||||||
|
|
||||||
|
### 4.1 SSE 스트리밍 테스트
|
||||||
|
|
||||||
|
**테스트 방법**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. SSE 엔드포인트 연결
|
||||||
|
curl -N -H "Accept: text/event-stream" \
|
||||||
|
"http://localhost:8083/api/suggestions/meetings/test-meeting-001/stream"
|
||||||
|
|
||||||
|
# 예상 출력:
|
||||||
|
# event: ai-suggestion
|
||||||
|
# data: {"discussionTopics":[...],"decisions":[...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트엔드 연동 예시** (JavaScript):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 05-회의진행.html에 추가
|
||||||
|
const meetingId = "test-meeting-001";
|
||||||
|
const eventSource = new EventSource(
|
||||||
|
`http://localhost:8083/api/suggestions/meetings/${meetingId}/stream`
|
||||||
|
);
|
||||||
|
|
||||||
|
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||||
|
const suggestions = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// 논의사항 표시
|
||||||
|
suggestions.discussionTopics.forEach(topic => {
|
||||||
|
addDiscussionCard(topic);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 결정사항 표시
|
||||||
|
suggestions.decisions.forEach(decision => {
|
||||||
|
addDecisionCard(decision);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE 연결 오류:', error);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
function addDiscussionCard(topic) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'ai-suggestion-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="ai-suggestion-header">
|
||||||
|
<span class="ai-suggestion-time">${new Date().toLocaleTimeString()}</span>
|
||||||
|
<span class="badge badge-${topic.priority.toLowerCase()}">${topic.priority}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ai-suggestion-text">
|
||||||
|
<strong>[논의사항]</strong> ${topic.topic}
|
||||||
|
</div>
|
||||||
|
<div class="ai-suggestion-reason">${topic.reason}</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('aiSuggestionList').prepend(card);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 용어 설명 API 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# POST /api/terms/detect
|
||||||
|
curl -X POST http://localhost:8083/api/terms/detect \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"meetingId": "test-meeting-001",
|
||||||
|
"text": "오늘 회의에서는 MSA 아키텍처와 API Gateway 설계에 대해 논의하겠습니다.",
|
||||||
|
"organizationId": "org-001"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 예상 응답:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"detectedTerms": [
|
||||||
|
{
|
||||||
|
"term": "MSA",
|
||||||
|
"confidence": 0.95,
|
||||||
|
"category": "아키텍처",
|
||||||
|
"definition": "Microservices Architecture의 약자...",
|
||||||
|
"context": "회의 맥락에 맞는 설명..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"term": "API Gateway",
|
||||||
|
"confidence": 0.92,
|
||||||
|
"category": "아키텍처"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalCount": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 관련 회의록 API 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GET /api/transcripts/{meetingId}/related
|
||||||
|
curl "http://localhost:8083/api/transcripts/test-meeting-001/related?transcriptId=transcript-001&limit=5"
|
||||||
|
|
||||||
|
# 예상 응답:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"relatedTranscripts": [
|
||||||
|
{
|
||||||
|
"transcriptId": "meeting-002",
|
||||||
|
"title": "MSA 아키텍처 설계 회의",
|
||||||
|
"date": "2025-01-15",
|
||||||
|
"participants": ["김철수", "이영희"],
|
||||||
|
"relevanceScore": 85.5,
|
||||||
|
"commonKeywords": ["MSA", "API Gateway", "Spring Boot"],
|
||||||
|
"link": "/transcripts/meeting-002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalCount": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프롬프트 엔지니어링 가이드
|
||||||
|
|
||||||
|
### 5.1 AI 제안사항 프롬프트
|
||||||
|
|
||||||
|
**핵심 원칙**:
|
||||||
|
1. **명확한 역할 정의**: AI의 역할을 "회의록 작성 전문가"로 명시
|
||||||
|
2. **구체적인 기준**: 무엇을 추출하고 무엇을 제외할지 명확히 명시
|
||||||
|
3. **컨텍스트 제공**: 회의 목적과 안건을 프롬프트에 포함
|
||||||
|
4. **구조화된 출력**: JSON 형식으로 파싱 가능한 응답 요청
|
||||||
|
|
||||||
|
**프롬프트 템플릿**:
|
||||||
|
|
||||||
|
```
|
||||||
|
당신은 비즈니스 회의록 작성 전문 AI 어시스턴트입니다.
|
||||||
|
|
||||||
|
**역할**: 실시간 회의 텍스트를 분석하여 업무 관련 핵심 내용만 추출
|
||||||
|
|
||||||
|
**분석 기준**:
|
||||||
|
1. 논의사항 (discussions):
|
||||||
|
- 회의 안건과 관련된 미결 주제
|
||||||
|
- 추가 검토가 필요한 업무 항목
|
||||||
|
- "~에 대해 논의 필요", "~검토해야 함" 등의 패턴
|
||||||
|
- **제외**: 잡담, 농담, 인사말, 회의와 무관한 대화
|
||||||
|
|
||||||
|
2. 결정사항 (decisions):
|
||||||
|
- 명확한 의사결정 표현 ("~하기로 함", "~로 결정", "~로 합의")
|
||||||
|
- 구체적인 Action Item
|
||||||
|
- 책임자와 일정이 언급된 항목
|
||||||
|
|
||||||
|
**필터링 규칙**:
|
||||||
|
- 회의 목적/안건과 무관한 내용 제외
|
||||||
|
- 단순 질의응답이나 확인 대화 제외
|
||||||
|
- 업무 맥락이 명확한 내용만 추출
|
||||||
|
|
||||||
|
**응답 형식**: 반드시 JSON만 반환
|
||||||
|
{
|
||||||
|
"discussions": [
|
||||||
|
{
|
||||||
|
"topic": "구체적인 논의 주제",
|
||||||
|
"reason": "회의 안건과의 연관성",
|
||||||
|
"priority": "HIGH|MEDIUM|LOW",
|
||||||
|
"relatedAgenda": "관련 안건"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decisions": [
|
||||||
|
{
|
||||||
|
"content": "결정 내용",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"extractedFrom": "원문 인용",
|
||||||
|
"actionOwner": "담당자 (있는 경우)",
|
||||||
|
"deadline": "일정 (있는 경우)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**회의 정보**:
|
||||||
|
- 목적: {meeting_purpose}
|
||||||
|
- 안건: {agenda_items}
|
||||||
|
|
||||||
|
**회의 내용 (최근 5분)**:
|
||||||
|
{transcript_text}
|
||||||
|
|
||||||
|
위 회의 목적과 안건에 맞춰 **회의와 관련된 내용만** 분석해주세요.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 용어 설명 프롬프트
|
||||||
|
|
||||||
|
```
|
||||||
|
다음 용어를 회의 맥락에 맞게 설명해주세요:
|
||||||
|
|
||||||
|
**용어**: {term}
|
||||||
|
**현재 회의 맥락**: {context}
|
||||||
|
|
||||||
|
**이전 회의에서의 사용 사례**:
|
||||||
|
{past_usages}
|
||||||
|
|
||||||
|
**설명 형식**:
|
||||||
|
1. 기본 정의 (1-2문장, 비전문가도 이해 가능하도록)
|
||||||
|
2. 현재 회의 맥락에서의 의미 (1-2문장, 이번 회의에서 이 용어가 어떤 의미로 쓰이는지)
|
||||||
|
3. 이전 논의 참고사항 (있는 경우, 과거 회의에서 관련 결정사항)
|
||||||
|
|
||||||
|
비즈니스 맥락에 맞는 실용적인 설명을 제공하세요.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 관련 회의록 검색 쿼리 생성 프롬프트
|
||||||
|
|
||||||
|
```
|
||||||
|
현재 회의와 관련된 과거 회의록을 찾기 위한 검색 쿼리를 생성하세요.
|
||||||
|
|
||||||
|
**현재 회의 정보**:
|
||||||
|
- 목적: {meeting_purpose}
|
||||||
|
- 안건: {agenda_items}
|
||||||
|
- 진행 중인 주요 논의: {current_discussions}
|
||||||
|
|
||||||
|
**검색 쿼리 생성 규칙**:
|
||||||
|
1. 회의 목적과 안건에서 핵심 키워드 추출
|
||||||
|
2. 동의어와 관련 용어 포함
|
||||||
|
3. 너무 일반적인 단어는 제외 (예: "회의", "논의")
|
||||||
|
4. 5-10개의 키워드로 구성
|
||||||
|
|
||||||
|
**출력 형식**: 키워드를 공백으로 구분한 문자열
|
||||||
|
예시: "MSA 마이크로서비스 API게이트웨이 분산시스템 아키텍처설계"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 환경 설정
|
||||||
|
|
||||||
|
### 6.1 application.yml 확인 사항
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Claude API 설정
|
||||||
|
external:
|
||||||
|
ai:
|
||||||
|
claude:
|
||||||
|
api-key: ${CLAUDE_API_KEY} # 환경변수 설정 필요
|
||||||
|
base-url: https://api.anthropic.com
|
||||||
|
model: claude-3-5-sonnet-20241022
|
||||||
|
max-tokens: 2000
|
||||||
|
temperature: 0.3
|
||||||
|
|
||||||
|
# Azure AI Search 설정
|
||||||
|
ai-search:
|
||||||
|
endpoint: ${AZURE_AI_SEARCH_ENDPOINT}
|
||||||
|
api-key: ${AZURE_AI_SEARCH_API_KEY}
|
||||||
|
index-name: meeting-transcripts
|
||||||
|
|
||||||
|
# Event Hub 설정
|
||||||
|
eventhub:
|
||||||
|
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING}
|
||||||
|
namespace: hgzero-eventhub-ns
|
||||||
|
eventhub-name: hgzero-eventhub-name
|
||||||
|
consumer-group:
|
||||||
|
transcript: ai-transcript-group
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 환경 변수 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Claude API
|
||||||
|
export CLAUDE_API_KEY="sk-ant-..."
|
||||||
|
|
||||||
|
# Azure AI Search
|
||||||
|
export AZURE_AI_SEARCH_ENDPOINT="https://your-search-service.search.windows.net"
|
||||||
|
export AZURE_AI_SEARCH_API_KEY="your-api-key"
|
||||||
|
|
||||||
|
# Azure Event Hub
|
||||||
|
export AZURE_EVENTHUB_CONNECTION_STRING="Endpoint=sb://..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 테스트 시나리오
|
||||||
|
|
||||||
|
### 7.1 전체 통합 테스트 시나리오
|
||||||
|
|
||||||
|
1. **회의 시작**
|
||||||
|
- 회의 생성 API 호출
|
||||||
|
- SSE 스트림 연결
|
||||||
|
|
||||||
|
2. **STT 텍스트 수신**
|
||||||
|
- Event Hub로 TranscriptSegmentReady 이벤트 발행
|
||||||
|
- Redis에 텍스트 축적 확인
|
||||||
|
|
||||||
|
3. **AI 제안사항 생성**
|
||||||
|
- 5분간 텍스트 축적
|
||||||
|
- Claude API 자동 호출
|
||||||
|
- SSE로 제안사항 수신 확인
|
||||||
|
|
||||||
|
4. **용어 설명 요청**
|
||||||
|
- 감지된 용어로 설명 API 호출
|
||||||
|
- 맥락에 맞는 설명 확인
|
||||||
|
|
||||||
|
5. **관련 회의록 조회**
|
||||||
|
- 관련 회의록 API 호출
|
||||||
|
- 유사도 점수 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 다음 단계
|
||||||
|
|
||||||
|
1. ✅ **MeetingGateway 구현**: Meeting 서비스와 통신하여 회의 정보 조회
|
||||||
|
2. ✅ **SearchGateway Azure AI Search 통합**: 벡터 검색 구현
|
||||||
|
3. ✅ **ClaudeApiClient 프롬프트 개선**: 회의 컨텍스트 활용
|
||||||
|
4. ✅ **프론트엔드 SSE 연동**: 05-회의진행.html에 SSE 클라이언트 추가
|
||||||
|
5. ✅ **통합 테스트**: 전체 플로우 동작 확인
|
||||||
340
develop/dev/dev-ai-integration-guide.md
Normal file
340
develop/dev/dev-ai-integration-guide.md
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
# AI 제안사항 프론트엔드 연동 가이드 (간소화 버전)
|
||||||
|
|
||||||
|
## 📋 **개요**
|
||||||
|
|
||||||
|
백엔드를 간소화하여 **논의사항과 결정사항을 구분하지 않고**, 단일 "AI 제안사항" 배열로 통합 제공합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 **변경 사항 요약**
|
||||||
|
|
||||||
|
### **Before (구분)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"discussionTopics": [
|
||||||
|
{ "topic": "보안 요구사항 검토", ... }
|
||||||
|
],
|
||||||
|
"decisions": [
|
||||||
|
{ "content": "React로 개발", ... }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **After (통합)** ✅
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"id": "sugg-001",
|
||||||
|
"content": "신제품의 타겟 고객층을 20-30대로 설정하고...",
|
||||||
|
"timestamp": "00:05:23",
|
||||||
|
"confidence": 0.92
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 **프론트엔드 통합 방법**
|
||||||
|
|
||||||
|
### **1. 05-회의진행.html에 스크립트 추가**
|
||||||
|
|
||||||
|
기존 `</body>` 태그 직전에 추가:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- AI 제안사항 SSE 연동 -->
|
||||||
|
<script src="ai-suggestion-integration.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### **2. 전체 플로우**
|
||||||
|
|
||||||
|
```
|
||||||
|
[페이지 로드]
|
||||||
|
↓
|
||||||
|
SSE 연결
|
||||||
|
↓
|
||||||
|
[회의 진행 중]
|
||||||
|
↓
|
||||||
|
AI 분석 완료 시마다
|
||||||
|
SSE로 제안사항 전송
|
||||||
|
↓
|
||||||
|
자동으로 카드 생성
|
||||||
|
↓
|
||||||
|
[회의 종료]
|
||||||
|
↓
|
||||||
|
SSE 연결 종료
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 **SSE API 명세**
|
||||||
|
|
||||||
|
### **엔드포인트**
|
||||||
|
```
|
||||||
|
GET /api/suggestions/meetings/{meetingId}/stream
|
||||||
|
```
|
||||||
|
|
||||||
|
### **헤더**
|
||||||
|
```
|
||||||
|
Accept: text/event-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
### **응답 형식**
|
||||||
|
```
|
||||||
|
event: ai-suggestion
|
||||||
|
id: 12345
|
||||||
|
data: {"suggestions":[{"id":"sugg-001","content":"...","timestamp":"00:05:23","confidence":0.92}]}
|
||||||
|
|
||||||
|
event: ai-suggestion
|
||||||
|
id: 12346
|
||||||
|
data: {"suggestions":[{"id":"sugg-002","content":"...","timestamp":"00:08:45","confidence":0.88}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 **테스트 방법**
|
||||||
|
|
||||||
|
### **1. 로컬 테스트 (Mock 데이터)**
|
||||||
|
|
||||||
|
백엔드가 아직 없어도 테스트 가능:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 테스트용 Mock 데이터 전송
|
||||||
|
function testAiSuggestion() {
|
||||||
|
const mockSuggestion = {
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
id: "test-001",
|
||||||
|
content: "테스트 제안사항입니다. 이것은 AI가 생성한 제안입니다.",
|
||||||
|
timestamp: "00:05:23",
|
||||||
|
confidence: 0.95
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAiSuggestions(mockSuggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 콘솔에서 실행
|
||||||
|
testAiSuggestion();
|
||||||
|
```
|
||||||
|
|
||||||
|
### **2. curl로 SSE 연결 테스트**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -N -H "Accept: text/event-stream" \
|
||||||
|
"http://localhost:8083/api/suggestions/meetings/test-meeting-001/stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
예상 출력:
|
||||||
|
```
|
||||||
|
event: ai-suggestion
|
||||||
|
data: {"suggestions":[...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **3. 브라우저 DevTools로 확인**
|
||||||
|
|
||||||
|
1. **Network 탭** → "EventStream" 필터
|
||||||
|
2. `/stream` 엔드포인트 클릭
|
||||||
|
3. **Messages** 탭에서 실시간 데이터 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 **JavaScript API 사용법**
|
||||||
|
|
||||||
|
### **초기화**
|
||||||
|
```javascript
|
||||||
|
// 자동으로 실행됨 (페이지 로드 시)
|
||||||
|
initializeAiSuggestions();
|
||||||
|
```
|
||||||
|
|
||||||
|
### **수동 연결 종료**
|
||||||
|
```javascript
|
||||||
|
closeAiSuggestions();
|
||||||
|
```
|
||||||
|
|
||||||
|
### **제안사항 수동 추가 (테스트용)**
|
||||||
|
```javascript
|
||||||
|
addSuggestionCard({
|
||||||
|
id: "manual-001",
|
||||||
|
content: "수동으로 추가한 제안사항",
|
||||||
|
timestamp: "00:10:00",
|
||||||
|
confidence: 0.9
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 **UI 커스터마이징**
|
||||||
|
|
||||||
|
### **신뢰도 표시 스타일**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 05-회의진행.html의 <style> 태그에 추가 */
|
||||||
|
.ai-suggestion-confidence {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed #E0E0E0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-confidence span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **신뢰도에 따른 색상 변경**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function getConfidenceColor(confidence) {
|
||||||
|
if (confidence >= 0.9) return '#4CAF50'; // 녹색 (높음)
|
||||||
|
if (confidence >= 0.7) return '#FFC107'; // 노란색 (중간)
|
||||||
|
return '#FF9800'; // 주황색 (낮음)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카드에 적용
|
||||||
|
card.innerHTML = `
|
||||||
|
...
|
||||||
|
<div class="ai-suggestion-confidence">
|
||||||
|
<span style="color: ${getConfidenceColor(suggestion.confidence)};">
|
||||||
|
신뢰도: ${Math.round(suggestion.confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 **트러블슈팅**
|
||||||
|
|
||||||
|
### **문제 1: SSE 연결이 안 됨**
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
```
|
||||||
|
EventSource's response has a MIME type ("application/json") that is not "text/event-stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
- 백엔드에서 `produces = MediaType.TEXT_EVENT_STREAM_VALUE` 확인
|
||||||
|
- SuggestionController.java:111 라인 확인
|
||||||
|
|
||||||
|
### **문제 2: CORS 오류**
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
```
|
||||||
|
Access to XMLHttpRequest has been blocked by CORS policy
|
||||||
|
```
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```java
|
||||||
|
// SecurityConfig.java에 추가
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
configuration.setAllowedOrigins(Arrays.asList("http://localhost:*"));
|
||||||
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST"));
|
||||||
|
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/api/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **문제 3: 제안사항이 화면에 안 나타남**
|
||||||
|
|
||||||
|
**체크리스트**:
|
||||||
|
1. `aiSuggestionList` ID가 HTML에 있는지 확인
|
||||||
|
2. 브라우저 콘솔에 에러가 없는지 확인
|
||||||
|
3. Network 탭에서 SSE 데이터가 오는지 확인
|
||||||
|
4. `handleAiSuggestions` 함수에 `console.log` 추가하여 디버깅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **성능 최적화**
|
||||||
|
|
||||||
|
### **제안사항 개수 제한**
|
||||||
|
|
||||||
|
너무 많은 카드가 쌓이면 성능 저하:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function addSuggestionCard(suggestion) {
|
||||||
|
// 카드 추가 로직...
|
||||||
|
|
||||||
|
// 최대 20개까지만 유지
|
||||||
|
const listElement = document.getElementById('aiSuggestionList');
|
||||||
|
const cards = listElement.querySelectorAll('.ai-suggestion-card');
|
||||||
|
if (cards.length > 20) {
|
||||||
|
cards[cards.length - 1].remove(); // 가장 오래된 카드 삭제
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **중복 제안사항 필터링**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const shownSuggestionIds = new Set();
|
||||||
|
|
||||||
|
function addSuggestionCard(suggestion) {
|
||||||
|
// 이미 표시된 제안사항은 무시
|
||||||
|
if (shownSuggestionIds.has(suggestion.id)) {
|
||||||
|
console.log('중복 제안사항 무시:', suggestion.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shownSuggestionIds.add(suggestion.id);
|
||||||
|
|
||||||
|
// 카드 추가 로직...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 **다음 단계**
|
||||||
|
|
||||||
|
1. ✅ **SimpleSuggestionDto 생성 완료**
|
||||||
|
2. ✅ **RealtimeSuggestionsDto 수정 완료**
|
||||||
|
3. ✅ **ClaudeApiClient 프롬프트 간소화 완료**
|
||||||
|
4. ✅ **SuggestionService 로직 수정 완료**
|
||||||
|
5. ✅ **프론트엔드 연동 코드 작성 완료**
|
||||||
|
|
||||||
|
### **실제 테스트 준비**
|
||||||
|
|
||||||
|
1. **백엔드 서버 시작**
|
||||||
|
```bash
|
||||||
|
cd ai
|
||||||
|
./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **프론트엔드 파일 열기**
|
||||||
|
```
|
||||||
|
design/uiux/prototype/05-회의진행.html
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **브라우저 DevTools 열고 Network 탭 확인**
|
||||||
|
|
||||||
|
4. **SSE 연결 확인**
|
||||||
|
- EventStream 필터 활성화
|
||||||
|
- `/stream` 엔드포인트 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 **완료 체크리스트**
|
||||||
|
|
||||||
|
- [x] SimpleSuggestionDto 생성
|
||||||
|
- [x] RealtimeSuggestionsDto 수정
|
||||||
|
- [x] ClaudeApiClient 프롬프트 간소화
|
||||||
|
- [x] SuggestionService Mock 데이터 수정
|
||||||
|
- [x] 프론트엔드 연동 JavaScript 작성
|
||||||
|
- [ ] 05-회의진행.html에 스크립트 추가
|
||||||
|
- [ ] 로컬 환경에서 테스트
|
||||||
|
- [ ] Claude API 실제 연동 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎉 간소화 작업 완료!**
|
||||||
|
|
||||||
|
이제 프론트엔드와 백엔드가 일치합니다. 05-회의진행.html에 스크립트만 추가하면 바로 사용 가능합니다.
|
||||||
385
develop/dev/dev-ai-realtime-streaming.md
Normal file
385
develop/dev/dev-ai-realtime-streaming.md
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
# 실시간 AI 제안 스트리밍 개발 가이드
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
회의 진행 중 STT로 변환된 텍스트를 실시간으로 분석하여 논의사항/결정사항을 AI가 제안하는 기능
|
||||||
|
|
||||||
|
**개발 일시**: 2025-10-24
|
||||||
|
**개발자**: AI Specialist (서연)
|
||||||
|
**사용 기술**: Claude API, Azure Event Hub, Redis, SSE (Server-Sent Events)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 구현된 기능
|
||||||
|
|
||||||
|
### ✅ **1. Claude API 클라이언트**
|
||||||
|
- **파일**: `ai/src/main/java/com/unicorn/hgzero/ai/infra/client/ClaudeApiClient.java`
|
||||||
|
- **기능**:
|
||||||
|
- Anthropic Claude API (claude-3-5-sonnet) 호출
|
||||||
|
- 실시간 텍스트 분석하여 논의사항/결정사항 추출
|
||||||
|
- JSON 응답 파싱 및 DTO 변환
|
||||||
|
|
||||||
|
### ✅ **2. Azure Event Hub Consumer**
|
||||||
|
- **파일**: `ai/src/main/java/com/unicorn/hgzero/ai/infra/config/EventHubConfig.java`
|
||||||
|
- **기능**:
|
||||||
|
- STT Service의 `TranscriptSegmentReady` 이벤트 구독
|
||||||
|
- 실시간 음성 변환 텍스트 수신
|
||||||
|
- SuggestionService로 전달하여 AI 분석 트리거
|
||||||
|
|
||||||
|
### ✅ **3. 실시간 텍스트 축적 로직**
|
||||||
|
- **파일**: `ai/src/main/java/com/unicorn/hgzero/ai/biz/service/SuggestionService.java`
|
||||||
|
- **메서드**: `processRealtimeTranscript()`
|
||||||
|
- **기능**:
|
||||||
|
- Redis Sorted Set을 이용한 슬라이딩 윈도우 (최근 5분 텍스트 유지)
|
||||||
|
- 임계값 도달 시 자동 AI 분석 (10개 세그먼트 = 약 100-200자)
|
||||||
|
|
||||||
|
### ✅ **4. SSE 스트리밍**
|
||||||
|
- **API**: `GET /api/suggestions/meetings/{meetingId}/stream`
|
||||||
|
- **Controller**: `SuggestionController:111`
|
||||||
|
- **기능**:
|
||||||
|
- Server-Sent Events로 실시간 AI 제안사항 전송
|
||||||
|
- 멀티캐스트 지원 (여러 클라이언트 동시 연결)
|
||||||
|
- 자동 리소스 정리 (연결 종료 시)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
[회의 진행 중]
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 1. STT Service (Azure Speech) │
|
||||||
|
│ - 음성 → 텍스트 실시간 변환 │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓ Azure Event Hub
|
||||||
|
↓ (TranscriptSegmentReady Event)
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 2. AI Service (Event Hub Consumer) │
|
||||||
|
│ - 이벤트 수신 │
|
||||||
|
│ - Redis에 텍스트 축적 │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 3. Redis (슬라이딩 윈도우) │
|
||||||
|
│ - 최근 5분 텍스트 유지 │
|
||||||
|
│ - 임계값 체크 (10 segments) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 4. Claude API (Anthropic) │
|
||||||
|
│ - 누적 텍스트 분석 │
|
||||||
|
│ - 논의사항/결정사항 추출 │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 5. SSE 스트리밍 │
|
||||||
|
│ - 클라이언트에 실시간 전송 │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 설정 방법
|
||||||
|
|
||||||
|
### **1. Claude API 키 발급**
|
||||||
|
|
||||||
|
1. [Anthropic Console](https://console.anthropic.com/) 접속
|
||||||
|
2. API Keys → Create Key
|
||||||
|
3. 생성된 API Key 복사
|
||||||
|
|
||||||
|
### **2. 환경 변수 설정**
|
||||||
|
|
||||||
|
**application.yml** 또는 **환경 변수**에 추가:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Claude API 설정
|
||||||
|
export CLAUDE_API_KEY="sk-ant-api03-..."
|
||||||
|
export CLAUDE_MODEL="claude-3-5-sonnet-20241022"
|
||||||
|
export CLAUDE_MAX_TOKENS="2000"
|
||||||
|
export CLAUDE_TEMPERATURE="0.3"
|
||||||
|
|
||||||
|
# Azure Event Hub 설정 (이미 설정됨)
|
||||||
|
export AZURE_EVENTHUB_CONNECTION_STRING="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;..."
|
||||||
|
export AZURE_EVENTHUB_NAME="hgzero-eventhub-name"
|
||||||
|
export AZURE_EVENTHUB_CONSUMER_GROUP_TRANSCRIPT="ai-transcript-group"
|
||||||
|
|
||||||
|
# Redis 설정 (이미 설정됨)
|
||||||
|
export REDIS_HOST="20.249.177.114"
|
||||||
|
export REDIS_PORT="6379"
|
||||||
|
export REDIS_PASSWORD="Hi5Jessica!"
|
||||||
|
export REDIS_DATABASE="4"
|
||||||
|
```
|
||||||
|
|
||||||
|
### **3. 의존성 확인**
|
||||||
|
|
||||||
|
`ai/build.gradle`에 이미 추가됨:
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
dependencies {
|
||||||
|
// Common module
|
||||||
|
implementation project(':common')
|
||||||
|
|
||||||
|
// Redis
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
|
|
||||||
|
// Anthropic Claude SDK
|
||||||
|
implementation 'com.anthropic:anthropic-sdk-java:0.1.0'
|
||||||
|
|
||||||
|
// Azure Event Hubs
|
||||||
|
implementation "com.azure:azure-messaging-eventhubs:${azureEventHubsVersion}"
|
||||||
|
implementation "com.azure:azure-messaging-eventhubs-checkpointstore-blob:${azureEventHubsCheckpointVersion}"
|
||||||
|
|
||||||
|
// Spring WebFlux for SSE streaming
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 실행 방법
|
||||||
|
|
||||||
|
### **1. AI Service 빌드**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/jominseo/HGZero
|
||||||
|
./gradlew :ai:build -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
### **2. AI Service 실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ai
|
||||||
|
./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 IntelliJ Run Configuration 사용
|
||||||
|
|
||||||
|
### **3. 클라이언트 테스트 (회의진행.html)**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// SSE 연결
|
||||||
|
const meetingId = "MTG-2025-001";
|
||||||
|
const eventSource = new EventSource(`/api/suggestions/meetings/${meetingId}/stream`);
|
||||||
|
|
||||||
|
// AI 제안사항 수신
|
||||||
|
eventSource.addEventListener('ai-suggestion', (event) => {
|
||||||
|
const suggestion = JSON.parse(event.data);
|
||||||
|
console.log('실시간 AI 제안:', suggestion);
|
||||||
|
|
||||||
|
// 논의사항 UI 업데이트
|
||||||
|
suggestion.discussionTopics.forEach(topic => {
|
||||||
|
addDiscussionToUI(topic);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 결정사항 UI 업데이트
|
||||||
|
suggestion.decisions.forEach(decision => {
|
||||||
|
addDecisionToUI(decision);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 에러 처리
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE 연결 오류:', error);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 회의 종료 시 연결 종료
|
||||||
|
function endMeeting() {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 데이터 흐름
|
||||||
|
|
||||||
|
### **Event Hub 이벤트 구조**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recordingId": "REC-20250123-001",
|
||||||
|
"meetingId": "MTG-2025-001",
|
||||||
|
"transcriptId": "TRS-SEG-001",
|
||||||
|
"text": "안녕하세요, 오늘 회의를 시작하겠습니다.",
|
||||||
|
"timestamp": 1234567890,
|
||||||
|
"confidence": 0.92,
|
||||||
|
"eventTime": "2025-01-23T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Claude API 응답 구조**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"discussions": [
|
||||||
|
{
|
||||||
|
"topic": "보안 요구사항 검토",
|
||||||
|
"reason": "안건에 포함되어 있으나 아직 논의되지 않음",
|
||||||
|
"priority": "HIGH"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decisions": [
|
||||||
|
{
|
||||||
|
"content": "React로 프론트엔드 개발",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"extractedFrom": "프론트엔드는 React로 개발하기로 했습니다"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **SSE 스트리밍 응답**
|
||||||
|
|
||||||
|
```
|
||||||
|
event: ai-suggestion
|
||||||
|
id: 12345
|
||||||
|
data: {"discussionTopics":[...],"decisions":[...]}
|
||||||
|
|
||||||
|
event: ai-suggestion
|
||||||
|
id: 12346
|
||||||
|
data: {"discussionTopics":[...],"decisions":[...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 주요 설정값
|
||||||
|
|
||||||
|
| 설정 | 값 | 설명 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `MIN_SEGMENTS_FOR_ANALYSIS` | 10 | AI 분석 시작 임계값 (세그먼트 수) |
|
||||||
|
| `TEXT_RETENTION_MS` | 300000 (5분) | Redis 텍스트 보관 기간 |
|
||||||
|
| `CLAUDE_MODEL` | claude-3-5-sonnet-20241022 | 사용 Claude 모델 |
|
||||||
|
| `CLAUDE_MAX_TOKENS` | 2000 | 최대 응답 토큰 수 |
|
||||||
|
| `CLAUDE_TEMPERATURE` | 0.3 | 창의성 수준 (0-1) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 트러블슈팅
|
||||||
|
|
||||||
|
### **1. Event Hub 연결 실패**
|
||||||
|
|
||||||
|
**증상**: `Event Hub Processor 시작 실패` 로그
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```bash
|
||||||
|
# 연결 문자열 확인
|
||||||
|
echo $AZURE_EVENTHUB_CONNECTION_STRING
|
||||||
|
|
||||||
|
# Consumer Group 확인
|
||||||
|
echo $AZURE_EVENTHUB_CONSUMER_GROUP_TRANSCRIPT
|
||||||
|
```
|
||||||
|
|
||||||
|
### **2. Claude API 호출 실패**
|
||||||
|
|
||||||
|
**증상**: `Claude API 호출 실패` 로그
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```bash
|
||||||
|
# API 키 확인
|
||||||
|
echo $CLAUDE_API_KEY
|
||||||
|
|
||||||
|
# 네트워크 연결 확인
|
||||||
|
curl -X POST https://api.anthropic.com/v1/messages \
|
||||||
|
-H "x-api-key: $CLAUDE_API_KEY" \
|
||||||
|
-H "anthropic-version: 2023-06-01" \
|
||||||
|
-H "content-type: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### **3. Redis 연결 실패**
|
||||||
|
|
||||||
|
**증상**: `Unable to connect to Redis` 로그
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```bash
|
||||||
|
# Redis 연결 테스트
|
||||||
|
redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD ping
|
||||||
|
|
||||||
|
# 응답: PONG
|
||||||
|
```
|
||||||
|
|
||||||
|
### **4. SSE 스트림 끊김**
|
||||||
|
|
||||||
|
**증상**: 클라이언트에서 연결이 자주 끊김
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```javascript
|
||||||
|
// 자동 재연결 로직 추가
|
||||||
|
function connectSSE(meetingId) {
|
||||||
|
const eventSource = new EventSource(`/api/suggestions/meetings/${meetingId}/stream`);
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE 연결 오류, 5초 후 재연결...');
|
||||||
|
eventSource.close();
|
||||||
|
setTimeout(() => connectSSE(meetingId), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return eventSource;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 성능 최적화
|
||||||
|
|
||||||
|
### **1. Redis 메모리 관리**
|
||||||
|
- 슬라이딩 윈도우로 최근 5분만 유지
|
||||||
|
- 회의 종료 시 자동 삭제
|
||||||
|
- TTL 설정 고려 (향후 추가)
|
||||||
|
|
||||||
|
### **2. Claude API 호출 최적화**
|
||||||
|
- 임계값 도달 시에만 호출 (불필요한 호출 방지)
|
||||||
|
- 비동기 처리로 응답 대기 시간 최소화
|
||||||
|
- 에러 발생 시 빈 응답 반환 (서비스 중단 방지)
|
||||||
|
|
||||||
|
### **3. SSE 연결 관리**
|
||||||
|
- 멀티캐스트로 여러 클라이언트 동시 지원
|
||||||
|
- 연결 종료 시 자동 리소스 정리
|
||||||
|
- Backpressure 버퍼링으로 과부하 방지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔜 향후 개발 계획
|
||||||
|
|
||||||
|
### **Phase 2: AI 정확도 향상**
|
||||||
|
- [ ] 회의 안건 기반 맥락 분석
|
||||||
|
- [ ] 과거 회의록 참조 (RAG)
|
||||||
|
- [ ] 조직별 용어 사전 통합
|
||||||
|
|
||||||
|
### **Phase 3: 성능 개선**
|
||||||
|
- [ ] Redis TTL 자동 설정
|
||||||
|
- [ ] Claude API 캐싱 전략
|
||||||
|
- [ ] 배치 분석 옵션 추가
|
||||||
|
|
||||||
|
### **Phase 4: 모니터링**
|
||||||
|
- [ ] AI 제안 정확도 측정
|
||||||
|
- [ ] 응답 시간 메트릭 수집
|
||||||
|
- [ ] 사용량 대시보드 구축
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 참고 자료
|
||||||
|
|
||||||
|
- [Anthropic Claude API 문서](https://docs.anthropic.com/claude/reference/messages)
|
||||||
|
- [Azure Event Hubs 문서](https://learn.microsoft.com/en-us/azure/event-hubs/)
|
||||||
|
- [Server-Sent Events 스펙](https://html.spec.whatwg.org/multipage/server-sent-events.html)
|
||||||
|
- [Redis Sorted Sets 가이드](https://redis.io/docs/data-types/sorted-sets/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 체크리스트
|
||||||
|
|
||||||
|
- [x] Claude API 클라이언트 구현
|
||||||
|
- [x] Azure Event Hub Consumer 구현
|
||||||
|
- [x] Redis 슬라이딩 윈도우 구현
|
||||||
|
- [x] SSE 스트리밍 구현
|
||||||
|
- [x] SuggestionService 통합
|
||||||
|
- [ ] Claude API 키 발급 및 설정
|
||||||
|
- [ ] 통합 테스트 (STT → AI → SSE)
|
||||||
|
- [ ] 프론트엔드 연동 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**개발 완료**: 2025-10-24
|
||||||
|
**다음 단계**: Claude API 키 발급 및 통합 테스트
|
||||||
400
develop/dev/dev-ai-sample-data-guide.md
Normal file
400
develop/dev/dev-ai-sample-data-guide.md
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
# AI 샘플 데이터 통합 가이드
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
AI 서비스 개발이 완료되지 않은 상황에서 프론트엔드 개발을 병행하기 위해 **샘플 데이터 자동 발행 기능**을 구현했습니다.
|
||||||
|
|
||||||
|
### 목적
|
||||||
|
- 프론트엔드 개발자가 AI 기능 완성을 기다리지 않고 화면 개발 가능
|
||||||
|
- 실시간 SSE(Server-Sent Events) 스트리밍 동작 테스트
|
||||||
|
- 회의 진행 중 AI 제안사항 표시 기능 검증
|
||||||
|
|
||||||
|
### 주요 기능
|
||||||
|
- **백엔드**: AI Service에서 5초마다 샘플 제안사항 3개 자동 발행
|
||||||
|
- **프론트엔드**: EventSource API를 통한 실시간 데이터 수신 및 화면 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 백엔드 구현
|
||||||
|
|
||||||
|
### 1.1 수정 파일
|
||||||
|
- **파일**: `ai/src/main/java/com/unicorn/hgzero/ai/biz/service/SuggestionService.java`
|
||||||
|
- **수정 내용**: `startMockDataEmission()` 메서드 추가
|
||||||
|
|
||||||
|
### 1.2 구현 내용
|
||||||
|
|
||||||
|
#### Mock 데이터 자동 발행 메서드
|
||||||
|
```java
|
||||||
|
/**
|
||||||
|
* TODO: AI 개발 완료 후 제거
|
||||||
|
* Mock 데이터 자동 발행 (프론트엔드 개발용)
|
||||||
|
* 5초마다 샘플 제안사항을 발행합니다.
|
||||||
|
*/
|
||||||
|
private void startMockDataEmission(String meetingId, Sinks.Many<RealtimeSuggestionsDto> sink) {
|
||||||
|
// 프론트엔드 HTML에 맞춘 샘플 데이터 (3개)
|
||||||
|
List<SimpleSuggestionDto> mockSuggestions = List.of(
|
||||||
|
SimpleSuggestionDto.builder()
|
||||||
|
.id("suggestion-1")
|
||||||
|
.content("신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.")
|
||||||
|
.timestamp("00:05:23")
|
||||||
|
.confidence(0.92)
|
||||||
|
.build(),
|
||||||
|
// ... 3개의 샘플 데이터
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5초마다 하나씩 발행 (총 3개)
|
||||||
|
Flux.interval(Duration.ofSeconds(5))
|
||||||
|
.take(3)
|
||||||
|
.map(index -> {
|
||||||
|
SimpleSuggestionDto suggestion = mockSuggestions.get(index.intValue());
|
||||||
|
return RealtimeSuggestionsDto.builder()
|
||||||
|
.suggestions(List.of(suggestion))
|
||||||
|
.build();
|
||||||
|
})
|
||||||
|
.subscribe(
|
||||||
|
suggestions -> {
|
||||||
|
sink.tryEmitNext(suggestions);
|
||||||
|
log.info("Mock 제안사항 발행 완료");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SSE 스트리밍 메서드 수정
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
public Flux<RealtimeSuggestionsDto> streamRealtimeSuggestions(String meetingId) {
|
||||||
|
// Sink 생성
|
||||||
|
Sinks.Many<RealtimeSuggestionsDto> sink = Sinks.many()
|
||||||
|
.multicast()
|
||||||
|
.onBackpressureBuffer();
|
||||||
|
|
||||||
|
meetingSinks.put(meetingId, sink);
|
||||||
|
|
||||||
|
// TODO: AI 개발 완료 후 제거 - Mock 데이터 자동 발행
|
||||||
|
startMockDataEmission(meetingId, sink);
|
||||||
|
|
||||||
|
return sink.asFlux()
|
||||||
|
.doOnCancel(() -> {
|
||||||
|
meetingSinks.remove(meetingId);
|
||||||
|
cleanupMeetingData(meetingId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 샘플 데이터 구조
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"id": "suggestion-1",
|
||||||
|
"content": "신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.",
|
||||||
|
"timestamp": "00:05:23",
|
||||||
|
"confidence": 0.92
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 프론트엔드 구현
|
||||||
|
|
||||||
|
### 2.1 수정 파일
|
||||||
|
- **파일**: `design/uiux/prototype/05-회의진행.html`
|
||||||
|
- **수정 내용**: SSE 연결 및 실시간 데이터 수신 코드 추가
|
||||||
|
|
||||||
|
### 2.2 구현 내용
|
||||||
|
|
||||||
|
#### SSE 연결 함수
|
||||||
|
```javascript
|
||||||
|
function connectAiSuggestionStream() {
|
||||||
|
const apiUrl = `http://localhost:8082/api/suggestions/meetings/${meetingId}/stream`;
|
||||||
|
|
||||||
|
eventSource = new EventSource(apiUrl);
|
||||||
|
|
||||||
|
eventSource.addEventListener('ai-suggestion', function(event) {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
const suggestions = data.suggestions;
|
||||||
|
|
||||||
|
if (suggestions && suggestions.length > 0) {
|
||||||
|
suggestions.forEach(suggestion => {
|
||||||
|
addAiSuggestionToUI(suggestion);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = function(error) {
|
||||||
|
console.error('SSE 연결 오류:', error);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UI 추가 함수
|
||||||
|
```javascript
|
||||||
|
function addAiSuggestionToUI(suggestion) {
|
||||||
|
const listContainer = document.getElementById('aiSuggestionList');
|
||||||
|
const cardId = `suggestion-${suggestion.id}`;
|
||||||
|
|
||||||
|
// 중복 방지
|
||||||
|
if (document.getElementById(cardId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 제안 카드 HTML 생성
|
||||||
|
const cardHtml = `
|
||||||
|
<div class="ai-suggestion-card" id="${cardId}">
|
||||||
|
<div class="ai-suggestion-header">
|
||||||
|
<span class="ai-suggestion-time">${suggestion.timestamp}</span>
|
||||||
|
<button class="ai-suggestion-add-btn"
|
||||||
|
onclick="addToMemo('${escapeHtml(suggestion.content)}', document.getElementById('${cardId}'))"
|
||||||
|
title="메모에 추가">
|
||||||
|
➕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-suggestion-text">
|
||||||
|
${escapeHtml(suggestion.content)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
listContainer.insertAdjacentHTML('beforeend', cardHtml);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### XSS 방지
|
||||||
|
```javascript
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, m => map[m]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 테스트 방법
|
||||||
|
|
||||||
|
### 3.1 서비스 실행
|
||||||
|
|
||||||
|
#### 1단계: AI 서비스 실행
|
||||||
|
```bash
|
||||||
|
# IntelliJ 실행 프로파일 사용
|
||||||
|
python3 tools/run-intellij-service-profile.py ai
|
||||||
|
|
||||||
|
# 또는 직접 실행
|
||||||
|
cd ai
|
||||||
|
./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
**실행 확인**:
|
||||||
|
- 포트: `8082`
|
||||||
|
- 로그 확인: `ai/logs/ai-service.log`
|
||||||
|
|
||||||
|
#### 2단계: 프론트엔드 HTML 열기
|
||||||
|
```bash
|
||||||
|
# 브라우저에서 직접 열기
|
||||||
|
open design/uiux/prototype/05-회의진행.html
|
||||||
|
|
||||||
|
# 또는 HTTP 서버 실행
|
||||||
|
cd design/uiux/prototype
|
||||||
|
python3 -m http.server 8000
|
||||||
|
# 브라우저: http://localhost:8000/05-회의진행.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 동작 확인
|
||||||
|
|
||||||
|
#### 브라우저 콘솔 확인
|
||||||
|
1. 개발자 도구 열기 (F12)
|
||||||
|
2. Console 탭 확인
|
||||||
|
|
||||||
|
**예상 로그**:
|
||||||
|
```
|
||||||
|
AI 제안사항 SSE 스트림 연결됨: http://localhost:8082/api/suggestions/meetings/550e8400-e29b-41d4-a716-446655440000/stream
|
||||||
|
AI 제안사항 수신: {"suggestions":[{"id":"suggestion-1", ...}]}
|
||||||
|
AI 제안사항 추가됨: 신제품의 타겟 고객층을 20-30대로 설정하고...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 화면 동작 확인
|
||||||
|
1. **페이지 로드**: 회의진행.html 열기
|
||||||
|
2. **AI 제안 탭 클릭**: "AI 제안" 탭으로 이동
|
||||||
|
3. **5초 대기**: 첫 번째 제안사항 표시
|
||||||
|
4. **10초 대기**: 두 번째 제안사항 표시
|
||||||
|
5. **15초 대기**: 세 번째 제안사항 표시
|
||||||
|
|
||||||
|
#### 백엔드 로그 확인
|
||||||
|
```bash
|
||||||
|
tail -f ai/logs/ai-service.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 로그**:
|
||||||
|
```
|
||||||
|
실시간 AI 제안사항 스트리밍 시작 - meetingId: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
Mock 데이터 자동 발행 시작 - meetingId: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
Mock 제안사항 발행 - meetingId: 550e8400-e29b-41d4-a716-446655440000, 제안: 신제품의 타겟 고객층...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 API 직접 테스트 (curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSE 스트림 연결
|
||||||
|
curl -N http://localhost:8082/api/suggestions/meetings/550e8400-e29b-41d4-a716-446655440000/stream
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 응답**:
|
||||||
|
```
|
||||||
|
event: ai-suggestion
|
||||||
|
id: 123456789
|
||||||
|
data: {"suggestions":[{"id":"suggestion-1","content":"신제품의 타겟 고객층...","timestamp":"00:05:23","confidence":0.92}]}
|
||||||
|
|
||||||
|
event: ai-suggestion
|
||||||
|
id: 987654321
|
||||||
|
data: {"suggestions":[{"id":"suggestion-2","content":"개발 일정...","timestamp":"00:08:45","confidence":0.88}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. CORS 설정 (필요 시)
|
||||||
|
|
||||||
|
프론트엔드를 다른 포트에서 실행할 경우 CORS 설정이 필요합니다.
|
||||||
|
|
||||||
|
### 4.1 application.yml 확인
|
||||||
|
```yaml
|
||||||
|
# ai/src/main/resources/application.yml
|
||||||
|
spring:
|
||||||
|
web:
|
||||||
|
cors:
|
||||||
|
allowed-origins:
|
||||||
|
- http://localhost:8000
|
||||||
|
- http://localhost:3000
|
||||||
|
allowed-methods:
|
||||||
|
- GET
|
||||||
|
- POST
|
||||||
|
- PUT
|
||||||
|
- DELETE
|
||||||
|
allowed-headers:
|
||||||
|
- "*"
|
||||||
|
allow-credentials: true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 주의사항
|
||||||
|
|
||||||
|
### 5.1 Mock 데이터 제거 시점
|
||||||
|
⚠️ **AI 개발 완료 후 반드시 제거해야 할 코드**:
|
||||||
|
|
||||||
|
#### 백엔드 (SuggestionService.java)
|
||||||
|
```java
|
||||||
|
// TODO: AI 개발 완료 후 제거 - 이 줄 삭제
|
||||||
|
startMockDataEmission(meetingId, sink);
|
||||||
|
|
||||||
|
// TODO: AI 개발 완료 후 제거 - 이 메서드 전체 삭제
|
||||||
|
private void startMockDataEmission(...) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 프론트엔드 (회의진행.html)
|
||||||
|
- SSE 연결 코드는 **그대로 유지**
|
||||||
|
- API URL만 실제 환경에 맞게 수정:
|
||||||
|
```javascript
|
||||||
|
// 개발 환경
|
||||||
|
const apiUrl = `http://localhost:8082/api/suggestions/meetings/${meetingId}/stream`;
|
||||||
|
|
||||||
|
// 운영 환경 (예시)
|
||||||
|
const apiUrl = `/api/suggestions/meetings/${meetingId}/stream`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 제한사항
|
||||||
|
|
||||||
|
1. **회의 ID 고정**
|
||||||
|
- 현재 테스트용 회의 ID가 하드코딩됨
|
||||||
|
- 실제 환경에서는 회의 생성 API 응답에서 받아야 함
|
||||||
|
|
||||||
|
2. **샘플 데이터 개수**
|
||||||
|
- 현재 3개로 제한
|
||||||
|
- 실제 AI는 회의 진행에 따라 동적으로 생성
|
||||||
|
|
||||||
|
3. **재연결 처리 없음**
|
||||||
|
- SSE 연결이 끊어지면 재연결하지 않음
|
||||||
|
- 실제 환경에서는 재연결 로직 필요
|
||||||
|
|
||||||
|
4. **인증/인가 없음**
|
||||||
|
- 현재 JWT 토큰 검증 없이 테스트
|
||||||
|
- 실제 환경에서는 인증 헤더 추가 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 트러블슈팅
|
||||||
|
|
||||||
|
### 문제 1: SSE 연결 안 됨
|
||||||
|
**증상**: 브라우저 콘솔에 "SSE 연결 오류" 표시
|
||||||
|
|
||||||
|
**해결 방법**:
|
||||||
|
1. AI 서비스가 실행 중인지 확인
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8082/actuator/health
|
||||||
|
```
|
||||||
|
2. CORS 설정 확인
|
||||||
|
3. 방화벽/포트 확인
|
||||||
|
|
||||||
|
### 문제 2: 제안사항이 표시되지 않음
|
||||||
|
**증상**: SSE는 연결되지만 화면에 아무것도 표시되지 않음
|
||||||
|
|
||||||
|
**해결 방법**:
|
||||||
|
1. 브라우저 콘솔에서 에러 확인
|
||||||
|
2. Network 탭에서 SSE 이벤트 확인
|
||||||
|
3. 백엔드 로그 확인
|
||||||
|
|
||||||
|
### 문제 3: 중복 제안사항 표시
|
||||||
|
**증상**: 같은 제안이 여러 번 표시됨
|
||||||
|
|
||||||
|
**해결 방법**:
|
||||||
|
- 페이지 새로고침 (SSE 연결 재시작)
|
||||||
|
- 브라우저 캐시 삭제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 다음 단계
|
||||||
|
|
||||||
|
### AI 개발 완료 후 작업
|
||||||
|
1. **Mock 코드 제거**
|
||||||
|
- `startMockDataEmission()` 메서드 삭제
|
||||||
|
- 관련 TODO 주석 제거
|
||||||
|
|
||||||
|
2. **실제 AI 로직 연결**
|
||||||
|
- Claude API 연동
|
||||||
|
- Event Hub 메시지 수신
|
||||||
|
- Redis 텍스트 축적 및 분석
|
||||||
|
|
||||||
|
3. **프론트엔드 개선**
|
||||||
|
- 재연결 로직 추가
|
||||||
|
- 에러 핸들링 강화
|
||||||
|
- 로딩 상태 표시
|
||||||
|
|
||||||
|
4. **성능 최적화**
|
||||||
|
- SSE 연결 풀 관리
|
||||||
|
- 메모리 누수 방지
|
||||||
|
- 네트워크 재시도 전략
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 관련 문서
|
||||||
|
|
||||||
|
- [AI Service API 설계서](../../design/backend/api/spec/ai-service-api-spec.md)
|
||||||
|
- [SSE 스트리밍 가이드](dev-ai-realtime-streaming.md)
|
||||||
|
- [백엔드 개발 가이드](dev-backend.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문서 이력
|
||||||
|
|
||||||
|
| 버전 | 작성일 | 작성자 | 변경 내용 |
|
||||||
|
|------|--------|--------|----------|
|
||||||
|
| 1.0 | 2025-10-27 | 준호 (Backend Developer) | 초안 작성 |
|
||||||
File diff suppressed because it is too large
Load Diff
4118
stt/logs/stt.log
4118
stt/logs/stt.log
File diff suppressed because it is too large
Load Diff
BIN
stt/logs/stt.log.2025-10-23.0.gz
Normal file
BIN
stt/logs/stt.log.2025-10-23.0.gz
Normal file
Binary file not shown.
BIN
stt/logs/stt.log.2025-10-24.0.gz
Normal file
BIN
stt/logs/stt.log.2025-10-24.0.gz
Normal file
Binary file not shown.
@ -2,11 +2,9 @@ package com.unicorn.hgzero.stt.config;
|
|||||||
|
|
||||||
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
|
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
|
||||||
import com.unicorn.hgzero.stt.repository.jpa.RecordingRepository;
|
import com.unicorn.hgzero.stt.repository.jpa.RecordingRepository;
|
||||||
import com.unicorn.hgzero.stt.repository.jpa.SpeakerRepository;
|
|
||||||
import com.unicorn.hgzero.stt.repository.jpa.TranscriptionRepository;
|
import com.unicorn.hgzero.stt.repository.jpa.TranscriptionRepository;
|
||||||
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
|
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
|
||||||
import com.unicorn.hgzero.stt.service.RecordingService;
|
import com.unicorn.hgzero.stt.service.RecordingService;
|
||||||
import com.unicorn.hgzero.stt.service.SpeakerService;
|
|
||||||
import com.unicorn.hgzero.stt.service.TranscriptionService;
|
import com.unicorn.hgzero.stt.service.TranscriptionService;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
|
|||||||
@ -53,7 +53,6 @@ class RecordingControllerTest {
|
|||||||
.sessionId("SESSION-001")
|
.sessionId("SESSION-001")
|
||||||
.status("READY")
|
.status("READY")
|
||||||
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-001")
|
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-001")
|
||||||
.storagePath("recordings/MEETING-001/SESSION-001.wav")
|
|
||||||
.estimatedInitTime(1100)
|
.estimatedInitTime(1100)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@ -145,8 +144,6 @@ class RecordingControllerTest {
|
|||||||
.startTime(LocalDateTime.now().minusMinutes(30))
|
.startTime(LocalDateTime.now().minusMinutes(30))
|
||||||
.endTime(LocalDateTime.now())
|
.endTime(LocalDateTime.now())
|
||||||
.duration(1800)
|
.duration(1800)
|
||||||
.fileSize(172800000L)
|
|
||||||
.storagePath("recordings/MEETING-001/SESSION-001.wav")
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
when(recordingService.stopRecording(eq(recordingId), any(RecordingDto.StopRequest.class)))
|
when(recordingService.stopRecording(eq(recordingId), any(RecordingDto.StopRequest.class)))
|
||||||
@ -160,8 +157,7 @@ class RecordingControllerTest {
|
|||||||
.andExpect(jsonPath("$.success").value(true))
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
|
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
|
||||||
.andExpect(jsonPath("$.data.status").value("STOPPED"))
|
.andExpect(jsonPath("$.data.status").value("STOPPED"))
|
||||||
.andExpect(jsonPath("$.data.duration").value(1800))
|
.andExpect(jsonPath("$.data.duration").value(1800));
|
||||||
.andExpect(jsonPath("$.data.fileSize").value(172800000L));
|
|
||||||
|
|
||||||
verify(recordingService).stopRecording(eq(recordingId), any(RecordingDto.StopRequest.class));
|
verify(recordingService).stopRecording(eq(recordingId), any(RecordingDto.StopRequest.class));
|
||||||
}
|
}
|
||||||
@ -180,9 +176,7 @@ class RecordingControllerTest {
|
|||||||
.startTime(LocalDateTime.now().minusMinutes(30))
|
.startTime(LocalDateTime.now().minusMinutes(30))
|
||||||
.endTime(LocalDateTime.now())
|
.endTime(LocalDateTime.now())
|
||||||
.duration(1800)
|
.duration(1800)
|
||||||
.speakerCount(3)
|
|
||||||
.segmentCount(45)
|
.segmentCount(45)
|
||||||
.storagePath("recordings/MEETING-001/SESSION-001.wav")
|
|
||||||
.language("ko-KR")
|
.language("ko-KR")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@ -197,7 +191,6 @@ class RecordingControllerTest {
|
|||||||
.andExpect(jsonPath("$.data.sessionId").value("SESSION-001"))
|
.andExpect(jsonPath("$.data.sessionId").value("SESSION-001"))
|
||||||
.andExpect(jsonPath("$.data.status").value("STOPPED"))
|
.andExpect(jsonPath("$.data.status").value("STOPPED"))
|
||||||
.andExpect(jsonPath("$.data.duration").value(1800))
|
.andExpect(jsonPath("$.data.duration").value(1800))
|
||||||
.andExpect(jsonPath("$.data.speakerCount").value(3))
|
|
||||||
.andExpect(jsonPath("$.data.segmentCount").value(45))
|
.andExpect(jsonPath("$.data.segmentCount").value(45))
|
||||||
.andExpect(jsonPath("$.data.language").value("ko-KR"));
|
.andExpect(jsonPath("$.data.language").value("ko-KR"));
|
||||||
|
|
||||||
|
|||||||
@ -51,7 +51,6 @@ class SimpleRecordingControllerTest {
|
|||||||
.sessionId("SESSION-001")
|
.sessionId("SESSION-001")
|
||||||
.status("READY")
|
.status("READY")
|
||||||
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-001")
|
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-001")
|
||||||
.storagePath("recordings/MEETING-001/SESSION-001.wav")
|
|
||||||
.estimatedInitTime(1100)
|
.estimatedInitTime(1100)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
package com.unicorn.hgzero.stt.integration;
|
package com.unicorn.hgzero.stt.integration;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.unicorn.hgzero.stt.config.TestConfig;
|
|
||||||
import com.unicorn.hgzero.stt.dto.RecordingDto;
|
import com.unicorn.hgzero.stt.dto.RecordingDto;
|
||||||
import com.unicorn.hgzero.stt.dto.TranscriptionDto;
|
import com.unicorn.hgzero.stt.dto.TranscriptionDto;
|
||||||
import com.unicorn.hgzero.stt.dto.SpeakerDto;
|
|
||||||
import com.unicorn.hgzero.stt.service.RecordingService;
|
import com.unicorn.hgzero.stt.service.RecordingService;
|
||||||
import com.unicorn.hgzero.stt.service.SpeakerService;
|
|
||||||
import com.unicorn.hgzero.stt.service.TranscriptionService;
|
import com.unicorn.hgzero.stt.service.TranscriptionService;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
@ -15,12 +12,10 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.MvcResult;
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
@ -47,9 +42,6 @@ class SttApiIntegrationTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private RecordingService recordingService;
|
private RecordingService recordingService;
|
||||||
|
|
||||||
@MockBean
|
|
||||||
private SpeakerService speakerService;
|
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private TranscriptionService transcriptionService;
|
private TranscriptionService transcriptionService;
|
||||||
|
|
||||||
@ -62,7 +54,6 @@ class SttApiIntegrationTest {
|
|||||||
.sessionId("SESSION-INTEGRATION-001")
|
.sessionId("SESSION-INTEGRATION-001")
|
||||||
.status("READY")
|
.status("READY")
|
||||||
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-INTEGRATION-001")
|
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-INTEGRATION-001")
|
||||||
.storagePath("recordings/MEETING-INTEGRATION-001/SESSION-INTEGRATION-001.wav")
|
|
||||||
.estimatedInitTime(1100)
|
.estimatedInitTime(1100)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
@ -81,8 +72,6 @@ class SttApiIntegrationTest {
|
|||||||
.startTime(java.time.LocalDateTime.now().minusMinutes(30))
|
.startTime(java.time.LocalDateTime.now().minusMinutes(30))
|
||||||
.endTime(java.time.LocalDateTime.now())
|
.endTime(java.time.LocalDateTime.now())
|
||||||
.duration(1800)
|
.duration(1800)
|
||||||
.fileSize(172800000L)
|
|
||||||
.storagePath("recordings/MEETING-INTEGRATION-001/SESSION-INTEGRATION-001.wav")
|
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
when(recordingService.getRecording(anyString()))
|
when(recordingService.getRecording(anyString()))
|
||||||
@ -94,9 +83,7 @@ class SttApiIntegrationTest {
|
|||||||
.startTime(java.time.LocalDateTime.now().minusMinutes(30))
|
.startTime(java.time.LocalDateTime.now().minusMinutes(30))
|
||||||
.endTime(java.time.LocalDateTime.now())
|
.endTime(java.time.LocalDateTime.now())
|
||||||
.duration(1800)
|
.duration(1800)
|
||||||
.speakerCount(3)
|
|
||||||
.segmentCount(45)
|
.segmentCount(45)
|
||||||
.storagePath("recordings/MEETING-INTEGRATION-001/SESSION-INTEGRATION-001.wav")
|
|
||||||
.language("ko-KR")
|
.language("ko-KR")
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
@ -108,33 +95,17 @@ class SttApiIntegrationTest {
|
|||||||
.text("안녕하세요")
|
.text("안녕하세요")
|
||||||
.confidence(0.95)
|
.confidence(0.95)
|
||||||
.timestamp(System.currentTimeMillis())
|
.timestamp(System.currentTimeMillis())
|
||||||
.speakerId("SPK-001")
|
|
||||||
.duration(2.5)
|
.duration(2.5)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
when(transcriptionService.getTranscription(anyString(), any(), any()))
|
when(transcriptionService.getTranscription(anyString()))
|
||||||
.thenReturn(TranscriptionDto.Response.builder()
|
.thenReturn(TranscriptionDto.Response.builder()
|
||||||
.recordingId("REC-20250123-001")
|
.recordingId("REC-20250123-001")
|
||||||
.fullText("안녕하세요. 오늘 회의를 시작하겠습니다.")
|
.fullText("안녕하세요. 오늘 회의를 시작하겠습니다.")
|
||||||
.segmentCount(45)
|
.segmentCount(45)
|
||||||
.speakerCount(3)
|
|
||||||
.totalDuration(1800)
|
.totalDuration(1800)
|
||||||
.averageConfidence(0.92)
|
.averageConfidence(0.92)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
// SpeakerService Mock 설정
|
|
||||||
when(speakerService.identifySpeaker(any(SpeakerDto.IdentifyRequest.class)))
|
|
||||||
.thenReturn(SpeakerDto.IdentificationResponse.builder()
|
|
||||||
.speakerId("SPK-001")
|
|
||||||
.confidence(0.95)
|
|
||||||
.isNewSpeaker(false)
|
|
||||||
.build());
|
|
||||||
|
|
||||||
when(speakerService.getRecordingSpeakers(anyString()))
|
|
||||||
.thenReturn(SpeakerDto.ListResponse.builder()
|
|
||||||
.recordingId("REC-20250123-001")
|
|
||||||
.speakerCount(3)
|
|
||||||
.build());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -189,21 +160,7 @@ class SttApiIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.data.text").exists())
|
.andExpect(jsonPath("$.data.text").exists())
|
||||||
.andExpect(jsonPath("$.data.confidence").exists());
|
.andExpect(jsonPath("$.data.confidence").exists());
|
||||||
|
|
||||||
// 4단계: 화자 식별
|
// 4단계: 녹음 중지
|
||||||
SpeakerDto.IdentifyRequest identifyRequest = SpeakerDto.IdentifyRequest.builder()
|
|
||||||
.recordingId(recordingId)
|
|
||||||
.audioFrame("dGVzdCBhdWRpbyBmcmFtZQ==") // base64 encoded "test audio frame"
|
|
||||||
.build();
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/stt/speakers/identify")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(identifyRequest)))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
|
||||||
.andExpect(jsonPath("$.data.speakerId").exists())
|
|
||||||
.andExpect(jsonPath("$.data.confidence").exists());
|
|
||||||
|
|
||||||
// 5단계: 녹음 중지
|
|
||||||
RecordingDto.StopRequest stopRequest = RecordingDto.StopRequest.builder()
|
RecordingDto.StopRequest stopRequest = RecordingDto.StopRequest.builder()
|
||||||
.stoppedBy("integration-test-user")
|
.stoppedBy("integration-test-user")
|
||||||
.build();
|
.build();
|
||||||
@ -216,27 +173,20 @@ class SttApiIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.data.status").value("STOPPED"))
|
.andExpect(jsonPath("$.data.status").value("STOPPED"))
|
||||||
.andExpect(jsonPath("$.data.duration").exists());
|
.andExpect(jsonPath("$.data.duration").exists());
|
||||||
|
|
||||||
// 6단계: 녹음 정보 조회
|
// 5단계: 녹음 정보 조회
|
||||||
mockMvc.perform(get("/api/v1/stt/recordings/{recordingId}", recordingId))
|
mockMvc.perform(get("/api/v1/stt/recordings/{recordingId}", recordingId))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
|
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
|
||||||
.andExpect(jsonPath("$.data.status").value("STOPPED"));
|
.andExpect(jsonPath("$.data.status").value("STOPPED"));
|
||||||
|
|
||||||
// 7단계: 변환 결과 조회 (세그먼트 포함)
|
// 6단계: 변환 결과 조회 (세그먼트 포함)
|
||||||
mockMvc.perform(get("/api/v1/stt/transcription/{recordingId}", recordingId)
|
mockMvc.perform(get("/api/v1/stt/transcription/{recordingId}", recordingId)
|
||||||
.param("includeSegments", "true"))
|
.param("includeSegments", "true"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
|
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
|
||||||
.andExpect(jsonPath("$.data.fullText").exists());
|
.andExpect(jsonPath("$.data.fullText").exists());
|
||||||
|
|
||||||
// 8단계: 녹음별 화자 목록 조회
|
|
||||||
mockMvc.perform(get("/api/v1/stt/speakers/recordings/{recordingId}", recordingId))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
|
||||||
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
|
|
||||||
.andExpect(jsonPath("$.data.speakerCount").exists());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -248,7 +198,7 @@ class SttApiIntegrationTest {
|
|||||||
com.unicorn.hgzero.common.exception.ErrorCode.ENTITY_NOT_FOUND,
|
com.unicorn.hgzero.common.exception.ErrorCode.ENTITY_NOT_FOUND,
|
||||||
"녹음을 찾을 수 없습니다"));
|
"녹음을 찾을 수 없습니다"));
|
||||||
|
|
||||||
when(transcriptionService.getTranscription(eq("NONEXISTENT-001"), any(), any()))
|
when(transcriptionService.getTranscription(eq("NONEXISTENT-001")))
|
||||||
.thenThrow(new com.unicorn.hgzero.common.exception.BusinessException(
|
.thenThrow(new com.unicorn.hgzero.common.exception.BusinessException(
|
||||||
com.unicorn.hgzero.common.exception.ErrorCode.ENTITY_NOT_FOUND,
|
com.unicorn.hgzero.common.exception.ErrorCode.ENTITY_NOT_FOUND,
|
||||||
"변환 결과를 찾을 수 없습니다"));
|
"변환 결과를 찾을 수 없습니다"));
|
||||||
|
|||||||
@ -54,9 +54,7 @@ class RecordingServiceTest {
|
|||||||
.sessionId("SESSION-001")
|
.sessionId("SESSION-001")
|
||||||
.status(Recording.RecordingStatus.READY)
|
.status(Recording.RecordingStatus.READY)
|
||||||
.language("ko-KR")
|
.language("ko-KR")
|
||||||
.speakerCount(0)
|
|
||||||
.segmentCount(0)
|
.segmentCount(0)
|
||||||
.storagePath("recordings/MEETING-001/SESSION-001.wav")
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,7 +172,6 @@ class RecordingServiceTest {
|
|||||||
assertThat(response.getRecordingId()).isEqualTo(recordingId);
|
assertThat(response.getRecordingId()).isEqualTo(recordingId);
|
||||||
assertThat(response.getStatus()).isEqualTo("STOPPED");
|
assertThat(response.getStatus()).isEqualTo("STOPPED");
|
||||||
assertThat(response.getDuration()).isEqualTo(1800);
|
assertThat(response.getDuration()).isEqualTo(1800);
|
||||||
assertThat(response.getFileSize()).isEqualTo(172800000L);
|
|
||||||
|
|
||||||
verify(recordingRepository).findById(recordingId);
|
verify(recordingRepository).findById(recordingId);
|
||||||
verify(recordingRepository).save(any(RecordingEntity.class));
|
verify(recordingRepository).save(any(RecordingEntity.class));
|
||||||
|
|||||||
@ -149,85 +149,6 @@ class TranscriptionServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("배치 음성 변환 작업 시작 성공")
|
|
||||||
void transcribeAudioBatch_Success() {
|
|
||||||
// Given
|
|
||||||
TranscriptionDto.BatchRequest batchRequest = TranscriptionDto.BatchRequest.builder()
|
|
||||||
.recordingId("REC-20250123-001")
|
|
||||||
.callbackUrl("https://api.example.com/callback")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
MockMultipartFile audioFile = new MockMultipartFile(
|
|
||||||
"audioFile", "test.wav", "audio/wav", "test audio content".getBytes()
|
|
||||||
);
|
|
||||||
|
|
||||||
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
|
|
||||||
|
|
||||||
// When
|
|
||||||
TranscriptionDto.BatchResponse response = transcriptionService.transcribeAudioBatch(batchRequest, audioFile);
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(response).isNotNull();
|
|
||||||
assertThat(response.getRecordingId()).isEqualTo("REC-20250123-001");
|
|
||||||
assertThat(response.getStatus()).isEqualTo("PROCESSING");
|
|
||||||
assertThat(response.getJobId()).isNotEmpty();
|
|
||||||
assertThat(response.getCallbackUrl()).isEqualTo("https://api.example.com/callback");
|
|
||||||
assertThat(response.getEstimatedCompletionTime()).isAfter(LocalDateTime.now());
|
|
||||||
|
|
||||||
verify(recordingRepository).findById("REC-20250123-001");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("배치 변환 완료 콜백 처리 성공")
|
|
||||||
void processBatchCallback_Success() {
|
|
||||||
// Given
|
|
||||||
List<TranscriptSegmentDto.Detail> segments = List.of(
|
|
||||||
TranscriptSegmentDto.Detail.builder()
|
|
||||||
.transcriptId("TRS-001")
|
|
||||||
.text("안녕하세요")
|
|
||||||
.speakerId("SPK-001")
|
|
||||||
.speakerName("화자-001")
|
|
||||||
.timestamp(System.currentTimeMillis())
|
|
||||||
.duration(2.5)
|
|
||||||
.confidence(0.95)
|
|
||||||
.build(),
|
|
||||||
TranscriptSegmentDto.Detail.builder()
|
|
||||||
.transcriptId("TRS-002")
|
|
||||||
.text("회의를 시작하겠습니다")
|
|
||||||
.speakerId("SPK-002")
|
|
||||||
.speakerName("화자-002")
|
|
||||||
.timestamp(System.currentTimeMillis() + 3000)
|
|
||||||
.duration(3.2)
|
|
||||||
.confidence(0.92)
|
|
||||||
.build()
|
|
||||||
);
|
|
||||||
|
|
||||||
TranscriptionDto.BatchCallbackRequest callbackRequest = TranscriptionDto.BatchCallbackRequest.builder()
|
|
||||||
.jobId("JOB-20250123-001")
|
|
||||||
.status("COMPLETED")
|
|
||||||
.segments(segments)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(segmentEntity);
|
|
||||||
when(transcriptionRepository.save(any(TranscriptionEntity.class))).thenReturn(transcriptionEntity);
|
|
||||||
|
|
||||||
// When
|
|
||||||
TranscriptionDto.CompleteResponse response = transcriptionService.processBatchCallback(callbackRequest);
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(response).isNotNull();
|
|
||||||
assertThat(response.getJobId()).isEqualTo("JOB-20250123-001");
|
|
||||||
assertThat(response.getStatus()).isEqualTo("COMPLETED");
|
|
||||||
assertThat(response.getSegmentCount()).isEqualTo(2);
|
|
||||||
assertThat(response.getTotalDuration()).isEqualTo(5); // 2.5 + 3.2 반올림
|
|
||||||
assertThat(response.getAverageConfidence()).isEqualTo(0.935); // (0.95 + 0.92) / 2
|
|
||||||
|
|
||||||
verify(segmentRepository, times(2)).save(any(TranscriptSegmentEntity.class));
|
|
||||||
verify(transcriptionRepository).save(any(TranscriptionEntity.class));
|
|
||||||
verify(eventPublisher).publishAsync(eq("transcription-events"), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("변환 결과 조회 성공")
|
@DisplayName("변환 결과 조회 성공")
|
||||||
void getTranscription_Success() {
|
void getTranscription_Success() {
|
||||||
@ -241,14 +162,13 @@ class TranscriptionServiceTest {
|
|||||||
.segmentCount(2)
|
.segmentCount(2)
|
||||||
.totalDuration(300)
|
.totalDuration(300)
|
||||||
.averageConfidence(0.92)
|
.averageConfidence(0.92)
|
||||||
.speakerCount(2)
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
when(transcriptionRepository.findByRecordingId(recordingId))
|
when(transcriptionRepository.findByRecordingId(recordingId))
|
||||||
.thenReturn(Optional.of(transcriptionEntity));
|
.thenReturn(Optional.of(transcriptionEntity));
|
||||||
|
|
||||||
// When
|
// When
|
||||||
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId, false, null);
|
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertThat(response).isNotNull();
|
assertThat(response).isNotNull();
|
||||||
@ -257,7 +177,6 @@ class TranscriptionServiceTest {
|
|||||||
assertThat(response.getSegmentCount()).isEqualTo(2);
|
assertThat(response.getSegmentCount()).isEqualTo(2);
|
||||||
assertThat(response.getTotalDuration()).isEqualTo(300);
|
assertThat(response.getTotalDuration()).isEqualTo(300);
|
||||||
assertThat(response.getAverageConfidence()).isEqualTo(0.92);
|
assertThat(response.getAverageConfidence()).isEqualTo(0.92);
|
||||||
assertThat(response.getSpeakerCount()).isEqualTo(2);
|
|
||||||
assertThat(response.getSegments()).isNull(); // includeSegments = false
|
assertThat(response.getSegments()).isNull(); // includeSegments = false
|
||||||
|
|
||||||
verify(transcriptionRepository).findByRecordingId(recordingId);
|
verify(transcriptionRepository).findByRecordingId(recordingId);
|
||||||
@ -276,7 +195,6 @@ class TranscriptionServiceTest {
|
|||||||
.segmentCount(2)
|
.segmentCount(2)
|
||||||
.totalDuration(300)
|
.totalDuration(300)
|
||||||
.averageConfidence(0.92)
|
.averageConfidence(0.92)
|
||||||
.speakerCount(2)
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
List<TranscriptSegmentEntity> segmentEntities = List.of(
|
List<TranscriptSegmentEntity> segmentEntities = List.of(
|
||||||
@ -298,16 +216,13 @@ class TranscriptionServiceTest {
|
|||||||
.thenReturn(segmentEntities);
|
.thenReturn(segmentEntities);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId, true, null);
|
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertThat(response).isNotNull();
|
assertThat(response).isNotNull();
|
||||||
assertThat(response.getSegments()).isNotNull();
|
assertThat(response.getSegments()).isNull(); // 기본 동작에서는 세그먼트 미포함
|
||||||
assertThat(response.getSegments()).hasSize(1);
|
|
||||||
assertThat(response.getSegments().get(0).getText()).isEqualTo("안녕하세요");
|
|
||||||
|
|
||||||
verify(transcriptionRepository).findByRecordingId(recordingId);
|
verify(transcriptionRepository).findByRecordingId(recordingId);
|
||||||
verify(segmentRepository).findByRecordingIdOrderByTimestamp(recordingId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -319,7 +234,7 @@ class TranscriptionServiceTest {
|
|||||||
when(transcriptionRepository.findByRecordingId(recordingId)).thenReturn(Optional.empty());
|
when(transcriptionRepository.findByRecordingId(recordingId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
// When & Then
|
// When & Then
|
||||||
assertThatThrownBy(() -> transcriptionService.getTranscription(recordingId, false, null))
|
assertThatThrownBy(() -> transcriptionService.getTranscription(recordingId))
|
||||||
.isInstanceOf(BusinessException.class)
|
.isInstanceOf(BusinessException.class)
|
||||||
.hasMessageContaining("변환 결과를 찾을 수 없습니다");
|
.hasMessageContaining("변환 결과를 찾을 수 없습니다");
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user