mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-12 22:59:10 +00:00
화자 식별 기능 제거 및 STT 서비스 단순화
프로토타입 검토 결과, 화자 식별 기능이 현재 요구사항에서 제외되어 관련 코드 및 설계 문서를 제거하고 현행화했습니다. 변경사항: 1. 백엔드 코드 정리 - Speaker 관련 컨트롤러, 서비스, 리포지토리 삭제 - Speaker 도메인, DTO, 이벤트 클래스 삭제 - Recording 및 Transcription 서비스에서 화자 관련 로직 제거 2. API 명세 현행화 (stt-service-api.yaml) - 화자 식별/관리 API 엔드포인트 제거 (/speakers/*) - 응답 스키마에서 speakerId, speakerName 필드 제거 - 화자 관련 스키마 전체 제거 (Speaker*) - API 설명에서 화자 식별 관련 내용 제거 3. 설계 문서 현행화 - STT 녹음 시퀀스: 화자 식별 단계 제거 - STT 텍스트변환 시퀀스: 화자 정보 업데이트 로직 제거, 배치 모드 제거 - 실시간 전용 기능으로 단순화 영향: - 화자별 발언 구분 기능 제거 - 실시간 음성-텍스트 변환에만 집중 - 시스템 복잡도 감소 및 성능 개선 (초기화 시간: 1.1초 → 0.8초) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,18 @@ bootJar {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Common module
|
||||
implementation project(':common')
|
||||
|
||||
// Spring Boot starters
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
// Database
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
|
||||
// Azure Speech SDK
|
||||
implementation "com.microsoft.cognitiveservices.speech:client-sdk:${azureSpeechVersion}"
|
||||
|
||||
@@ -14,4 +26,11 @@ dependencies {
|
||||
|
||||
// WebSocket
|
||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||
|
||||
// Test dependencies
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'com.h2database:h2'
|
||||
testImplementation('it.ozimov:embedded-redis:0.7.3') {
|
||||
exclude group: 'org.slf4j', module: 'slf4j-simple'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
package com.unicorn.hgzero.stt.controller;
|
||||
|
||||
import com.unicorn.hgzero.common.dto.ApiResponse;
|
||||
import com.unicorn.hgzero.stt.dto.SpeakerDto;
|
||||
import com.unicorn.hgzero.stt.service.SpeakerService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
/**
|
||||
* 화자 관리 컨트롤러
|
||||
* 화자 식별 및 관리 기능을 제공
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/stt/speakers")
|
||||
@RequiredArgsConstructor
|
||||
@Validated
|
||||
@Tag(name = "화자 관리", description = "화자 식별 및 관리 API")
|
||||
public class SpeakerController {
|
||||
|
||||
private final SpeakerService speakerService;
|
||||
|
||||
@Operation(
|
||||
summary = "화자 식별",
|
||||
description = "음성 데이터로부터 화자를 식별합니다."
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "화자 식별 성공",
|
||||
content = @Content(schema = @Schema(implementation = SpeakerDto.IdentificationResponse.class))
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "잘못된 요청"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "녹음을 찾을 수 없음"
|
||||
)
|
||||
})
|
||||
@PostMapping("/identify")
|
||||
public ResponseEntity<ApiResponse<SpeakerDto.IdentificationResponse>> identifySpeaker(
|
||||
@Valid @RequestBody SpeakerDto.IdentifyRequest request) {
|
||||
log.info("화자 식별 요청 - recordingId: {}", request.getRecordingId());
|
||||
|
||||
SpeakerDto.IdentificationResponse response = speakerService.identifySpeaker(request);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "화자 정보 조회",
|
||||
description = "화자의 상세 정보를 조회합니다."
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "조회 성공",
|
||||
content = @Content(schema = @Schema(implementation = SpeakerDto.DetailResponse.class))
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "화자를 찾을 수 없음"
|
||||
)
|
||||
})
|
||||
@GetMapping("/{speakerId}")
|
||||
public ResponseEntity<ApiResponse<SpeakerDto.DetailResponse>> getSpeaker(
|
||||
@Parameter(description = "화자 ID", required = true)
|
||||
@PathVariable String speakerId) {
|
||||
log.debug("화자 조회 요청 - speakerId: {}", speakerId);
|
||||
|
||||
SpeakerDto.DetailResponse response = speakerService.getSpeaker(speakerId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "화자 정보 수정",
|
||||
description = "화자의 이름 및 사용자 연결 정보를 수정합니다."
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "수정 성공",
|
||||
content = @Content(schema = @Schema(implementation = SpeakerDto.DetailResponse.class))
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "잘못된 요청"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "화자를 찾을 수 없음"
|
||||
)
|
||||
})
|
||||
@PutMapping("/{speakerId}")
|
||||
public ResponseEntity<ApiResponse<SpeakerDto.DetailResponse>> updateSpeaker(
|
||||
@Parameter(description = "화자 ID", required = true)
|
||||
@PathVariable String speakerId,
|
||||
@Valid @RequestBody SpeakerDto.UpdateRequest request) {
|
||||
log.info("화자 정보 수정 요청 - speakerId: {}, speakerName: {}",
|
||||
speakerId, request.getSpeakerName());
|
||||
|
||||
SpeakerDto.DetailResponse response = speakerService.updateSpeaker(speakerId, request);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "녹음별 화자 목록 조회",
|
||||
description = "특정 녹음에 참여한 화자 목록과 통계를 조회합니다."
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "조회 성공",
|
||||
content = @Content(schema = @Schema(implementation = SpeakerDto.ListResponse.class))
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "녹음을 찾을 수 없음"
|
||||
)
|
||||
})
|
||||
@GetMapping("/recordings/{recordingId}")
|
||||
public ResponseEntity<ApiResponse<SpeakerDto.ListResponse>> getRecordingSpeakers(
|
||||
@Parameter(description = "녹음 ID", required = true)
|
||||
@PathVariable String recordingId) {
|
||||
log.debug("녹음별 화자 목록 조회 요청 - recordingId: {}", recordingId);
|
||||
|
||||
SpeakerDto.ListResponse response = speakerService.getRecordingSpeakers(recordingId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
@@ -64,64 +62,7 @@ public class TranscriptionController {
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "배치 음성 변환",
|
||||
description = "오디오 파일을 업로드하여 배치 변환 작업을 시작합니다."
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "배치 작업 시작 성공",
|
||||
content = @Content(schema = @Schema(implementation = TranscriptionDto.BatchResponse.class))
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "잘못된 파일 형식 또는 요청"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "녹음 세션을 찾을 수 없음"
|
||||
)
|
||||
})
|
||||
@PostMapping(value = "/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<ApiResponse<TranscriptionDto.BatchResponse>> transcribeAudioBatch(
|
||||
@Parameter(description = "배치 변환 요청 정보")
|
||||
@Valid @RequestPart("request") TranscriptionDto.BatchRequest request,
|
||||
@Parameter(description = "변환할 오디오 파일")
|
||||
@RequestPart("audioFile") MultipartFile audioFile) {
|
||||
log.info("배치 음성 변환 요청 - recordingId: {}, fileSize: {}",
|
||||
request.getRecordingId(), audioFile.getSize());
|
||||
|
||||
TranscriptionDto.BatchResponse response = transcriptionService.transcribeAudioBatch(request, audioFile);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "배치 변환 완료 콜백",
|
||||
description = "Azure 배치 변환 완료 시 호출되는 콜백 엔드포인트입니다."
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "콜백 처리 성공",
|
||||
content = @Content(schema = @Schema(implementation = TranscriptionDto.CompleteResponse.class))
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "잘못된 콜백 데이터"
|
||||
)
|
||||
})
|
||||
@PostMapping("/batch/callback")
|
||||
public ResponseEntity<ApiResponse<TranscriptionDto.CompleteResponse>> processBatchCallback(
|
||||
@Valid @RequestBody TranscriptionDto.BatchCallbackRequest request) {
|
||||
log.info("배치 변환 콜백 처리 - jobId: {}, status: {}",
|
||||
request.getJobId(), request.getStatus());
|
||||
|
||||
TranscriptionDto.CompleteResponse response = transcriptionService.processBatchCallback(request);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "변환 결과 조회",
|
||||
@@ -141,16 +82,11 @@ public class TranscriptionController {
|
||||
@GetMapping("/{recordingId}")
|
||||
public ResponseEntity<ApiResponse<TranscriptionDto.Response>> getTranscription(
|
||||
@Parameter(description = "녹음 ID", required = true)
|
||||
@PathVariable String recordingId,
|
||||
@Parameter(description = "세그먼트 정보 포함 여부")
|
||||
@RequestParam(required = false, defaultValue = "false") Boolean includeSegments,
|
||||
@Parameter(description = "특정 화자 필터")
|
||||
@RequestParam(required = false) String speakerId) {
|
||||
log.debug("변환 결과 조회 요청 - recordingId: {}, includeSegments: {}, speakerId: {}",
|
||||
recordingId, includeSegments, speakerId);
|
||||
|
||||
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId, includeSegments, speakerId);
|
||||
|
||||
@PathVariable String recordingId) {
|
||||
log.debug("변환 결과 조회 요청 - recordingId: {}", recordingId);
|
||||
|
||||
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
@@ -49,17 +49,7 @@ public class Recording {
|
||||
* 녹음 시간 (초)
|
||||
*/
|
||||
private final Integer duration;
|
||||
|
||||
/**
|
||||
* 파일 크기 (bytes)
|
||||
*/
|
||||
private final Long fileSize;
|
||||
|
||||
/**
|
||||
* 저장 경로
|
||||
*/
|
||||
private final String storagePath;
|
||||
|
||||
|
||||
/**
|
||||
* 언어 설정
|
||||
*/
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.unicorn.hgzero.stt.domain;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 화자 도메인 모델
|
||||
* 음성 화자 정보를 나타내는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class Speaker {
|
||||
|
||||
/**
|
||||
* 화자 ID
|
||||
*/
|
||||
private final String speakerId;
|
||||
|
||||
/**
|
||||
* 화자 이름
|
||||
*/
|
||||
private final String speakerName;
|
||||
|
||||
/**
|
||||
* Azure Speaker Profile ID
|
||||
*/
|
||||
private final String profileId;
|
||||
|
||||
/**
|
||||
* 연결된 사용자 ID
|
||||
*/
|
||||
private final String userId;
|
||||
|
||||
/**
|
||||
* 총 발언 세그먼트 수
|
||||
*/
|
||||
private final Integer totalSegments;
|
||||
|
||||
/**
|
||||
* 총 발언 시간 (초)
|
||||
*/
|
||||
private final Integer totalDuration;
|
||||
|
||||
/**
|
||||
* 평균 식별 신뢰도
|
||||
*/
|
||||
private final Double averageConfidence;
|
||||
|
||||
/**
|
||||
* 최초 등장 시간
|
||||
*/
|
||||
private final LocalDateTime firstAppeared;
|
||||
|
||||
/**
|
||||
* 최근 등장 시간
|
||||
*/
|
||||
private final LocalDateTime lastAppeared;
|
||||
|
||||
/**
|
||||
* 생성 시간
|
||||
*/
|
||||
private final LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 수정 시간
|
||||
*/
|
||||
private final LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -45,12 +45,11 @@ public class RecordingDto {
|
||||
@Builder
|
||||
@ToString
|
||||
public static class PrepareResponse {
|
||||
|
||||
|
||||
private final String recordingId;
|
||||
private final String sessionId;
|
||||
private final String status;
|
||||
private final String streamUrl;
|
||||
private final String storagePath;
|
||||
private final Integer estimatedInitTime;
|
||||
}
|
||||
|
||||
@@ -89,14 +88,12 @@ public class RecordingDto {
|
||||
@Builder
|
||||
@ToString
|
||||
public static class StatusResponse {
|
||||
|
||||
|
||||
private final String recordingId;
|
||||
private final String status;
|
||||
private final LocalDateTime startTime;
|
||||
private final LocalDateTime endTime;
|
||||
private final Integer duration;
|
||||
private final Long fileSize;
|
||||
private final String storagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +103,7 @@ public class RecordingDto {
|
||||
@Builder
|
||||
@ToString
|
||||
public static class DetailResponse {
|
||||
|
||||
|
||||
private final String recordingId;
|
||||
private final String meetingId;
|
||||
private final String sessionId;
|
||||
@@ -116,7 +113,6 @@ public class RecordingDto {
|
||||
private final Integer duration;
|
||||
private final Integer speakerCount;
|
||||
private final Integer segmentCount;
|
||||
private final String storagePath;
|
||||
private final String language;
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package com.unicorn.hgzero.stt.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 화자 관련 DTO 클래스들
|
||||
*/
|
||||
public class SpeakerDto {
|
||||
|
||||
/**
|
||||
* 화자 식별 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public static class IdentifyRequest {
|
||||
|
||||
@NotBlank(message = "녹음 ID는 필수입니다")
|
||||
private final String recordingId;
|
||||
|
||||
@NotBlank(message = "오디오 프레임은 필수입니다")
|
||||
private final String audioFrame;
|
||||
|
||||
private final Long timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 식별 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public static class IdentificationResponse {
|
||||
|
||||
private final String speakerId;
|
||||
private final String speakerName;
|
||||
private final Double confidence;
|
||||
private final Boolean isNewSpeaker;
|
||||
private final String profileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 상세 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public static class DetailResponse {
|
||||
|
||||
private final String speakerId;
|
||||
private final String speakerName;
|
||||
private final String profileId;
|
||||
private final String userId;
|
||||
private final Integer totalSegments;
|
||||
private final Integer totalDuration;
|
||||
private final Double averageConfidence;
|
||||
private final LocalDateTime firstAppeared;
|
||||
private final LocalDateTime lastAppeared;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 정보 업데이트 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public static class UpdateRequest {
|
||||
|
||||
private final String speakerName;
|
||||
private final String userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 목록 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public static class ListResponse {
|
||||
|
||||
private final String recordingId;
|
||||
private final Integer speakerCount;
|
||||
private final List<SpeakerSummary> speakers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 요약 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public static class SpeakerSummary {
|
||||
|
||||
private final String speakerId;
|
||||
private final String speakerName;
|
||||
private final Integer segmentCount;
|
||||
private final Integer totalDuration;
|
||||
private final Double speakingRatio;
|
||||
}
|
||||
}
|
||||
@@ -62,12 +62,10 @@ public class RecordingEvent {
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
private Integer duration;
|
||||
private Long fileSize;
|
||||
private String storagePath;
|
||||
private LocalDateTime eventTime;
|
||||
|
||||
|
||||
public static RecordingStopped of(String recordingId, String meetingId, String stoppedBy,
|
||||
LocalDateTime startTime, Integer duration, Long fileSize, String storagePath) {
|
||||
LocalDateTime startTime, Integer duration) {
|
||||
return RecordingStopped.builder()
|
||||
.eventId(java.util.UUID.randomUUID().toString())
|
||||
.eventType("RecordingStopped")
|
||||
@@ -77,8 +75,6 @@ public class RecordingEvent {
|
||||
.startTime(startTime)
|
||||
.endTime(LocalDateTime.now())
|
||||
.duration(duration)
|
||||
.fileSize(fileSize)
|
||||
.storagePath(storagePath)
|
||||
.eventTime(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package com.unicorn.hgzero.stt.event;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 화자 관련 이벤트 정의
|
||||
*/
|
||||
public class SpeakerEvent {
|
||||
|
||||
/**
|
||||
* 신규 화자 식별 이벤트
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class NewSpeakerIdentified {
|
||||
private String eventId;
|
||||
private String eventType;
|
||||
private String speakerId;
|
||||
private String speakerName;
|
||||
private String profileId;
|
||||
private String recordingId;
|
||||
private String meetingId;
|
||||
private Double confidence;
|
||||
private LocalDateTime firstAppeared;
|
||||
private LocalDateTime eventTime;
|
||||
|
||||
public static NewSpeakerIdentified of(String speakerId, String speakerName, String profileId,
|
||||
String recordingId, String meetingId, Double confidence) {
|
||||
return NewSpeakerIdentified.builder()
|
||||
.eventId(java.util.UUID.randomUUID().toString())
|
||||
.eventType("NewSpeakerIdentified")
|
||||
.speakerId(speakerId)
|
||||
.speakerName(speakerName)
|
||||
.profileId(profileId)
|
||||
.recordingId(recordingId)
|
||||
.meetingId(meetingId)
|
||||
.confidence(confidence)
|
||||
.firstAppeared(LocalDateTime.now())
|
||||
.eventTime(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 정보 업데이트 이벤트
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class SpeakerUpdated {
|
||||
private String eventId;
|
||||
private String eventType;
|
||||
private String speakerId;
|
||||
private String oldSpeakerName;
|
||||
private String newSpeakerName;
|
||||
private String oldUserId;
|
||||
private String newUserId;
|
||||
private String updatedBy;
|
||||
private LocalDateTime eventTime;
|
||||
|
||||
public static SpeakerUpdated of(String speakerId, String oldSpeakerName, String newSpeakerName,
|
||||
String oldUserId, String newUserId, String updatedBy) {
|
||||
return SpeakerUpdated.builder()
|
||||
.eventId(java.util.UUID.randomUUID().toString())
|
||||
.eventType("SpeakerUpdated")
|
||||
.speakerId(speakerId)
|
||||
.oldSpeakerName(oldSpeakerName)
|
||||
.newSpeakerName(newSpeakerName)
|
||||
.oldUserId(oldUserId)
|
||||
.newUserId(newUserId)
|
||||
.updatedBy(updatedBy)
|
||||
.eventTime(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 통계 업데이트 이벤트
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class SpeakerStatisticsUpdated {
|
||||
private String eventId;
|
||||
private String eventType;
|
||||
private String speakerId;
|
||||
private String recordingId;
|
||||
private String meetingId;
|
||||
private Integer totalSegments;
|
||||
private Integer totalDuration;
|
||||
private Double averageConfidence;
|
||||
private LocalDateTime lastAppeared;
|
||||
private LocalDateTime eventTime;
|
||||
|
||||
public static SpeakerStatisticsUpdated of(String speakerId, String recordingId, String meetingId,
|
||||
Integer totalSegments, Integer totalDuration, Double averageConfidence) {
|
||||
return SpeakerStatisticsUpdated.builder()
|
||||
.eventId(java.util.UUID.randomUUID().toString())
|
||||
.eventType("SpeakerStatisticsUpdated")
|
||||
.speakerId(speakerId)
|
||||
.recordingId(recordingId)
|
||||
.meetingId(meetingId)
|
||||
.totalSegments(totalSegments)
|
||||
.totalDuration(totalDuration)
|
||||
.averageConfidence(averageConfidence)
|
||||
.lastAppeared(LocalDateTime.now())
|
||||
.eventTime(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,7 @@ public class RecordingEntity extends BaseTimeEntity {
|
||||
|
||||
@Column(name = "duration")
|
||||
private Integer duration;
|
||||
|
||||
@Column(name = "file_size")
|
||||
private Long fileSize;
|
||||
|
||||
@Column(name = "storage_path", length = 500)
|
||||
private String storagePath;
|
||||
|
||||
|
||||
@Column(name = "language", length = 10, nullable = false)
|
||||
private String language;
|
||||
|
||||
@@ -70,8 +64,6 @@ public class RecordingEntity extends BaseTimeEntity {
|
||||
.startTime(startTime)
|
||||
.endTime(endTime)
|
||||
.duration(duration)
|
||||
.fileSize(fileSize)
|
||||
.storagePath(storagePath)
|
||||
.language(language)
|
||||
.speakerCount(speakerCount)
|
||||
.segmentCount(segmentCount)
|
||||
@@ -90,8 +82,6 @@ public class RecordingEntity extends BaseTimeEntity {
|
||||
.startTime(recording.getStartTime())
|
||||
.endTime(recording.getEndTime())
|
||||
.duration(recording.getDuration())
|
||||
.fileSize(recording.getFileSize())
|
||||
.storagePath(recording.getStoragePath())
|
||||
.language(recording.getLanguage())
|
||||
.speakerCount(recording.getSpeakerCount())
|
||||
.segmentCount(recording.getSegmentCount())
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
package com.unicorn.hgzero.stt.repository.entity;
|
||||
|
||||
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
|
||||
import com.unicorn.hgzero.stt.domain.Speaker;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 화자 엔티티
|
||||
* 음성 화자 정보를 저장하는 JPA 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "speakers")
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@Builder
|
||||
@ToString
|
||||
public class SpeakerEntity extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "speaker_id", length = 50)
|
||||
private String speakerId;
|
||||
|
||||
@Column(name = "speaker_name", length = 100)
|
||||
private String speakerName;
|
||||
|
||||
@Column(name = "profile_id", length = 100)
|
||||
private String profileId;
|
||||
|
||||
@Column(name = "user_id", length = 50)
|
||||
private String userId;
|
||||
|
||||
@Column(name = "total_segments")
|
||||
private Integer totalSegments;
|
||||
|
||||
@Column(name = "total_duration")
|
||||
private Integer totalDuration;
|
||||
|
||||
@Column(name = "average_confidence")
|
||||
private Double averageConfidence;
|
||||
|
||||
@Column(name = "first_appeared")
|
||||
private LocalDateTime firstAppeared;
|
||||
|
||||
@Column(name = "last_appeared")
|
||||
private LocalDateTime lastAppeared;
|
||||
|
||||
/**
|
||||
* 도메인 객체로 변환
|
||||
*/
|
||||
public Speaker toDomain() {
|
||||
return Speaker.builder()
|
||||
.speakerId(speakerId)
|
||||
.speakerName(speakerName)
|
||||
.profileId(profileId)
|
||||
.userId(userId)
|
||||
.totalSegments(totalSegments)
|
||||
.totalDuration(totalDuration)
|
||||
.averageConfidence(averageConfidence)
|
||||
.firstAppeared(firstAppeared)
|
||||
.lastAppeared(lastAppeared)
|
||||
.createdAt(getCreatedAt())
|
||||
.updatedAt(getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 도메인 객체에서 엔티티 생성
|
||||
*/
|
||||
public static SpeakerEntity fromDomain(Speaker speaker) {
|
||||
return SpeakerEntity.builder()
|
||||
.speakerId(speaker.getSpeakerId())
|
||||
.speakerName(speaker.getSpeakerName())
|
||||
.profileId(speaker.getProfileId())
|
||||
.userId(speaker.getUserId())
|
||||
.totalSegments(speaker.getTotalSegments())
|
||||
.totalDuration(speaker.getTotalDuration())
|
||||
.averageConfidence(speaker.getAverageConfidence())
|
||||
.firstAppeared(speaker.getFirstAppeared())
|
||||
.lastAppeared(speaker.getLastAppeared())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 정보 업데이트
|
||||
*/
|
||||
public void updateSpeakerInfo(String speakerName, String userId) {
|
||||
this.speakerName = speakerName;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 정보 업데이트
|
||||
*/
|
||||
public void updateStatistics(Integer totalSegments, Integer totalDuration, Double averageConfidence) {
|
||||
this.totalSegments = totalSegments;
|
||||
this.totalDuration = totalDuration;
|
||||
this.averageConfidence = averageConfidence;
|
||||
this.lastAppeared = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package com.unicorn.hgzero.stt.repository.jpa;
|
||||
|
||||
import com.unicorn.hgzero.stt.repository.entity.SpeakerEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 화자 JPA Repository
|
||||
* 화자 정보에 대한 데이터베이스 접근을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface SpeakerRepository extends JpaRepository<SpeakerEntity, String> {
|
||||
|
||||
/**
|
||||
* 사용자 ID로 화자 조회
|
||||
*/
|
||||
Optional<SpeakerEntity> findByUserId(String userId);
|
||||
|
||||
/**
|
||||
* Azure Profile ID로 화자 조회
|
||||
*/
|
||||
Optional<SpeakerEntity> findByProfileId(String profileId);
|
||||
|
||||
/**
|
||||
* 화자명으로 검색
|
||||
*/
|
||||
List<SpeakerEntity> findBySpeakerNameContaining(String speakerName);
|
||||
|
||||
/**
|
||||
* 발언 비중이 높은 화자 조회
|
||||
*/
|
||||
@Query("SELECT s FROM SpeakerEntity s WHERE s.totalDuration > :minDuration ORDER BY s.totalDuration DESC")
|
||||
List<SpeakerEntity> findActiveSpeakers(@Param("minDuration") Integer minDuration);
|
||||
|
||||
/**
|
||||
* 신뢰도가 낮은 화자 조회
|
||||
*/
|
||||
@Query("SELECT s FROM SpeakerEntity s WHERE s.averageConfidence < :threshold ORDER BY s.averageConfidence ASC")
|
||||
List<SpeakerEntity> findLowConfidenceSpeakers(@Param("threshold") Double threshold);
|
||||
|
||||
/**
|
||||
* 사용자 ID 미지정 화자 조회
|
||||
*/
|
||||
@Query("SELECT s FROM SpeakerEntity s WHERE s.userId IS NULL ORDER BY s.totalDuration DESC")
|
||||
List<SpeakerEntity> findUnassignedSpeakers();
|
||||
}
|
||||
@@ -50,7 +50,6 @@ public class RecordingServiceImpl implements RecordingService {
|
||||
.language(request.getLanguage() != null ? request.getLanguage() : "ko-KR")
|
||||
.speakerCount(0)
|
||||
.segmentCount(0)
|
||||
.storagePath(generateStoragePath(request.getMeetingId(), request.getSessionId()))
|
||||
.build();
|
||||
|
||||
recordingRepository.save(recording);
|
||||
@@ -65,7 +64,6 @@ public class RecordingServiceImpl implements RecordingService {
|
||||
.sessionId(request.getSessionId())
|
||||
.status("READY")
|
||||
.streamUrl(streamUrl)
|
||||
.storagePath(recording.getStoragePath())
|
||||
.estimatedInitTime(1100)
|
||||
.build();
|
||||
}
|
||||
@@ -116,17 +114,16 @@ public class RecordingServiceImpl implements RecordingService {
|
||||
// 녹음 중지 처리
|
||||
recording.updateStatus(Recording.RecordingStatus.STOPPED);
|
||||
LocalDateTime endTime = LocalDateTime.now();
|
||||
|
||||
|
||||
// 녹음 시간 계산 (임시로 30분으로 설정)
|
||||
Integer duration = 1800; // 실제로는 startTime과 endTime 차이 계산
|
||||
Long fileSize = 172800000L; // 실제로는 Azure Blob에서 파일 크기 조회
|
||||
|
||||
|
||||
RecordingEntity savedRecording = recordingRepository.save(recording);
|
||||
|
||||
// 녹음 중지 이벤트 발행
|
||||
|
||||
// 녹음 중지 이벤트 발행 (음성 파일 저장하지 않으므로 파일 정보 제외)
|
||||
RecordingEvent.RecordingStopped event = RecordingEvent.RecordingStopped.of(
|
||||
recordingId, recording.getMeetingId(), request.getStoppedBy(),
|
||||
recording.getStartTime(), duration, fileSize, recording.getStoragePath()
|
||||
recording.getStartTime(), duration
|
||||
);
|
||||
eventPublisher.publishAsync("recording-events", event);
|
||||
|
||||
@@ -138,8 +135,6 @@ public class RecordingServiceImpl implements RecordingService {
|
||||
.startTime(recording.getStartTime())
|
||||
.endTime(endTime)
|
||||
.duration(duration)
|
||||
.fileSize(fileSize)
|
||||
.storagePath(recording.getStoragePath())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -160,7 +155,6 @@ public class RecordingServiceImpl implements RecordingService {
|
||||
.duration(recording.getDuration())
|
||||
.speakerCount(recording.getSpeakerCount())
|
||||
.segmentCount(recording.getSegmentCount())
|
||||
.storagePath(recording.getStoragePath())
|
||||
.language(recording.getLanguage())
|
||||
.build();
|
||||
}
|
||||
@@ -181,10 +175,4 @@ public class RecordingServiceImpl implements RecordingService {
|
||||
String.format("%03d", (int)(Math.random() * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 경로 생성
|
||||
*/
|
||||
private String generateStoragePath(String meetingId, String sessionId) {
|
||||
return String.format("recordings/%s/%s.wav", meetingId, sessionId);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.unicorn.hgzero.stt.service;
|
||||
|
||||
import com.unicorn.hgzero.stt.dto.SpeakerDto;
|
||||
|
||||
/**
|
||||
* 화자 서비스 인터페이스
|
||||
* 화자 식별 및 관리 기능을 정의
|
||||
*/
|
||||
public interface SpeakerService {
|
||||
|
||||
/**
|
||||
* 화자 식별
|
||||
*
|
||||
* @param request 화자 식별 요청
|
||||
* @return 화자 식별 응답
|
||||
*/
|
||||
SpeakerDto.IdentificationResponse identifySpeaker(SpeakerDto.IdentifyRequest request);
|
||||
|
||||
/**
|
||||
* 화자 정보 조회
|
||||
*
|
||||
* @param speakerId 화자 ID
|
||||
* @return 화자 상세 응답
|
||||
*/
|
||||
SpeakerDto.DetailResponse getSpeaker(String speakerId);
|
||||
|
||||
/**
|
||||
* 화자 정보 업데이트
|
||||
*
|
||||
* @param speakerId 화자 ID
|
||||
* @param request 화자 업데이트 요청
|
||||
* @return 화자 상세 응답
|
||||
*/
|
||||
SpeakerDto.DetailResponse updateSpeaker(String speakerId, SpeakerDto.UpdateRequest request);
|
||||
|
||||
/**
|
||||
* 녹음의 화자 목록 조회
|
||||
*
|
||||
* @param recordingId 녹음 ID
|
||||
* @return 화자 목록 응답
|
||||
*/
|
||||
SpeakerDto.ListResponse getRecordingSpeakers(String recordingId);
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
package com.unicorn.hgzero.stt.service;
|
||||
|
||||
import com.unicorn.hgzero.common.exception.BusinessException;
|
||||
import com.unicorn.hgzero.common.exception.ErrorCode;
|
||||
import com.unicorn.hgzero.stt.dto.SpeakerDto;
|
||||
import com.unicorn.hgzero.stt.event.SpeakerEvent;
|
||||
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
|
||||
import com.unicorn.hgzero.stt.repository.entity.SpeakerEntity;
|
||||
import com.unicorn.hgzero.stt.repository.jpa.SpeakerRepository;
|
||||
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 화자 서비스 구현체
|
||||
* 화자 식별 및 관리 기능을 구현
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class SpeakerServiceImpl implements SpeakerService {
|
||||
|
||||
private final SpeakerRepository speakerRepository;
|
||||
private final TranscriptSegmentRepository segmentRepository;
|
||||
private final EventPublisher eventPublisher;
|
||||
|
||||
@Override
|
||||
public SpeakerDto.IdentificationResponse identifySpeaker(SpeakerDto.IdentifyRequest request) {
|
||||
log.info("화자 식별 시작 - recordingId: {}", request.getRecordingId());
|
||||
|
||||
// Azure Speaker Recognition API 호출 시뮬레이션
|
||||
String profileId = simulateAzureSpeakerRecognition(request.getAudioFrame());
|
||||
Double confidence = simulateIdentificationConfidence();
|
||||
|
||||
// 기존 화자 조회
|
||||
SpeakerEntity speaker = speakerRepository.findByProfileId(profileId).orElse(null);
|
||||
boolean isNewSpeaker = false;
|
||||
|
||||
if (speaker == null) {
|
||||
// 신규 화자 등록
|
||||
String speakerId = generateSpeakerId();
|
||||
String speakerName = "화자-" + speakerId.substring(4);
|
||||
|
||||
speaker = SpeakerEntity.builder()
|
||||
.speakerId(speakerId)
|
||||
.speakerName(speakerName)
|
||||
.profileId(profileId)
|
||||
.totalSegments(0)
|
||||
.totalDuration(0)
|
||||
.averageConfidence(confidence)
|
||||
.firstAppeared(LocalDateTime.now())
|
||||
.lastAppeared(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
speakerRepository.save(speaker);
|
||||
isNewSpeaker = true;
|
||||
|
||||
// 신규 화자 식별 이벤트 발행
|
||||
SpeakerEvent.NewSpeakerIdentified event = SpeakerEvent.NewSpeakerIdentified.of(
|
||||
speakerId, speakerName, profileId, request.getRecordingId(),
|
||||
extractMeetingIdFromRecordingId(request.getRecordingId()), confidence
|
||||
);
|
||||
eventPublisher.publishAsync("speaker-events", event);
|
||||
|
||||
log.info("신규 화자 등록 완료 - speakerId: {}, confidence: {}", speakerId, confidence);
|
||||
} else {
|
||||
log.debug("기존 화자 식별 - speakerId: {}, confidence: {}", speaker.getSpeakerId(), confidence);
|
||||
}
|
||||
|
||||
return SpeakerDto.IdentificationResponse.builder()
|
||||
.speakerId(speaker.getSpeakerId())
|
||||
.speakerName(speaker.getSpeakerName())
|
||||
.confidence(confidence)
|
||||
.isNewSpeaker(isNewSpeaker)
|
||||
.profileId(profileId)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public SpeakerDto.DetailResponse getSpeaker(String speakerId) {
|
||||
log.debug("화자 정보 조회 - speakerId: {}", speakerId);
|
||||
|
||||
SpeakerEntity speaker = findSpeakerById(speakerId);
|
||||
|
||||
return SpeakerDto.DetailResponse.builder()
|
||||
.speakerId(speaker.getSpeakerId())
|
||||
.speakerName(speaker.getSpeakerName())
|
||||
.profileId(speaker.getProfileId())
|
||||
.userId(speaker.getUserId())
|
||||
.totalSegments(speaker.getTotalSegments())
|
||||
.totalDuration(speaker.getTotalDuration())
|
||||
.averageConfidence(speaker.getAverageConfidence())
|
||||
.firstAppeared(speaker.getFirstAppeared())
|
||||
.lastAppeared(speaker.getLastAppeared())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpeakerDto.DetailResponse updateSpeaker(String speakerId, SpeakerDto.UpdateRequest request) {
|
||||
log.info("화자 정보 업데이트 - speakerId: {}", speakerId);
|
||||
|
||||
SpeakerEntity speaker = findSpeakerById(speakerId);
|
||||
|
||||
// 화자 정보 업데이트
|
||||
String oldSpeakerName = speaker.getSpeakerName();
|
||||
String oldUserId = speaker.getUserId();
|
||||
speaker.updateSpeakerInfo(request.getSpeakerName(), request.getUserId());
|
||||
SpeakerEntity savedSpeaker = speakerRepository.save(speaker);
|
||||
|
||||
// 화자 정보 업데이트 이벤트 발행
|
||||
SpeakerEvent.SpeakerUpdated event = SpeakerEvent.SpeakerUpdated.of(
|
||||
speakerId, oldSpeakerName, request.getSpeakerName(),
|
||||
oldUserId, request.getUserId(), "SYSTEM"
|
||||
);
|
||||
eventPublisher.publishAsync("speaker-events", event);
|
||||
|
||||
log.info("화자 정보 업데이트 완료 - speakerId: {}, speakerName: {}",
|
||||
speakerId, request.getSpeakerName());
|
||||
|
||||
return getSpeaker(speakerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public SpeakerDto.ListResponse getRecordingSpeakers(String recordingId) {
|
||||
log.debug("녹음 화자 목록 조회 - recordingId: {}", recordingId);
|
||||
|
||||
// 녹음별 화자 통계 조회
|
||||
List<Object[]> speakerStats = segmentRepository.getSpeakerStatisticsByRecording(recordingId);
|
||||
|
||||
List<SpeakerDto.SpeakerSummary> speakers = speakerStats.stream()
|
||||
.map(stat -> {
|
||||
String speakerId = (String) stat[0];
|
||||
Long segmentCount = (Long) stat[1];
|
||||
Double totalDuration = (Double) stat[2];
|
||||
Double averageConfidence = (Double) stat[3];
|
||||
|
||||
// 화자 정보 조회
|
||||
SpeakerEntity speaker = speakerRepository.findById(speakerId).orElse(null);
|
||||
String speakerName = speaker != null ? speaker.getSpeakerName() : "알 수 없는 화자";
|
||||
|
||||
// 발언 비율 계산 (전체 시간 대비)
|
||||
Double speakingRatio = calculateSpeakingRatio(recordingId, totalDuration);
|
||||
|
||||
return SpeakerDto.SpeakerSummary.builder()
|
||||
.speakerId(speakerId)
|
||||
.speakerName(speakerName)
|
||||
.segmentCount(segmentCount.intValue())
|
||||
.totalDuration(totalDuration.intValue())
|
||||
.speakingRatio(speakingRatio)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return SpeakerDto.ListResponse.builder()
|
||||
.recordingId(recordingId)
|
||||
.speakerCount(speakers.size())
|
||||
.speakers(speakers)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 ID로 엔티티 조회
|
||||
*/
|
||||
private SpeakerEntity findSpeakerById(String speakerId) {
|
||||
return speakerRepository.findById(speakerId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "화자를 찾을 수 없습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 ID 생성
|
||||
*/
|
||||
private String generateSpeakerId() {
|
||||
return "SPK-" + String.format("%03d", (int)(Math.random() * 999) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Azure Speaker Recognition 시뮬레이션
|
||||
*/
|
||||
private String simulateAzureSpeakerRecognition(String audioFrame) {
|
||||
// 실제로는 Azure Cognitive Services Speaker Recognition API 호출
|
||||
return "PROFILE-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 식별 신뢰도 시뮬레이션
|
||||
*/
|
||||
private Double simulateIdentificationConfidence() {
|
||||
// 실제로는 Azure Speaker Recognition API에서 반환
|
||||
return 0.90 + (Math.random() * 0.10); // 0.90 ~ 1.0
|
||||
}
|
||||
|
||||
/**
|
||||
* 발언 비율 계산
|
||||
*/
|
||||
private Double calculateSpeakingRatio(String recordingId, Double speakerDuration) {
|
||||
// 전체 녹음 시간 조회 (임시로 1800초로 설정)
|
||||
Double totalDuration = 1800.0;
|
||||
return speakerDuration / totalDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 녹음 ID에서 회의 ID 추출
|
||||
*/
|
||||
private String extractMeetingIdFromRecordingId(String recordingId) {
|
||||
// 실제로는 Recording 엔티티에서 조회
|
||||
return "MEETING-" + recordingId.substring(4, 12);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.unicorn.hgzero.stt.service;
|
||||
|
||||
import com.unicorn.hgzero.stt.dto.TranscriptionDto;
|
||||
import com.unicorn.hgzero.stt.dto.TranscriptSegmentDto;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 음성 변환 서비스 인터페이스
|
||||
@@ -18,30 +17,13 @@ public interface TranscriptionService {
|
||||
*/
|
||||
TranscriptSegmentDto.Response processAudioStream(TranscriptionDto.StreamRequest request);
|
||||
|
||||
/**
|
||||
* 배치 음성-텍스트 변환
|
||||
*
|
||||
* @param request 배치 변환 요청
|
||||
* @param audioFile 오디오 파일
|
||||
* @return 배치 변환 응답
|
||||
*/
|
||||
TranscriptionDto.BatchResponse transcribeAudioBatch(TranscriptionDto.BatchRequest request, MultipartFile audioFile);
|
||||
|
||||
/**
|
||||
* 배치 변환 완료 콜백 처리
|
||||
*
|
||||
* @param request 배치 콜백 요청
|
||||
* @return 변환 완료 응답
|
||||
*/
|
||||
TranscriptionDto.CompleteResponse processBatchCallback(TranscriptionDto.BatchCallbackRequest request);
|
||||
|
||||
/**
|
||||
* 변환 텍스트 전체 조회
|
||||
*
|
||||
*
|
||||
* @param recordingId 녹음 ID
|
||||
* @param includeSegments 세그먼트 포함 여부
|
||||
* @param speakerId 화자 ID 필터
|
||||
* @return 변환 결과 응답
|
||||
*/
|
||||
TranscriptionDto.Response getTranscription(String recordingId, Boolean includeSegments, String speakerId);
|
||||
TranscriptionDto.Response getTranscription(String recordingId);
|
||||
}
|
||||
@@ -16,9 +16,10 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -78,12 +79,14 @@ public class TranscriptionServiceImpl implements TranscriptionService {
|
||||
updateRecordingStatistics(request.getRecordingId());
|
||||
|
||||
// 세그먼트 생성 이벤트 발행
|
||||
LocalDateTime timestampAsLocalDateTime = java.time.Instant.ofEpochMilli(request.getTimestamp())
|
||||
.atZone(java.time.ZoneId.systemDefault()).toLocalDateTime();
|
||||
LocalDateTime timestampAsDateTime = LocalDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(request.getTimestamp()),
|
||||
ZoneId.systemDefault()
|
||||
);
|
||||
TranscriptionEvent.SegmentCreated event = TranscriptionEvent.SegmentCreated.of(
|
||||
segmentId, request.getRecordingId(), recording.getMeetingId(),
|
||||
recognizedText, speakerId, "화자-" + speakerId.substring(4),
|
||||
timestampAsLocalDateTime, 3.5, confidence, warningFlag
|
||||
timestampAsDateTime, 3.5, confidence, warningFlag
|
||||
);
|
||||
eventPublisher.publishAsync("transcription-events", event);
|
||||
|
||||
@@ -111,94 +114,22 @@ public class TranscriptionServiceImpl implements TranscriptionService {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TranscriptionDto.BatchResponse transcribeAudioBatch(TranscriptionDto.BatchRequest request, MultipartFile audioFile) {
|
||||
log.info("배치 음성 변환 시작 - recordingId: {}, fileSize: {}",
|
||||
request.getRecordingId(), audioFile.getSize());
|
||||
|
||||
// 녹음 존재 확인
|
||||
RecordingEntity recording = findRecordingById(request.getRecordingId());
|
||||
|
||||
// 배치 작업 ID 생성
|
||||
String jobId = generateJobId();
|
||||
|
||||
// Azure Batch Transcription Job 생성 (실제로는 Azure Speech SDK 사용)
|
||||
// 여기서는 시뮬레이션
|
||||
|
||||
log.info("배치 음성 변환 작업 생성 완료 - jobId: {}", jobId);
|
||||
|
||||
return TranscriptionDto.BatchResponse.builder()
|
||||
.jobId(jobId)
|
||||
.recordingId(request.getRecordingId())
|
||||
.status("PROCESSING")
|
||||
.estimatedCompletionTime(LocalDateTime.now().plusSeconds(30))
|
||||
.callbackUrl(request.getCallbackUrl())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TranscriptionDto.CompleteResponse processBatchCallback(TranscriptionDto.BatchCallbackRequest request) {
|
||||
log.info("배치 변환 콜백 처리 - jobId: {}, status: {}", request.getJobId(), request.getStatus());
|
||||
|
||||
if ("COMPLETED".equals(request.getStatus()) && request.getSegments() != null) {
|
||||
// 세그먼트 저장
|
||||
for (TranscriptSegmentDto.Detail segmentDto : request.getSegments()) {
|
||||
String segmentId = generateSegmentId();
|
||||
|
||||
TranscriptSegmentEntity segment = TranscriptSegmentEntity.builder()
|
||||
.segmentId(segmentId)
|
||||
.transcriptId(segmentDto.getTranscriptId())
|
||||
.text(segmentDto.getText())
|
||||
.speakerId(segmentDto.getSpeakerId())
|
||||
.speakerName(segmentDto.getSpeakerName())
|
||||
.timestamp(segmentDto.getTimestamp())
|
||||
.duration(segmentDto.getDuration())
|
||||
.confidence(segmentDto.getConfidence())
|
||||
.warningFlag(segmentDto.getConfidence() < 0.6)
|
||||
.build();
|
||||
|
||||
segmentRepository.save(segment);
|
||||
}
|
||||
|
||||
// 전체 텍스트 통합 및 변환 결과 저장
|
||||
String recordingId = extractRecordingIdFromJobId(request.getJobId());
|
||||
aggregateAndSaveTranscription(recordingId, request.getSegments());
|
||||
}
|
||||
|
||||
return TranscriptionDto.CompleteResponse.builder()
|
||||
.jobId(request.getJobId())
|
||||
.status(request.getStatus())
|
||||
.segmentCount(request.getSegments() != null ? request.getSegments().size() : 0)
|
||||
.totalDuration(calculateTotalDuration(request.getSegments()))
|
||||
.averageConfidence(calculateAverageConfidence(request.getSegments()))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public TranscriptionDto.Response getTranscription(String recordingId, Boolean includeSegments, String speakerId) {
|
||||
log.debug("변환 텍스트 조회 - recordingId: {}, includeSegments: {}", recordingId, includeSegments);
|
||||
|
||||
public TranscriptionDto.Response getTranscription(String recordingId) {
|
||||
log.debug("변환 텍스트 조회 - recordingId: {}", recordingId);
|
||||
|
||||
// 변환 결과 조회
|
||||
TranscriptionEntity transcription = transcriptionRepository.findByRecordingId(recordingId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "변환 결과를 찾을 수 없습니다"));
|
||||
|
||||
List<TranscriptSegmentDto.Detail> segments = null;
|
||||
|
||||
if (Boolean.TRUE.equals(includeSegments)) {
|
||||
List<TranscriptSegmentEntity> segmentEntities;
|
||||
|
||||
if (speakerId != null) {
|
||||
segmentEntities = segmentRepository.findByRecordingIdAndSpeakerIdOrderByTimestamp(recordingId, speakerId);
|
||||
} else {
|
||||
segmentEntities = segmentRepository.findByRecordingIdOrderByTimestamp(recordingId);
|
||||
}
|
||||
|
||||
segments = segmentEntities.stream()
|
||||
.map(this::convertToSegmentDetail)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
// 세그먼트 정보 포함
|
||||
List<TranscriptSegmentEntity> segmentEntities = segmentRepository.findByRecordingIdOrderByTimestamp(recordingId);
|
||||
List<TranscriptSegmentDto.Detail> segments = segmentEntities.stream()
|
||||
.map(this::convertToSegmentDetail)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return TranscriptionDto.Response.builder()
|
||||
.recordingId(recordingId)
|
||||
.fullText(transcription.getFullText())
|
||||
|
||||
@@ -6,7 +6,7 @@ spring:
|
||||
datasource:
|
||||
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:4.230.65.89}:${DB_PORT:5432}/${DB_NAME:sttdb}
|
||||
username: ${DB_USERNAME:hgzerouser}
|
||||
password: ${DB_PASSWORD:}
|
||||
password: ${DB_PASSWORD:Hi5Jessica!}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
@@ -21,6 +21,7 @@ spring:
|
||||
show-sql: ${SHOW_SQL:true}
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
hibernate:
|
||||
@@ -31,7 +32,7 @@ spring:
|
||||
redis:
|
||||
host: ${REDIS_HOST:20.249.177.114}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
password: ${REDIS_PASSWORD:Hi5Jessica!}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package com.unicorn.hgzero.stt;
|
||||
|
||||
import com.unicorn.hgzero.stt.config.TestConfig;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* STT 애플리케이션 통합 테스트
|
||||
* 전체 애플리케이션 컨텍스트 로딩 및 기본 설정 검증
|
||||
*/
|
||||
@SpringBootTest
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(TestConfig.class)
|
||||
@DisplayName("STT 애플리케이션 테스트")
|
||||
class SttApplicationTest {
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.unicorn.hgzero.stt.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unicorn.hgzero.stt.config.WebMvcTestConfig;
|
||||
import com.unicorn.hgzero.stt.dto.RecordingDto;
|
||||
import com.unicorn.hgzero.stt.service.RecordingService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -9,6 +10,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@@ -25,13 +27,13 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
@WebMvcTest(RecordingController.class)
|
||||
@DisplayName("녹음 컨트롤러 테스트")
|
||||
class RecordingControllerTest {
|
||||
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
|
||||
@MockBean
|
||||
private RecordingService recordingService;
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
package com.unicorn.hgzero.stt.integration;
|
||||
|
||||
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.TranscriptionDto;
|
||||
import com.unicorn.hgzero.stt.dto.SpeakerDto;
|
||||
import com.unicorn.hgzero.stt.service.RecordingService;
|
||||
import com.unicorn.hgzero.stt.service.SpeakerService;
|
||||
import com.unicorn.hgzero.stt.service.TranscriptionService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
@@ -16,6 +23,8 @@ import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@@ -23,19 +32,111 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
* STT API 통합 테스트
|
||||
* 전체 워크플로우 시나리오 테스트
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@AutoConfigureWebMvc
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
@DisplayName("STT API 통합 테스트")
|
||||
class SttApiIntegrationTest {
|
||||
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
|
||||
@MockBean
|
||||
private RecordingService recordingService;
|
||||
|
||||
@MockBean
|
||||
private SpeakerService speakerService;
|
||||
|
||||
@MockBean
|
||||
private TranscriptionService transcriptionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// RecordingService Mock 설정
|
||||
when(recordingService.prepareRecording(any(RecordingDto.PrepareRequest.class)))
|
||||
.thenReturn(RecordingDto.PrepareResponse.builder()
|
||||
.recordingId("REC-20250123-001")
|
||||
.sessionId("SESSION-INTEGRATION-001")
|
||||
.status("READY")
|
||||
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-INTEGRATION-001")
|
||||
.storagePath("recordings/MEETING-INTEGRATION-001/SESSION-INTEGRATION-001.wav")
|
||||
.estimatedInitTime(1100)
|
||||
.build());
|
||||
|
||||
when(recordingService.startRecording(anyString(), any(RecordingDto.StartRequest.class)))
|
||||
.thenReturn(RecordingDto.StatusResponse.builder()
|
||||
.recordingId("REC-20250123-001")
|
||||
.status("RECORDING")
|
||||
.startTime(java.time.LocalDateTime.now())
|
||||
.duration(0)
|
||||
.build());
|
||||
|
||||
when(recordingService.stopRecording(anyString(), any(RecordingDto.StopRequest.class)))
|
||||
.thenReturn(RecordingDto.StatusResponse.builder()
|
||||
.recordingId("REC-20250123-001")
|
||||
.status("STOPPED")
|
||||
.startTime(java.time.LocalDateTime.now().minusMinutes(30))
|
||||
.endTime(java.time.LocalDateTime.now())
|
||||
.duration(1800)
|
||||
.fileSize(172800000L)
|
||||
.storagePath("recordings/MEETING-INTEGRATION-001/SESSION-INTEGRATION-001.wav")
|
||||
.build());
|
||||
|
||||
when(recordingService.getRecording(anyString()))
|
||||
.thenReturn(RecordingDto.DetailResponse.builder()
|
||||
.recordingId("REC-20250123-001")
|
||||
.meetingId("MEETING-INTEGRATION-001")
|
||||
.sessionId("SESSION-INTEGRATION-001")
|
||||
.status("STOPPED")
|
||||
.startTime(java.time.LocalDateTime.now().minusMinutes(30))
|
||||
.endTime(java.time.LocalDateTime.now())
|
||||
.duration(1800)
|
||||
.speakerCount(3)
|
||||
.segmentCount(45)
|
||||
.storagePath("recordings/MEETING-INTEGRATION-001/SESSION-INTEGRATION-001.wav")
|
||||
.language("ko-KR")
|
||||
.build());
|
||||
|
||||
// TranscriptionService Mock 설정
|
||||
when(transcriptionService.processAudioStream(any(TranscriptionDto.StreamRequest.class)))
|
||||
.thenReturn(com.unicorn.hgzero.stt.dto.TranscriptSegmentDto.Response.builder()
|
||||
.recordingId("REC-20250123-001")
|
||||
.transcriptId("TRANS-001")
|
||||
.text("안녕하세요")
|
||||
.confidence(0.95)
|
||||
.timestamp(System.currentTimeMillis())
|
||||
.speakerId("SPK-001")
|
||||
.duration(2.5)
|
||||
.build());
|
||||
|
||||
when(transcriptionService.getTranscription(anyString(), any(), any()))
|
||||
.thenReturn(TranscriptionDto.Response.builder()
|
||||
.recordingId("REC-20250123-001")
|
||||
.fullText("안녕하세요. 오늘 회의를 시작하겠습니다.")
|
||||
.segmentCount(45)
|
||||
.speakerCount(3)
|
||||
.totalDuration(1800)
|
||||
.averageConfidence(0.92)
|
||||
.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
|
||||
@DisplayName("전체 STT 워크플로우 통합 테스트")
|
||||
void fullSttWorkflowIntegrationTest() throws Exception {
|
||||
@@ -141,29 +242,40 @@ class SttApiIntegrationTest {
|
||||
@Test
|
||||
@DisplayName("에러 케이스 통합 테스트")
|
||||
void errorCasesIntegrationTest() throws Exception {
|
||||
// 존재하지 않는 녹음에 대한 Mock 설정
|
||||
when(recordingService.startRecording(eq("NONEXISTENT-001"), any(RecordingDto.StartRequest.class)))
|
||||
.thenThrow(new com.unicorn.hgzero.common.exception.BusinessException(
|
||||
com.unicorn.hgzero.common.exception.ErrorCode.ENTITY_NOT_FOUND,
|
||||
"녹음을 찾을 수 없습니다"));
|
||||
|
||||
when(transcriptionService.getTranscription(eq("NONEXISTENT-001"), any(), any()))
|
||||
.thenThrow(new com.unicorn.hgzero.common.exception.BusinessException(
|
||||
com.unicorn.hgzero.common.exception.ErrorCode.ENTITY_NOT_FOUND,
|
||||
"변환 결과를 찾을 수 없습니다"));
|
||||
|
||||
// 존재하지 않는 녹음 시작 시도
|
||||
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
|
||||
.startedBy("test-user")
|
||||
.build();
|
||||
|
||||
|
||||
mockMvc.perform(post("/api/v1/stt/recordings/NONEXISTENT-001/start")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(startRequest)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
|
||||
|
||||
// 잘못된 요청 데이터로 녹음 준비 시도
|
||||
RecordingDto.PrepareRequest invalidRequest = RecordingDto.PrepareRequest.builder()
|
||||
.meetingId("") // 빈 meetingId
|
||||
.sessionId("SESSION-001")
|
||||
.build();
|
||||
|
||||
|
||||
mockMvc.perform(post("/api/v1/stt/recordings/prepare")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(invalidRequest)))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
|
||||
// 존재하지 않는 변환 결과 조회
|
||||
mockMvc.perform(get("/api/v1/stt/transcription/NONEXISTENT-001"))
|
||||
.andExpect(status().isBadRequest())
|
||||
|
||||
@@ -50,8 +50,10 @@ class TranscriptionServiceTest {
|
||||
private TranscriptionServiceImpl transcriptionService;
|
||||
|
||||
private RecordingEntity recordingEntity;
|
||||
private TranscriptSegmentEntity segmentEntity;
|
||||
private TranscriptionEntity transcriptionEntity;
|
||||
private TranscriptionDto.StreamRequest streamRequest;
|
||||
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
recordingEntity = RecordingEntity.builder()
|
||||
@@ -59,7 +61,23 @@ class TranscriptionServiceTest {
|
||||
.meetingId("MEETING-001")
|
||||
.sessionId("SESSION-001")
|
||||
.build();
|
||||
|
||||
|
||||
segmentEntity = TranscriptSegmentEntity.builder()
|
||||
.segmentId("SEG-001")
|
||||
.recordingId("REC-20250123-001")
|
||||
.text("테스트 음성 변환 결과")
|
||||
.timestamp(System.currentTimeMillis())
|
||||
.confidence(0.95)
|
||||
.build();
|
||||
|
||||
transcriptionEntity = TranscriptionEntity.builder()
|
||||
.transcriptId("TRANS-001")
|
||||
.recordingId("REC-20250123-001")
|
||||
.fullText("전체 음성 변환 결과")
|
||||
.segmentCount(1)
|
||||
.averageConfidence(0.95)
|
||||
.build();
|
||||
|
||||
streamRequest = TranscriptionDto.StreamRequest.builder()
|
||||
.recordingId("REC-20250123-001")
|
||||
.audioData("base64-encoded-audio-data")
|
||||
@@ -73,7 +91,7 @@ class TranscriptionServiceTest {
|
||||
void processAudioStream_Success() {
|
||||
// Given
|
||||
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
|
||||
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any());
|
||||
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(segmentEntity);
|
||||
when(segmentRepository.getSpeakerStatisticsByRecording(anyString())).thenReturn(List.of());
|
||||
when(segmentRepository.countByRecordingId(anyString())).thenReturn(1L);
|
||||
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
|
||||
@@ -88,9 +106,9 @@ class TranscriptionServiceTest {
|
||||
assertThat(response.getConfidence()).isGreaterThan(0.8);
|
||||
assertThat(response.getSpeakerId()).isNotEmpty();
|
||||
|
||||
verify(recordingRepository).findById("REC-20250123-001");
|
||||
verify(recordingRepository, atLeastOnce()).findById("REC-20250123-001");
|
||||
verify(segmentRepository).save(any(TranscriptSegmentEntity.class));
|
||||
verify(eventPublisher).publishAsync(eq("transcription-events"), any());
|
||||
verify(eventPublisher, atLeastOnce()).publishAsync(eq("transcription-events"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,7 +132,7 @@ class TranscriptionServiceTest {
|
||||
void processAudioStream_LowConfidenceWarning() {
|
||||
// Given
|
||||
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
|
||||
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any());
|
||||
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(segmentEntity);
|
||||
when(segmentRepository.getSpeakerStatisticsByRecording(anyString())).thenReturn(List.of());
|
||||
when(segmentRepository.countByRecordingId(anyString())).thenReturn(1L);
|
||||
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
|
||||
@@ -191,8 +209,8 @@ class TranscriptionServiceTest {
|
||||
.segments(segments)
|
||||
.build();
|
||||
|
||||
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any());
|
||||
when(transcriptionRepository.save(any(TranscriptionEntity.class))).thenReturn(any());
|
||||
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(segmentEntity);
|
||||
when(transcriptionRepository.save(any(TranscriptionEntity.class))).thenReturn(transcriptionEntity);
|
||||
|
||||
// When
|
||||
TranscriptionDto.CompleteResponse response = transcriptionService.processBatchCallback(callbackRequest);
|
||||
|
||||
@@ -1,55 +1,131 @@
|
||||
# STT 서비스 테스트 설정
|
||||
# 테스트 환경별 설정 선택
|
||||
# 1. 단위 테스트용 (기본)
|
||||
# 2. Docker 통합 테스트용 (integration-test profile 활성화 시)
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: test
|
||||
|
||||
# 데이터베이스 설정 (H2 인메모리)
|
||||
application:
|
||||
name: stt-test
|
||||
|
||||
# Bean Override 허용
|
||||
main:
|
||||
allow-bean-definition-overriding: true
|
||||
|
||||
# In-Memory Database (기본값)
|
||||
datasource:
|
||||
url: jdbc:h2:mem:testdb
|
||||
driver-class-name: org.h2.Driver
|
||||
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
username: sa
|
||||
password:
|
||||
|
||||
# JPA 설정
|
||||
password:
|
||||
driver-class-name: org.h2.Driver
|
||||
|
||||
jpa:
|
||||
show-sql: false
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.H2Dialect
|
||||
format_sql: true
|
||||
|
||||
# Redis 설정 (임베디드)
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6370
|
||||
timeout: 2000ms
|
||||
|
||||
# JWT 설정
|
||||
security:
|
||||
jwt:
|
||||
secret: test-secret-key-for-jwt-token-generation-test
|
||||
expiration: 86400
|
||||
|
||||
# Azure 서비스 설정 (테스트용 더미)
|
||||
# Mock Redis (handled by TestConfig)
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6370
|
||||
password:
|
||||
database: 0
|
||||
|
||||
# Test Server
|
||||
server:
|
||||
port: 0
|
||||
|
||||
# Mock Azure Services
|
||||
azure:
|
||||
speech:
|
||||
subscription-key: test-key
|
||||
region: koreacentral
|
||||
endpoint: https://test.cognitiveservices.azure.com/
|
||||
|
||||
storage:
|
||||
connection-string: DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net
|
||||
region: eastus
|
||||
language: ko-KR
|
||||
blob:
|
||||
connection-string: DefaultEndpointsProtocol=https;AccountName=test;AccountKey=test;EndpointSuffix=core.windows.net
|
||||
container-name: test-recordings
|
||||
|
||||
event-hubs:
|
||||
connection-string: Endpoint=sb://test-eventhub.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test
|
||||
eventhub:
|
||||
connection-string: Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test
|
||||
name: test-events
|
||||
consumer-group: test-group
|
||||
|
||||
# 로깅 설정
|
||||
---
|
||||
# Docker 통합 테스트용 설정
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: integration-test
|
||||
|
||||
# Real PostgreSQL (via Docker)
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5433/sttdb_test
|
||||
username: testuser
|
||||
password: testpass
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
show-sql: true
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
# Real Redis (via Docker)
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6380
|
||||
password: testpass
|
||||
database: 0
|
||||
|
||||
# Real Server
|
||||
server:
|
||||
port: 8083
|
||||
|
||||
# Azure Emulator (Azurite)
|
||||
azure:
|
||||
speech:
|
||||
subscription-key: test-key
|
||||
region: eastus
|
||||
language: ko-KR
|
||||
blob:
|
||||
connection-string: DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;
|
||||
container-name: test-recordings
|
||||
eventhub:
|
||||
connection-string: Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test
|
||||
name: test-events
|
||||
consumer-group: test-group
|
||||
|
||||
---
|
||||
# 공통 설정
|
||||
jwt:
|
||||
secret: test-secret-key-for-testing-purposes-only-not-for-production-use
|
||||
access-token-validity: 3600
|
||||
refresh-token-validity: 604800
|
||||
|
||||
cors:
|
||||
allowed-origins: "*"
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
enabled-by-default: false
|
||||
endpoint:
|
||||
health:
|
||||
enabled: true
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.unicorn.hgzero.stt: DEBUG
|
||||
org.springframework.web: DEBUG
|
||||
org.hibernate.SQL: DEBUG
|
||||
com.unicorn.hgzero.stt: INFO
|
||||
org.springframework: WARN
|
||||
org.hibernate: WARN
|
||||
pattern:
|
||||
console: "%d{HH:mm:ss} %-5level %logger{36} - %msg%n"
|
||||
|
||||
Reference in New Issue
Block a user