diff --git a/src/main/java/com/debatetimer/client/llm/OpenAiClient.java b/src/main/java/com/debatetimer/client/llm/OpenAiClient.java new file mode 100644 index 00000000..4c1a0cc4 --- /dev/null +++ b/src/main/java/com/debatetimer/client/llm/OpenAiClient.java @@ -0,0 +1,90 @@ +package com.debatetimer.client.llm; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; +import java.util.Map; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * OpenAI Chat Completions의 Structured Outputs(json_schema, strict)로 구조화된 JSON 응답을 받는다. + */ +@Component +@EnableConfigurationProperties(OpenAiProperties.class) +public class OpenAiClient { + + private final RestClient restClient; + private final OpenAiProperties properties; + + public OpenAiClient(RestClient.Builder restClientBuilder, OpenAiProperties properties) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setReadTimeout(properties.timeout()); + this.restClient = restClientBuilder + .baseUrl(properties.baseUrl()) + .requestFactory(requestFactory) + .build(); + this.properties = properties; + } + + public String requestStructuredOutput( + String systemPrompt, + String userPrompt, + String schemaName, + Map schema + ) { + if (!properties.hasApiKey()) { + throw new IllegalStateException("OpenAI API 키가 설정되지 않았습니다. OPENAI_API_KEY를 확인하세요."); + } + + Map body = Map.of( + "model", properties.model(), + "messages", List.of( + Map.of("role", "system", "content", systemPrompt), + Map.of("role", "user", "content", userPrompt) + ), + "response_format", Map.of( + "type", "json_schema", + "json_schema", Map.of("name", schemaName, "strict", true, "schema", schema) + ) + ); + + ChatCompletionResponse response = restClient.post() + .uri("/chat/completions") + .headers(headers -> headers.setBearerAuth(properties.apiKey())) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(ChatCompletionResponse.class); + + return extractContent(response); + } + + private String extractContent(ChatCompletionResponse response) { + if (response == null || response.choices() == null || response.choices().isEmpty()) { + throw new IllegalStateException("OpenAI 응답에 결과가 없습니다."); + } + Message message = response.choices().get(0).message(); + if (message.refusal() != null) { + throw new IllegalStateException("OpenAI가 분석을 거절했습니다: " + message.refusal()); + } + if (message.content() == null || message.content().isBlank()) { + throw new IllegalStateException("OpenAI 응답 본문이 비어 있습니다."); + } + return message.content(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record ChatCompletionResponse(List choices) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record Choice(Message message) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record Message(String content, String refusal) { + } +} diff --git a/src/main/java/com/debatetimer/client/llm/OpenAiProperties.java b/src/main/java/com/debatetimer/client/llm/OpenAiProperties.java new file mode 100644 index 00000000..d505e29f --- /dev/null +++ b/src/main/java/com/debatetimer/client/llm/OpenAiProperties.java @@ -0,0 +1,29 @@ +package com.debatetimer.client.llm; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 분석 보고서 생성에 쓰는 OpenAI 설정. 키가 비어 있어도 앱은 뜨고, 분석만 실패로 기록한다. + */ +@ConfigurationProperties(prefix = "openai") +public record OpenAiProperties(String apiKey, String model, String baseUrl, Duration timeout) { + + private static final String DEFAULT_MODEL = "gpt-5-mini"; + private static final String DEFAULT_BASE_URL = "https://api.openai.com/v1"; + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(180); + + public OpenAiProperties { + model = isBlank(model) ? DEFAULT_MODEL : model; + baseUrl = isBlank(baseUrl) ? DEFAULT_BASE_URL : baseUrl; + timeout = timeout == null ? DEFAULT_TIMEOUT : timeout; + } + + public boolean hasApiKey() { + return !isBlank(apiKey); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/debatetimer/client/stt/SonioxClient.java b/src/main/java/com/debatetimer/client/stt/SonioxClient.java new file mode 100644 index 00000000..f03d2afb --- /dev/null +++ b/src/main/java/com/debatetimer/client/stt/SonioxClient.java @@ -0,0 +1,223 @@ +package com.debatetimer.client.stt; + +import com.debatetimer.domain.transcript.SpeechToken; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.custom.DTServerErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.exception.errorcode.ServerErrorCode; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Semaphore; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +/** + * Soniox 비동기 전사 API로 오디오 한 개를 전사한다. + * 파일 업로드 → 전사 생성 → 완료까지 상태 조회 → 토큰 조회 순으로 진행하고, 끝나면 Soniox에 남은 파일과 전사를 지운다. + */ +@Slf4j +@Component +@EnableConfigurationProperties(SonioxProperties.class) +public class SonioxClient { + + private static final String STATUS_COMPLETED = "completed"; + private static final String STATUS_ERROR = "error"; + private static final String TRANSLATION_TOKEN = "translation"; + + private final RestClient restClient; + private final SonioxProperties properties; + // 전사는 요청 스레드에서 끝날 때까지 기다리므로, 동시에 진행하는 수를 제한해 다른 API의 스레드를 지킨다. + private final Semaphore permits; + + @Autowired + public SonioxClient(RestClient.Builder restClientBuilder, SonioxProperties properties) { + this(createRestClient(restClientBuilder, properties), properties); + } + + SonioxClient(RestClient restClient, SonioxProperties properties) { + this.restClient = restClient; + this.properties = properties; + this.permits = new Semaphore(properties.maxConcurrent()); + } + + private static RestClient createRestClient(RestClient.Builder restClientBuilder, SonioxProperties properties) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.connectTimeout()); + requestFactory.setReadTimeout(properties.timeout()); + return restClientBuilder + .baseUrl(properties.baseUrl()) + .requestFactory(requestFactory) + .build(); + } + + public List transcribe(Resource audio, List languageHints) { + if (!properties.hasApiKey()) { + throw new DTServerErrorException(ServerErrorCode.STT_NOT_CONFIGURED); + } + // 한도를 넘으면 기다리지 않고 바로 알린다. 프론트는 이 구간을 전사 실패로 표시한다. + if (!permits.tryAcquire()) { + throw new DTClientErrorException(ClientErrorCode.STT_TOO_MANY_REQUESTS); + } + try { + return transcribeWithPermit(audio, languageHints); + } finally { + permits.release(); + } + } + + private List transcribeWithPermit(Resource audio, List languageHints) { + String fileId = null; + String transcriptionId = null; + try { + fileId = uploadFile(audio); + transcriptionId = createTranscription(fileId, languageHints); + waitUntilCompleted(transcriptionId); + return fetchTokens(transcriptionId); + } catch (RestClientException exception) { + log.warn("Soniox 전사 요청에 실패했습니다. fileId={}, transcriptionId={}", fileId, transcriptionId, exception); + throw new DTServerErrorException(ServerErrorCode.STT_REQUEST_FAILED); + } finally { + cleanUp(fileId, transcriptionId); + } + } + + private String uploadFile(Resource audio) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", audio); + IdResponse response = restClient.post() + .uri("/files") + .headers(headers -> headers.setBearerAuth(properties.apiKey())) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(body) + .retrieve() + .body(IdResponse.class); + return requireId(response); + } + + private String createTranscription(String fileId, List languageHints) { + Map body = Map.of( + "model", properties.model(), + "file_id", fileId, + "language_hints", languageHints + ); + IdResponse response = restClient.post() + .uri("/transcriptions") + .headers(headers -> headers.setBearerAuth(properties.apiKey())) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(IdResponse.class); + return requireId(response); + } + + private void waitUntilCompleted(String transcriptionId) { + long deadline = System.nanoTime() + properties.maxWait().toNanos(); + while (true) { + TranscriptionResponse transcription = restClient.get() + .uri("/transcriptions/{id}", transcriptionId) + .headers(headers -> headers.setBearerAuth(properties.apiKey())) + .retrieve() + .body(TranscriptionResponse.class); + String status = transcription == null ? null : transcription.status(); + if (STATUS_COMPLETED.equals(status)) { + return; + } + if (STATUS_ERROR.equals(status)) { + log.warn("Soniox 전사가 실패했습니다. transcriptionId={}, error={}", transcriptionId, + transcription.errorMessage()); + throw new DTServerErrorException(ServerErrorCode.STT_REQUEST_FAILED); + } + if (System.nanoTime() >= deadline) { + log.warn("Soniox 전사가 제한 시간 안에 끝나지 않았습니다. transcriptionId={}", transcriptionId); + throw new DTServerErrorException(ServerErrorCode.STT_REQUEST_FAILED); + } + sleep(); + } + } + + private void sleep() { + try { + Thread.sleep(properties.pollInterval().toMillis()); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new DTServerErrorException(ServerErrorCode.STT_REQUEST_FAILED); + } + } + + private List fetchTokens(String transcriptionId) { + TranscriptResponse transcript = restClient.get() + .uri("/transcriptions/{id}/transcript", transcriptionId) + .headers(headers -> headers.setBearerAuth(properties.apiKey())) + .retrieve() + .body(TranscriptResponse.class); + if (transcript == null || transcript.tokens() == null) { + return List.of(); + } + return transcript.tokens().stream() + .filter(token -> !TRANSLATION_TOKEN.equals(token.translationStatus())) + .map(token -> new SpeechToken(token.text(), token.startMs(), token.endMs())) + .toList(); + } + + // 정리 실패는 전사 결과에 영향을 주지 않으므로 기록만 남긴다. + private void cleanUp(@Nullable String fileId, @Nullable String transcriptionId) { + if (transcriptionId != null) { + delete("/transcriptions/{id}", transcriptionId); + } + if (fileId != null) { + delete("/files/{id}", fileId); + } + } + + private void delete(String uri, String id) { + try { + restClient.delete() + .uri(uri, id) + .headers(headers -> headers.setBearerAuth(properties.apiKey())) + .retrieve() + .toBodilessEntity(); + } catch (RestClientException exception) { + log.warn("Soniox 리소스를 지우지 못했습니다. uri={}, id={}", uri, id, exception); + } + } + + private String requireId(@Nullable IdResponse response) { + if (response == null || response.id() == null) { + throw new DTServerErrorException(ServerErrorCode.STT_REQUEST_FAILED); + } + return response.id(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record IdResponse(String id) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record TranscriptionResponse(String status, @JsonProperty("error_message") String errorMessage) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record TranscriptResponse(List tokens) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record TokenResponse( + String text, + @JsonProperty("start_ms") long startMs, + @JsonProperty("end_ms") long endMs, + @JsonProperty("translation_status") String translationStatus + ) { + } +} diff --git a/src/main/java/com/debatetimer/client/stt/SonioxProperties.java b/src/main/java/com/debatetimer/client/stt/SonioxProperties.java new file mode 100644 index 00000000..ec03039c --- /dev/null +++ b/src/main/java/com/debatetimer/client/stt/SonioxProperties.java @@ -0,0 +1,47 @@ +package com.debatetimer.client.stt; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Soniox 비동기 전사 설정. 키가 비어 있어도 앱은 뜨고, Soniox 모드 전사 요청만 실패한다. + */ +@ConfigurationProperties(prefix = "soniox") +public record SonioxProperties( + String apiKey, + String model, + String baseUrl, + Duration timeout, + Duration pollInterval, + Duration maxWait, + Duration connectTimeout, + // 동시에 진행할 수 있는 전사 수. 전사 한 건이 요청 스레드를 최대 maxWait 동안 잡으므로 제한한다. + Integer maxConcurrent +) { + + private static final String DEFAULT_MODEL = "stt-async-v5"; + private static final String DEFAULT_BASE_URL = "https://api.soniox.com/v1"; + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(60); + private static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(1); + private static final Duration DEFAULT_MAX_WAIT = Duration.ofSeconds(180); + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final int DEFAULT_MAX_CONCURRENT = 4; + + public SonioxProperties { + model = isBlank(model) ? DEFAULT_MODEL : model; + baseUrl = isBlank(baseUrl) ? DEFAULT_BASE_URL : baseUrl; + timeout = timeout == null ? DEFAULT_TIMEOUT : timeout; + pollInterval = pollInterval == null ? DEFAULT_POLL_INTERVAL : pollInterval; + maxWait = maxWait == null ? DEFAULT_MAX_WAIT : maxWait; + connectTimeout = connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : connectTimeout; + maxConcurrent = maxConcurrent == null ? DEFAULT_MAX_CONCURRENT : maxConcurrent; + } + + public boolean hasApiKey() { + return !isBlank(apiKey); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/debatetimer/controller/transcript/SpeechTranscribeController.java b/src/main/java/com/debatetimer/controller/transcript/SpeechTranscribeController.java new file mode 100644 index 00000000..2d855e07 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/transcript/SpeechTranscribeController.java @@ -0,0 +1,33 @@ +package com.debatetimer.controller.transcript; + +import com.debatetimer.controller.auth.AuthMember; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.transcript.response.SpeechTranscribeResponse; +import com.debatetimer.service.transcript.SpeechTranscribeService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class SpeechTranscribeController { + + private final SpeechTranscribeService speechTranscribeService; + + // 회원만 호출할 수 있게 인증 회원을 받는다. 외부 STT 비용이 드는 요청이다. + @PostMapping(value = "/api/stt/soniox", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.OK) + public SpeechTranscribeResponse transcribeWithSoniox( + @AuthMember Member member, + @RequestPart("audio") MultipartFile audio, + @RequestParam(name = "language", required = false) String language + ) { + return speechTranscribeService.transcribeWithSoniox(audio, language); + } +} diff --git a/src/main/java/com/debatetimer/controller/transcript/TranscriptController.java b/src/main/java/com/debatetimer/controller/transcript/TranscriptController.java new file mode 100644 index 00000000..ae5a10b6 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/transcript/TranscriptController.java @@ -0,0 +1,50 @@ +package com.debatetimer.controller.transcript; + +import com.debatetimer.controller.auth.AuthMember; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.transcript.request.TranscriptCreateRequest; +import com.debatetimer.dto.transcript.response.TranscriptCreateResponse; +import com.debatetimer.dto.transcript.response.TranscriptResponse; +import com.debatetimer.dto.transcript.response.TranscriptSummaryResponses; +import com.debatetimer.service.transcript.TranscriptService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class TranscriptController { + + private final TranscriptService transcriptService; + + @PostMapping("/api/transcripts/{tableId}") + @ResponseStatus(HttpStatus.CREATED) + public TranscriptCreateResponse createTranscript( + @AuthMember Member member, + @PathVariable(name = "tableId") long tableId, + @RequestBody @Valid TranscriptCreateRequest request + ) { + return transcriptService.create(tableId, member, request); + } + + @GetMapping("/api/transcripts") + @ResponseStatus(HttpStatus.OK) + public TranscriptSummaryResponses getTranscripts(@AuthMember Member member) { + return transcriptService.findAll(member); + } + + @GetMapping("/api/transcripts/{transcriptId}") + @ResponseStatus(HttpStatus.OK) + public TranscriptResponse getTranscript( + @AuthMember Member member, + @PathVariable(name = "transcriptId") long transcriptId + ) { + return transcriptService.get(transcriptId, member); + } +} diff --git a/src/main/java/com/debatetimer/domain/transcript/RefinedTextMerger.java b/src/main/java/com/debatetimer/domain/transcript/RefinedTextMerger.java new file mode 100644 index 00000000..9302bd45 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/RefinedTextMerger.java @@ -0,0 +1,36 @@ +package com.debatetimer.domain.transcript; + +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +/** + * AI가 다듬은 문장을 속기록 줄에 반영한다. 화자·발언 순서·시각은 바꾸지 않고 문장만 바꾼다. + * 군말만 있어 비운 줄은 빼고 번호를 다시 매긴다. 다듬은 결과가 비정상이면 원문을 지킨다. + */ +public class RefinedTextMerger { + + public List merge(List lines, Map refinedTexts) { + List kept = lines.stream() + .map(line -> withText(line, refinedTexts.getOrDefault(line.index(), line.text()))) + .filter(line -> !line.text().isBlank()) + .toList(); + if (kept.isEmpty()) { + return lines; + } + return IntStream.range(0, kept.size()) + .mapToObj(index -> withIndex(kept.get(index), index)) + .toList(); + } + + private TranscriptLine withText(TranscriptLine line, String text) { + String stripped = text == null ? "" : text.strip(); + return new TranscriptLine(line.index(), line.timeBoxIndex(), line.speechType(), line.stance(), + line.speaker(), line.offsetMs(), stripped); + } + + private TranscriptLine withIndex(TranscriptLine line, int index) { + return new TranscriptLine(index, line.timeBoxIndex(), line.speechType(), line.stance(), line.speaker(), + line.offsetMs(), line.text()); + } +} diff --git a/src/main/java/com/debatetimer/domain/transcript/SpeakerSeparator.java b/src/main/java/com/debatetimer/domain/transcript/SpeakerSeparator.java new file mode 100644 index 00000000..36eade28 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/SpeakerSeparator.java @@ -0,0 +1,62 @@ +package com.debatetimer.domain.transcript; + +import com.debatetimer.domain.customize.CustomizeTimeBox; +import com.debatetimer.domain.customize.Stance; +import java.util.Comparator; +import java.util.List; +import java.util.stream.IntStream; +import org.springframework.lang.Nullable; + +/** + * 토론 시간표를 기준으로 발언자를 구분한다. 일반 타임박스는 시간표의 입장·발언자를, 자유토론은 발언 중이던 팀을 화자로 본다. + */ +public class SpeakerSeparator { + + public List separate(List timeBoxes, List segments) { + List unordered = segments.stream() + .filter(segment -> isValidIndex(timeBoxes, segment.timeBoxIndex())) + .flatMap(segment -> toLines(timeBoxes.get(segment.timeBoxIndex()), segment).stream()) + .sorted(Comparator.comparingLong(TranscriptLine::offsetMs)) + .toList(); + + return IntStream.range(0, unordered.size()) + .mapToObj(index -> withIndex(unordered.get(index), index)) + .toList(); + } + + private boolean isValidIndex(List timeBoxes, int index) { + return index >= 0 && index < timeBoxes.size(); + } + + private List toLines(CustomizeTimeBox timeBox, SpeechSegment segment) { + Stance stance = resolveStance(timeBox, segment.team()); + String speaker = timeBox.getBoxType().isTimeBased() ? null : timeBox.getSpeaker(); + return segment.utterances().stream() + .filter(utterance -> utterance.text() != null && !utterance.text().isBlank()) + .map(utterance -> new TranscriptLine( + 0, + segment.timeBoxIndex(), + timeBox.getSpeechType(), + stance, + speaker, + utterance.offsetMs(), + utterance.text().strip() + )) + .toList(); + } + + private Stance resolveStance(CustomizeTimeBox timeBox, @Nullable Stance team) { + if (timeBox.getBoxType().isNotTimeBased()) { + return timeBox.getStance(); + } + if (team == null || team.isNeutralStance()) { + return Stance.NEUTRAL; + } + return team; + } + + private TranscriptLine withIndex(TranscriptLine line, int index) { + return new TranscriptLine(index, line.timeBoxIndex(), line.speechType(), line.stance(), line.speaker(), + line.offsetMs(), line.text()); + } +} diff --git a/src/main/java/com/debatetimer/domain/transcript/SpeechSegment.java b/src/main/java/com/debatetimer/domain/transcript/SpeechSegment.java new file mode 100644 index 00000000..9c9d8142 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/SpeechSegment.java @@ -0,0 +1,11 @@ +package com.debatetimer.domain.transcript; + +import com.debatetimer.domain.customize.Stance; +import java.util.List; +import org.springframework.lang.Nullable; + +/** + * 타이머가 한 번 실행된 동안 인식한 발언 묶음. team은 자유토론처럼 한 타임박스 안에서 발언 팀이 바뀌는 경우에만 채워진다. + */ +public record SpeechSegment(int timeBoxIndex, @Nullable Stance team, List utterances) { +} diff --git a/src/main/java/com/debatetimer/domain/transcript/SpeechToken.java b/src/main/java/com/debatetimer/domain/transcript/SpeechToken.java new file mode 100644 index 00000000..ea3c5064 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/SpeechToken.java @@ -0,0 +1,7 @@ +package com.debatetimer.domain.transcript; + +/** + * 배치 음성 인식이 돌려준 하위 단어 단위 토큰. 시각은 전사한 오디오 시작 시점 기준이다. + */ +public record SpeechToken(String text, long startMs, long endMs) { +} diff --git a/src/main/java/com/debatetimer/domain/transcript/SttMode.java b/src/main/java/com/debatetimer/domain/transcript/SttMode.java new file mode 100644 index 00000000..9b1cbb95 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/SttMode.java @@ -0,0 +1,12 @@ +package com.debatetimer.domain.transcript; + +/** + * 발언을 텍스트로 바꾼 음성 인식 방식. 두 방식의 속기록 품질을 비교하려고 속기록마다 기록한다. + */ +public enum SttMode { + + // 크롬 Web Speech API(구글 음성 인식 RPC)로 발언 중 실시간 인식 + RPC, + // 발언 구간 오디오를 구간이 끝난 뒤 Soniox 비동기 API로 일괄 전사 + SONIOX, +} diff --git a/src/main/java/com/debatetimer/domain/transcript/Transcript.java b/src/main/java/com/debatetimer/domain/transcript/Transcript.java new file mode 100644 index 00000000..b74f0b82 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/Transcript.java @@ -0,0 +1,20 @@ +package com.debatetimer.domain.transcript; + +import java.util.List; +import org.springframework.lang.Nullable; + +public record Transcript( + Long id, + long tableId, + long memberId, + TranscriptStatus status, + SttMode sttMode, + // AI가 STT 원문을 다듬는 중이면 true. 다듬기가 끝나거나 실패하면 false가 된다. + boolean refining, + List lines, + // 다듬기 전 STT 원문. STT 모드별 인식 품질 비교용으로 보관한다. + List rawLines, + @Nullable String reportJson, + @Nullable String errorMessage +) { +} diff --git a/src/main/java/com/debatetimer/domain/transcript/TranscriptLine.java b/src/main/java/com/debatetimer/domain/transcript/TranscriptLine.java new file mode 100644 index 00000000..a0a9129a --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/TranscriptLine.java @@ -0,0 +1,15 @@ +package com.debatetimer.domain.transcript; + +import com.debatetimer.domain.customize.Stance; +import org.springframework.lang.Nullable; + +public record TranscriptLine( + int index, + int timeBoxIndex, + String speechType, + Stance stance, + @Nullable String speaker, + long offsetMs, + String text +) { +} diff --git a/src/main/java/com/debatetimer/domain/transcript/TranscriptStatus.java b/src/main/java/com/debatetimer/domain/transcript/TranscriptStatus.java new file mode 100644 index 00000000..a28651e7 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/TranscriptStatus.java @@ -0,0 +1,8 @@ +package com.debatetimer.domain.transcript; + +public enum TranscriptStatus { + + PROCESSING, + DONE, + FAILED, +} diff --git a/src/main/java/com/debatetimer/domain/transcript/TranscriptSummary.java b/src/main/java/com/debatetimer/domain/transcript/TranscriptSummary.java new file mode 100644 index 00000000..c1fa9b3f --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/TranscriptSummary.java @@ -0,0 +1,16 @@ +package com.debatetimer.domain.transcript; + +import java.time.LocalDateTime; + +/** + * 토론 기록 목록에 보여 줄 요약. 속기록·보고서 본문은 담지 않는다. + */ +public record TranscriptSummary( + long id, + long tableId, + SttMode sttMode, + TranscriptStatus status, + boolean refining, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/debatetimer/domain/transcript/Utterance.java b/src/main/java/com/debatetimer/domain/transcript/Utterance.java new file mode 100644 index 00000000..8633fc38 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/Utterance.java @@ -0,0 +1,7 @@ +package com.debatetimer.domain.transcript; + +/** + * 음성 인식이 확정한 한 문장. offsetMs는 토론 기록 시작 시점부터의 경과 시간이다. + */ +public record Utterance(long offsetMs, String text) { +} diff --git a/src/main/java/com/debatetimer/domain/transcript/UtteranceAssembler.java b/src/main/java/com/debatetimer/domain/transcript/UtteranceAssembler.java new file mode 100644 index 00000000..ebf7d04c --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/UtteranceAssembler.java @@ -0,0 +1,47 @@ +package com.debatetimer.domain.transcript; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 배치 음성 인식 토큰을 크롬 음성 인식의 확정 문장과 같은 단위의 발화로 묶는다. + * 문장 끝 부호가 나오거나 말이 오래 끊기면 발화를 나눈다. + */ +public class UtteranceAssembler { + + public static final long PAUSE_THRESHOLD_MS = 1_500; + private static final Pattern SENTENCE_END = Pattern.compile("[.?!。?!…]$"); + + public List assemble(List tokens) { + List utterances = new ArrayList<>(); + StringBuilder text = new StringBuilder(); + long startMs = 0; + long lastEndMs = 0; + + for (SpeechToken token : tokens) { + if (!text.isEmpty() && token.startMs() - lastEndMs >= PAUSE_THRESHOLD_MS) { + flush(utterances, startMs, text); + } + if (text.isEmpty()) { + startMs = token.startMs(); + } + text.append(token.text()); + lastEndMs = token.endMs(); + if (SENTENCE_END.matcher(token.text().strip()).find()) { + flush(utterances, startMs, text); + } + } + flush(utterances, startMs, text); + return utterances; + } + + private void flush(List utterances, long startMs, StringBuilder text) { + String sentence = text.toString().strip(); + text.setLength(0); + // 부호만 남은 토큰은 발화로 보지 않는다. + if (sentence.codePoints().anyMatch(Character::isLetterOrDigit)) { + utterances.add(new Utterance(startMs, sentence)); + } + } +} diff --git a/src/main/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepository.java new file mode 100644 index 00000000..15f29ae1 --- /dev/null +++ b/src/main/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepository.java @@ -0,0 +1,95 @@ +package com.debatetimer.domainrepository.transcript; + +import com.debatetimer.domain.transcript.SttMode; +import com.debatetimer.domain.transcript.Transcript; +import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domain.transcript.TranscriptSummary; +import com.debatetimer.entity.transcript.TranscriptEntity; +import com.debatetimer.repository.transcript.TranscriptRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@RequiredArgsConstructor +public class TranscriptDomainRepository { + + private static final TypeReference> LINES_TYPE = new TypeReference<>() { + }; + + private final TranscriptRepository transcriptRepository; + private final ObjectMapper objectMapper; + + @Transactional + public Transcript create(long tableId, long memberId, SttMode sttMode, List lines) { + TranscriptEntity entity = new TranscriptEntity(tableId, memberId, sttMode, writeLines(lines)); + return toDomain(transcriptRepository.save(entity)); + } + + @Transactional(readOnly = true) + public Transcript getById(long id) { + return toDomain(transcriptRepository.getById(id)); + } + + @Transactional(readOnly = true) + public Transcript getByIdAndMemberId(long id, long memberId) { + return toDomain(transcriptRepository.getByIdAndMemberId(id, memberId)); + } + + @Transactional(readOnly = true) + public List findSummariesByMemberId(long memberId) { + return transcriptRepository.findSummariesByMemberId(memberId); + } + + @Transactional + public void refine(long id, List refinedLines) { + transcriptRepository.getById(id).refine(writeLines(refinedLines)); + } + + @Transactional + public void skipRefinement(long id) { + transcriptRepository.getById(id).skipRefinement(); + } + + @Transactional + public void complete(long id, String reportJson) { + transcriptRepository.getById(id).complete(reportJson); + } + + @Transactional + public void fail(long id, String errorMessage) { + transcriptRepository.getById(id).fail(errorMessage); + } + + private Transcript toDomain(TranscriptEntity entity) { + return new Transcript(entity.getId(), entity.getTableId(), entity.getMemberId(), entity.getStatus(), + entity.getSttMode(), entity.isRefining(), readLines(entity.getLinesJson()), + readRawLines(entity.getRawLinesJson()), entity.getReportJson(), + entity.getErrorMessage()); + } + + private String writeLines(List lines) { + try { + return objectMapper.writeValueAsString(lines); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("속기록을 저장 형식으로 변환하지 못했습니다.", exception); + } + } + + // 다듬기 도입 전 속기록은 원문을 따로 보관하지 않았다. + private List readRawLines(String rawLinesJson) { + return rawLinesJson == null ? List.of() : readLines(rawLinesJson); + } + + private List readLines(String linesJson) { + try { + return objectMapper.readValue(linesJson, LINES_TYPE); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("저장된 속기록을 읽지 못했습니다.", exception); + } + } +} diff --git a/src/main/java/com/debatetimer/dto/transcript/request/TranscriptCreateRequest.java b/src/main/java/com/debatetimer/dto/transcript/request/TranscriptCreateRequest.java new file mode 100644 index 00000000..8ffe4d43 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/transcript/request/TranscriptCreateRequest.java @@ -0,0 +1,52 @@ +package com.debatetimer.dto.transcript.request; + +import com.debatetimer.domain.customize.Stance; +import com.debatetimer.domain.transcript.SpeechSegment; +import com.debatetimer.domain.transcript.SttMode; +import com.debatetimer.domain.transcript.Utterance; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; +import org.springframework.lang.Nullable; + +public record TranscriptCreateRequest( + @NotNull @Valid List segments, + @Nullable SttMode sttMode +) { + + public TranscriptCreateRequest(List segments) { + this(segments, null); + } + + // STT 모드 선택 이전 클라이언트는 크롬 음성 인식만 썼으므로 RPC로 본다. + public SttMode resolveSttMode() { + return sttMode == null ? SttMode.RPC : sttMode; + } + + public List toSegments() { + return segments.stream() + .map(SegmentRequest::toDomain) + .toList(); + } + + public record SegmentRequest( + @PositiveOrZero int timeBoxIndex, + @Nullable Stance team, + @NotNull @Valid List utterances + ) { + + private SpeechSegment toDomain() { + List domainUtterances = utterances.stream() + .map(utterance -> new Utterance(utterance.offsetMs(), utterance.text())) + .toList(); + return new SpeechSegment(timeBoxIndex, team, domainUtterances); + } + } + + public record UtteranceRequest( + @PositiveOrZero long offsetMs, + @NotNull String text + ) { + } +} diff --git a/src/main/java/com/debatetimer/dto/transcript/response/SpeechTranscribeResponse.java b/src/main/java/com/debatetimer/dto/transcript/response/SpeechTranscribeResponse.java new file mode 100644 index 00000000..e69f8973 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/transcript/response/SpeechTranscribeResponse.java @@ -0,0 +1,10 @@ +package com.debatetimer.dto.transcript.response; + +import com.debatetimer.domain.transcript.Utterance; +import java.util.List; + +/** + * 발언 구간 오디오를 전사한 문장 목록. offsetMs는 구간 오디오 시작 시점 기준이다. + */ +public record SpeechTranscribeResponse(List utterances) { +} diff --git a/src/main/java/com/debatetimer/dto/transcript/response/TranscriptCreateResponse.java b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptCreateResponse.java new file mode 100644 index 00000000..eb65e40b --- /dev/null +++ b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptCreateResponse.java @@ -0,0 +1,4 @@ +package com.debatetimer.dto.transcript.response; + +public record TranscriptCreateResponse(long id) { +} diff --git a/src/main/java/com/debatetimer/dto/transcript/response/TranscriptResponse.java b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptResponse.java new file mode 100644 index 00000000..95faf0bc --- /dev/null +++ b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptResponse.java @@ -0,0 +1,32 @@ +package com.debatetimer.dto.transcript.response; + +import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.transcript.SttMode; +import com.debatetimer.domain.transcript.Transcript; +import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domain.transcript.TranscriptStatus; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import org.springframework.lang.Nullable; + +public record TranscriptResponse( + long id, + long tableId, + String tableName, + String agenda, + String prosTeamName, + String consTeamName, + TranscriptStatus status, + SttMode sttMode, + boolean refining, + List lines, + @Nullable JsonNode report, + @Nullable String errorMessage +) { + + public TranscriptResponse(Transcript transcript, CustomizeTable table, @Nullable JsonNode report) { + this(transcript.id(), transcript.tableId(), table.getName(), table.getAgenda(), table.getProsTeamName(), + table.getConsTeamName(), transcript.status(), transcript.sttMode(), transcript.refining(), + transcript.lines(), report, transcript.errorMessage()); + } +} diff --git a/src/main/java/com/debatetimer/dto/transcript/response/TranscriptSummaryResponse.java b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptSummaryResponse.java new file mode 100644 index 00000000..96e47401 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptSummaryResponse.java @@ -0,0 +1,29 @@ +package com.debatetimer.dto.transcript.response; + +import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.transcript.SttMode; +import com.debatetimer.domain.transcript.TranscriptStatus; +import com.debatetimer.domain.transcript.TranscriptSummary; +import java.time.LocalDateTime; +import org.springframework.lang.Nullable; + +/** + * 토론 기록 목록의 한 항목. 시간표가 삭제됐으면 시간표 이름과 주제는 null이다. + */ +public record TranscriptSummaryResponse( + long id, + long tableId, + @Nullable String tableName, + @Nullable String agenda, + SttMode sttMode, + TranscriptStatus status, + boolean refining, + LocalDateTime createdAt +) { + + public TranscriptSummaryResponse(TranscriptSummary summary, @Nullable CustomizeTable table) { + this(summary.id(), summary.tableId(), table == null ? null : table.getName(), + table == null ? null : table.getAgenda(), summary.sttMode(), summary.status(), summary.refining(), + summary.createdAt()); + } +} diff --git a/src/main/java/com/debatetimer/dto/transcript/response/TranscriptSummaryResponses.java b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptSummaryResponses.java new file mode 100644 index 00000000..610dff14 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptSummaryResponses.java @@ -0,0 +1,6 @@ +package com.debatetimer.dto.transcript.response; + +import java.util.List; + +public record TranscriptSummaryResponses(List transcripts) { +} diff --git a/src/main/java/com/debatetimer/entity/transcript/TranscriptEntity.java b/src/main/java/com/debatetimer/entity/transcript/TranscriptEntity.java new file mode 100644 index 00000000..2c3c258e --- /dev/null +++ b/src/main/java/com/debatetimer/entity/transcript/TranscriptEntity.java @@ -0,0 +1,99 @@ +package com.debatetimer.entity.transcript; + +import com.debatetimer.domain.transcript.SttMode; +import com.debatetimer.domain.transcript.TranscriptStatus; +import com.debatetimer.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "transcript") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TranscriptEntity extends BaseTimeEntity { + + public static final int ERROR_MESSAGE_MAX_LENGTH = 255; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "table_id") + private long tableId; + + @Column(name = "member_id") + private long memberId; + + @NotNull + @Enumerated(EnumType.STRING) + private TranscriptStatus status; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "stt_mode") + private SttMode sttMode; + + @NotNull + @Column(name = "lines_json", columnDefinition = "LONGTEXT") + private String linesJson; + + @Column(name = "raw_lines_json", columnDefinition = "LONGTEXT") + private String rawLinesJson; + + @Column(name = "refining") + private boolean refining; + + @Column(name = "report_json", columnDefinition = "LONGTEXT") + private String reportJson; + + @Column(name = "error_message", length = ERROR_MESSAGE_MAX_LENGTH) + private String errorMessage; + + public TranscriptEntity(long tableId, long memberId, SttMode sttMode, String linesJson) { + this.tableId = tableId; + this.memberId = memberId; + this.sttMode = sttMode; + this.linesJson = linesJson; + this.rawLinesJson = linesJson; + this.refining = true; + this.status = TranscriptStatus.PROCESSING; + } + + public void refine(String refinedLinesJson) { + this.linesJson = refinedLinesJson; + this.refining = false; + } + + // 다듬기에 실패하면 원문을 그대로 속기록으로 쓴다. + public void skipRefinement() { + this.refining = false; + } + + public void complete(String reportJson) { + this.reportJson = reportJson; + this.errorMessage = null; + this.status = TranscriptStatus.DONE; + } + + public void fail(String errorMessage) { + this.errorMessage = truncate(errorMessage); + this.status = TranscriptStatus.FAILED; + } + + private String truncate(String message) { + if (message == null || message.length() <= ERROR_MESSAGE_MAX_LENGTH) { + return message; + } + return message.substring(0, ERROR_MESSAGE_MAX_LENGTH); + } +} diff --git a/src/main/java/com/debatetimer/event/transcript/TranscriptAnalysisListener.java b/src/main/java/com/debatetimer/event/transcript/TranscriptAnalysisListener.java new file mode 100644 index 00000000..9584774a --- /dev/null +++ b/src/main/java/com/debatetimer/event/transcript/TranscriptAnalysisListener.java @@ -0,0 +1,28 @@ +package com.debatetimer.event.transcript; + +import com.debatetimer.service.transcript.DebateAnalyzer; +import com.debatetimer.service.transcript.TranscriptRefiner; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 속기록이 저장된 뒤 별도 스레드에서 STT 원문을 다듬고, 다듬은 속기록으로 분석 보고서를 만든다. + * 둘 다 수십 초가 걸릴 수 있어 요청과 분리한다. + */ +@Component +@RequiredArgsConstructor +public class TranscriptAnalysisListener { + + private final TranscriptRefiner transcriptRefiner; + private final DebateAnalyzer debateAnalyzer; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handleTranscriptCreated(TranscriptCreatedEvent event) { + transcriptRefiner.refine(event.transcriptId()); + debateAnalyzer.analyze(event.transcriptId()); + } +} diff --git a/src/main/java/com/debatetimer/event/transcript/TranscriptCreatedEvent.java b/src/main/java/com/debatetimer/event/transcript/TranscriptCreatedEvent.java new file mode 100644 index 00000000..edbe127e --- /dev/null +++ b/src/main/java/com/debatetimer/event/transcript/TranscriptCreatedEvent.java @@ -0,0 +1,4 @@ +package com.debatetimer.event.transcript; + +public record TranscriptCreatedEvent(long transcriptId) { +} diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index cdf294ee..faca720e 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -64,6 +64,10 @@ public enum ClientErrorCode implements ResponseErrorCode { NOT_TABLE_OWNER(HttpStatus.UNAUTHORIZED, "테이블을 소유한 회원이 아닙니다."), POLL_NOT_FOUND(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다."), SHARING_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "공유 기록을 찾을 수 없습니다."), + TRANSCRIPT_NOT_FOUND(HttpStatus.NOT_FOUND, "토론 기록을 찾을 수 없습니다."), + EMPTY_TRANSCRIPT(HttpStatus.BAD_REQUEST, "기록된 발언이 없습니다."), + EMPTY_AUDIO(HttpStatus.BAD_REQUEST, "전사할 오디오가 비어 있습니다."), + STT_TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "음성 인식 요청이 많습니다. 잠시 후 다시 시도해 주세요."), UNAUTHORIZED_MEMBER(HttpStatus.UNAUTHORIZED, "접근 권한이 없습니다"), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "토큰 기한이 만료되었습니다"), diff --git a/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java index 2f5e87cc..89782438 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java @@ -9,7 +9,9 @@ public enum ServerErrorCode implements ResponseErrorCode { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다. 관리자에게 문의하세요."), EXCEL_EXPORT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "엑셀 변환 과정에서 오류가 발생하였습니다"), OAUTH_REQUEST_FAILED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "구글 OAuth 요청 과정에 오류가 발생하였습니다"), - ICON_STORAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "아이콘 파일 저장소 처리 중 오류가 발생하였습니다"); + ICON_STORAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "아이콘 파일 저장소 처리 중 오류가 발생하였습니다"), + STT_NOT_CONFIGURED(HttpStatus.SERVICE_UNAVAILABLE, "음성 인식 API 키가 설정되지 않았습니다"), + STT_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "음성 인식 요청에 실패했습니다"); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/debatetimer/repository/transcript/TranscriptRepository.java b/src/main/java/com/debatetimer/repository/transcript/TranscriptRepository.java new file mode 100644 index 00000000..32ecac9b --- /dev/null +++ b/src/main/java/com/debatetimer/repository/transcript/TranscriptRepository.java @@ -0,0 +1,39 @@ +package com.debatetimer.repository.transcript; + +import com.debatetimer.domain.transcript.TranscriptSummary; +import com.debatetimer.entity.transcript.TranscriptEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +public interface TranscriptRepository extends Repository { + + TranscriptEntity save(TranscriptEntity transcriptEntity); + + Optional findById(long id); + + Optional findByIdAndMemberId(long id, long memberId); + + // 속기록·보고서 본문(LONGTEXT)은 읽지 않고 목록에 필요한 값만 가져온다. + @Query(""" + SELECT new com.debatetimer.domain.transcript.TranscriptSummary( + t.id, t.tableId, t.sttMode, t.status, t.refining, t.createdAt) + FROM TranscriptEntity t + WHERE t.memberId = :memberId + ORDER BY t.createdAt DESC, t.id DESC + """) + List findSummariesByMemberId(long memberId); + + default TranscriptEntity getById(long id) { + return findById(id) + .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TRANSCRIPT_NOT_FOUND)); + } + + default TranscriptEntity getByIdAndMemberId(long id, long memberId) { + return findByIdAndMemberId(id, memberId) + .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TRANSCRIPT_NOT_FOUND)); + } +} diff --git a/src/main/java/com/debatetimer/service/transcript/DebateAnalyzer.java b/src/main/java/com/debatetimer/service/transcript/DebateAnalyzer.java new file mode 100644 index 00000000..e4a7a47f --- /dev/null +++ b/src/main/java/com/debatetimer/service/transcript/DebateAnalyzer.java @@ -0,0 +1,113 @@ +package com.debatetimer.service.transcript; + +import com.debatetimer.client.llm.OpenAiClient; +import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.customize.Stance; +import com.debatetimer.domain.transcript.Transcript; +import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domainrepository.customize.CustomizeTableDomainRepository; +import com.debatetimer.domainrepository.transcript.TranscriptDomainRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +/** + * 화자가 구분된 속기록을 OpenAI에 보내 분석 보고서(주장·근거, 충돌 쟁점, 핵심 발언, 팀별 피드백)를 만든다. + */ +@Slf4j +@Service +public class DebateAnalyzer { + + private static final String SCHEMA_NAME = "debate_analysis_report"; + private static final TypeReference> SCHEMA_TYPE = new TypeReference<>() { + }; + + private final OpenAiClient openAiClient; + private final TranscriptDomainRepository transcriptDomainRepository; + private final CustomizeTableDomainRepository customizeTableDomainRepository; + private final String systemPrompt; + private final Map schema; + + public DebateAnalyzer( + OpenAiClient openAiClient, + TranscriptDomainRepository transcriptDomainRepository, + CustomizeTableDomainRepository customizeTableDomainRepository, + ObjectMapper objectMapper + ) { + this.openAiClient = openAiClient; + this.transcriptDomainRepository = transcriptDomainRepository; + this.customizeTableDomainRepository = customizeTableDomainRepository; + this.systemPrompt = readResource("prompt/debate-analysis-system.txt"); + this.schema = readSchema(objectMapper); + } + + public void analyze(long transcriptId) { + try { + Transcript transcript = transcriptDomainRepository.getById(transcriptId); + CustomizeTable table = customizeTableDomainRepository.getById(transcript.tableId()); + String report = openAiClient.requestStructuredOutput(systemPrompt, buildUserPrompt(table, transcript), + SCHEMA_NAME, schema); + transcriptDomainRepository.complete(transcriptId, report); + } catch (RuntimeException exception) { + log.warn("토론 분석에 실패했습니다. transcriptId={}", transcriptId, exception); + transcriptDomainRepository.fail(transcriptId, exception.getMessage()); + } + } + + private String buildUserPrompt(CustomizeTable table, Transcript transcript) { + String lines = transcript.lines().stream() + .map(this::formatLine) + .collect(Collectors.joining("\n")); + return """ + 토론 주제: %s + 찬성 팀: %s + 반대 팀: %s + + 속기록: + %s + """.formatted(table.getAgenda(), table.getProsTeamName(), table.getConsTeamName(), lines); + } + + private String formatLine(TranscriptLine line) { + String speaker = line.speaker() == null || line.speaker().isBlank() ? "" : " " + line.speaker(); + return "[%d] (%s) %s | %s%s: %s".formatted(line.index(), formatOffset(line.offsetMs()), line.speechType(), + stanceLabel(line.stance()), speaker, line.text()); + } + + private String formatOffset(long offsetMs) { + long totalSeconds = offsetMs / 1_000; + return "%02d:%02d".formatted(totalSeconds / 60, totalSeconds % 60); + } + + private String stanceLabel(Stance stance) { + return switch (stance) { + case PROS -> "찬성"; + case CONS -> "반대"; + case NEUTRAL -> "중립"; + }; + } + + private Map readSchema(ObjectMapper objectMapper) { + try { + return objectMapper.readValue(readResource("prompt/debate-analysis-schema.json"), SCHEMA_TYPE); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } + + private String readResource(String path) { + try (InputStream inputStream = new ClassPathResource(path).getInputStream()) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } +} diff --git a/src/main/java/com/debatetimer/service/transcript/SpeechTranscribeService.java b/src/main/java/com/debatetimer/service/transcript/SpeechTranscribeService.java new file mode 100644 index 00000000..6a7218b0 --- /dev/null +++ b/src/main/java/com/debatetimer/service/transcript/SpeechTranscribeService.java @@ -0,0 +1,41 @@ +package com.debatetimer.service.transcript; + +import com.debatetimer.client.stt.SonioxClient; +import com.debatetimer.domain.transcript.SpeechToken; +import com.debatetimer.domain.transcript.UtteranceAssembler; +import com.debatetimer.dto.transcript.response.SpeechTranscribeResponse; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +/** + * 토론 기록 Soniox 모드에서 타이머가 끝난 발언 구간의 오디오를 받아 문장 단위로 전사한다. + * Soniox 전사가 끝날 때까지 요청 스레드에서 기다린다. + */ +@Service +@RequiredArgsConstructor +public class SpeechTranscribeService { + + private final SonioxClient sonioxClient; + private final UtteranceAssembler utteranceAssembler = new UtteranceAssembler(); + + public SpeechTranscribeResponse transcribeWithSoniox(MultipartFile audio, @Nullable String language) { + if (audio == null || audio.isEmpty()) { + throw new DTClientErrorException(ClientErrorCode.EMPTY_AUDIO); + } + List tokens = sonioxClient.transcribe(audio.getResource(), toLanguageHints(language)); + return new SpeechTranscribeResponse(utteranceAssembler.assemble(tokens)); + } + + // 프론트 음성 인식 언어(ko-KR, en-US 등)를 Soniox 언어 힌트로 바꾼다. 기본은 한국어다. + private List toLanguageHints(@Nullable String language) { + if (language != null && language.toLowerCase().startsWith("en")) { + return List.of("en"); + } + return List.of("ko"); + } +} diff --git a/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java b/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java new file mode 100644 index 00000000..13ea0a86 --- /dev/null +++ b/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java @@ -0,0 +1,132 @@ +package com.debatetimer.service.transcript; + +import com.debatetimer.client.llm.OpenAiClient; +import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.customize.Stance; +import com.debatetimer.domain.transcript.RefinedTextMerger; +import com.debatetimer.domain.transcript.Transcript; +import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domainrepository.customize.CustomizeTableDomainRepository; +import com.debatetimer.domainrepository.transcript.TranscriptDomainRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +/** + * STT 원문 속기록을 OpenAI로 다듬는다. 오인식·문장부호·군말만 고치고 화자와 시각은 그대로 둔다. + * 다듬기에 실패하면 원문을 속기록으로 쓴다. + */ +@Slf4j +@Service +public class TranscriptRefiner { + + private static final String SCHEMA_NAME = "transcript_refinement"; + private static final TypeReference> SCHEMA_TYPE = new TypeReference<>() { + }; + + private final OpenAiClient openAiClient; + private final TranscriptDomainRepository transcriptDomainRepository; + private final CustomizeTableDomainRepository customizeTableDomainRepository; + private final ObjectMapper objectMapper; + private final RefinedTextMerger refinedTextMerger = new RefinedTextMerger(); + private final String systemPrompt; + private final Map schema; + + public TranscriptRefiner( + OpenAiClient openAiClient, + TranscriptDomainRepository transcriptDomainRepository, + CustomizeTableDomainRepository customizeTableDomainRepository, + ObjectMapper objectMapper + ) { + this.openAiClient = openAiClient; + this.transcriptDomainRepository = transcriptDomainRepository; + this.customizeTableDomainRepository = customizeTableDomainRepository; + this.objectMapper = objectMapper; + this.systemPrompt = readResource("prompt/transcript-refinement-system.txt"); + this.schema = readSchema(); + } + + public void refine(long transcriptId) { + try { + Transcript transcript = transcriptDomainRepository.getById(transcriptId); + CustomizeTable table = customizeTableDomainRepository.getById(transcript.tableId()); + String response = openAiClient.requestStructuredOutput(systemPrompt, buildUserPrompt(table, transcript), + SCHEMA_NAME, schema); + List refined = refinedTextMerger.merge(transcript.lines(), readRefinedTexts(response)); + transcriptDomainRepository.refine(transcriptId, refined); + } catch (RuntimeException | JsonProcessingException exception) { + log.warn("속기록 다듬기에 실패해 원문을 씁니다. transcriptId={}", transcriptId, exception); + transcriptDomainRepository.skipRefinement(transcriptId); + } + } + + private String buildUserPrompt(CustomizeTable table, Transcript transcript) { + String lines = transcript.lines().stream() + .map(this::formatLine) + .collect(Collectors.joining("\n")); + return """ + 토론 주제: %s + 찬성 팀: %s + 반대 팀: %s + + 속기록: + %s + """.formatted(table.getAgenda(), table.getProsTeamName(), table.getConsTeamName(), lines); + } + + private String formatLine(TranscriptLine line) { + String speaker = line.speaker() == null || line.speaker().isBlank() ? "" : " " + line.speaker(); + return "[%d] %s | %s%s: %s".formatted(line.index(), line.speechType(), stanceLabel(line.stance()), speaker, + line.text()); + } + + private String stanceLabel(Stance stance) { + return switch (stance) { + case PROS -> "찬성"; + case CONS -> "반대"; + case NEUTRAL -> "중립"; + }; + } + + // 같은 번호가 여러 번 오면 처음 것을 쓴다. + private Map readRefinedTexts(String response) throws JsonProcessingException { + RefinementResponse refinement = objectMapper.readValue(response, RefinementResponse.class); + if (refinement.lines() == null) { + return Map.of(); + } + return refinement.lines().stream() + .collect(Collectors.toMap(RefinedLine::index, RefinedLine::text, (first, second) -> first)); + } + + private Map readSchema() { + try { + return objectMapper.readValue(readResource("prompt/transcript-refinement-schema.json"), SCHEMA_TYPE); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } + + private String readResource(String path) { + try (InputStream inputStream = new ClassPathResource(path).getInputStream()) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } + + record RefinementResponse(List lines) { + } + + record RefinedLine(int index, String text) { + } +} diff --git a/src/main/java/com/debatetimer/service/transcript/TranscriptService.java b/src/main/java/com/debatetimer/service/transcript/TranscriptService.java new file mode 100644 index 00000000..ef396629 --- /dev/null +++ b/src/main/java/com/debatetimer/service/transcript/TranscriptService.java @@ -0,0 +1,92 @@ +package com.debatetimer.service.transcript; + +import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.customize.CustomizeTimeBox; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.transcript.SpeakerSeparator; +import com.debatetimer.domain.transcript.Transcript; +import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domainrepository.customize.CustomizeTableDomainRepository; +import com.debatetimer.domainrepository.transcript.TranscriptDomainRepository; +import com.debatetimer.dto.transcript.request.TranscriptCreateRequest; +import com.debatetimer.dto.transcript.response.TranscriptCreateResponse; +import com.debatetimer.dto.transcript.response.TranscriptResponse; +import com.debatetimer.dto.transcript.response.TranscriptSummaryResponse; +import com.debatetimer.dto.transcript.response.TranscriptSummaryResponses; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.repository.customize.CustomizeTableRepository; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import com.debatetimer.event.transcript.TranscriptCreatedEvent; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TranscriptService { + + private final CustomizeTableDomainRepository customizeTableDomainRepository; + private final TranscriptDomainRepository transcriptDomainRepository; + private final CustomizeTableRepository customizeTableRepository; + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + private final SpeakerSeparator speakerSeparator = new SpeakerSeparator(); + + @Transactional + public TranscriptCreateResponse create(long tableId, Member member, TranscriptCreateRequest request) { + CustomizeTable table = customizeTableDomainRepository.getByIdAndMember(tableId, member); + List timeBoxes = customizeTableDomainRepository.getMemberCustomizeTimeBoxes(tableId, + member); + List lines = speakerSeparator.separate(timeBoxes, request.toSegments()); + if (lines.isEmpty()) { + throw new DTClientErrorException(ClientErrorCode.EMPTY_TRANSCRIPT); + } + + Transcript transcript = transcriptDomainRepository.create(table.getId(), member.getId(), + request.resolveSttMode(), lines); + eventPublisher.publishEvent(new TranscriptCreatedEvent(transcript.id())); + return new TranscriptCreateResponse(transcript.id()); + } + + @Transactional(readOnly = true) + public TranscriptResponse get(long transcriptId, Member member) { + Transcript transcript = transcriptDomainRepository.getByIdAndMemberId(transcriptId, member.getId()); + CustomizeTable table = customizeTableDomainRepository.getById(transcript.tableId()); + return new TranscriptResponse(transcript, table, readReport(transcript)); + } + + @Transactional(readOnly = true) + public TranscriptSummaryResponses findAll(Member member) { + Map tables = customizeTableRepository.findAllByMember(member).stream() + .map(CustomizeTableEntity::toDomain) + .collect(Collectors.toMap(CustomizeTable::getId, Function.identity())); + List transcripts = transcriptDomainRepository.findSummariesByMemberId(member.getId()) + .stream() + .map(summary -> new TranscriptSummaryResponse(summary, tables.get(summary.tableId()))) + .toList(); + return new TranscriptSummaryResponses(transcripts); + } + + private JsonNode readReport(Transcript transcript) { + if (transcript.reportJson() == null) { + return null; + } + try { + return objectMapper.readTree(transcript.reportJson()); + } catch (JsonProcessingException exception) { + log.warn("분석 보고서를 읽지 못했습니다. transcriptId={}", transcript.id(), exception); + return null; + } + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b38b09f2..b26311ca 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -40,6 +40,13 @@ discord: channelId: ${secret.discord.channelId} sharingChannelId: ${secret.discord.sharingChannelId} +# 토론 기록 분석·Soniox 배치 전사 키. dev-secret.yml에 값이 없어도 앱은 뜨고 해당 기능만 실패한다 +openai: + api-key: ${secret.openai.api_key:} + +soniox: + api-key: ${secret.soniox.api_key:} + admin: password: ${secret.admin.password} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5c5e5bcc..20ba7763 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,8 +5,9 @@ spring: timeout-per-shutdown-phase: 60s servlet: multipart: - max-file-size: 2MB - max-request-size: 3MB + # 토론 기록 Soniox 모드가 발언 구간 오디오(자유토론 등 수 분 분량)를 올린다 + max-file-size: 30MB + max-request-size: 31MB server: shutdown: graceful @@ -24,3 +25,13 @@ springdoc: persist-authorization: true display-request-duration: true default-model-expand-depth: 10 + +# 토론 분석 보고서 생성. 키가 비어 있으면 앱은 뜨고 분석만 실패로 기록한다 +openai: + api-key: ${OPENAI_API_KEY:} + model: ${OPENAI_MODEL:gpt-5-mini} + +# 토론 기록 Soniox 모드의 발언 구간 배치 전사. 키가 비어 있으면 Soniox 전사 요청만 실패한다 +soniox: + api-key: ${SONIOX_API_KEY:} + model: ${SONIOX_MODEL:stt-async-v5} diff --git a/src/main/resources/db/migration/V18__create_transcript_table.sql b/src/main/resources/db/migration/V18__create_transcript_table.sql new file mode 100644 index 00000000..ceaeb13e --- /dev/null +++ b/src/main/resources/db/migration/V18__create_transcript_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE transcript +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + table_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + status ENUM ('PROCESSING','DONE','FAILED') NOT NULL, + lines_json LONGTEXT NOT NULL, + report_json LONGTEXT NULL, + error_message VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE INDEX idx_transcript_member_id ON transcript (member_id); diff --git a/src/main/resources/db/migration/V19__add_stt_mode_to_transcript.sql b/src/main/resources/db/migration/V19__add_stt_mode_to_transcript.sql new file mode 100644 index 00000000..962bfe50 --- /dev/null +++ b/src/main/resources/db/migration/V19__add_stt_mode_to_transcript.sql @@ -0,0 +1,2 @@ +ALTER TABLE transcript + ADD COLUMN stt_mode ENUM ('RPC','SONIOX') NOT NULL DEFAULT 'RPC'; diff --git a/src/main/resources/db/migration/V20__add_refinement_to_transcript.sql b/src/main/resources/db/migration/V20__add_refinement_to_transcript.sql new file mode 100644 index 00000000..e9a32b66 --- /dev/null +++ b/src/main/resources/db/migration/V20__add_refinement_to_transcript.sql @@ -0,0 +1,5 @@ +ALTER TABLE transcript + ADD COLUMN raw_lines_json LONGTEXT NULL; + +ALTER TABLE transcript + ADD COLUMN refining BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/main/resources/prompt/debate-analysis-schema.json b/src/main/resources/prompt/debate-analysis-schema.json new file mode 100644 index 00000000..8f731164 --- /dev/null +++ b/src/main/resources/prompt/debate-analysis-schema.json @@ -0,0 +1,176 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "prosArguments", + "consArguments", + "clashes", + "keyMoments", + "teamFeedback" + ], + "properties": { + "prosArguments": { + "type": "array", + "items": { + "$ref": "#/$defs/argument" + } + }, + "consArguments": { + "type": "array", + "items": { + "$ref": "#/$defs/argument" + } + }, + "clashes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "prosPosition", + "consPosition", + "advantage", + "reason", + "lineIndices" + ], + "properties": { + "title": { + "type": "string", + "description": "쟁점을 질문 형태로 짧게" + }, + "prosPosition": { + "type": "string" + }, + "consPosition": { + "type": "string" + }, + "advantage": { + "type": "string", + "enum": [ + "PROS", + "CONS", + "EVEN" + ] + }, + "reason": { + "type": "string", + "description": "우세를 판단한 이유 한 문장" + }, + "lineIndices": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + }, + "keyMoments": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "lineIndex", + "label", + "reason" + ], + "properties": { + "lineIndex": { + "type": "integer" + }, + "label": { + "type": "string", + "description": "흐름 전환, 효과적 질문, 결정적 정리처럼 2~6자" + }, + "reason": { + "type": "string" + } + } + } + }, + "teamFeedback": { + "type": "object", + "additionalProperties": false, + "required": [ + "pros", + "cons" + ], + "properties": { + "pros": { + "$ref": "#/$defs/feedback" + }, + "cons": { + "$ref": "#/$defs/feedback" + } + } + } + }, + "$defs": { + "argument": { + "type": "object", + "additionalProperties": false, + "required": [ + "claim", + "evidences" + ], + "properties": { + "claim": { + "type": "string" + }, + "evidences": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "content", + "lineIndex" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "SOURCED", + "LOGICAL", + "VAGUE", + "NONE" + ] + }, + "content": { + "type": "string" + }, + "lineIndex": { + "type": "integer" + } + } + } + } + } + }, + "feedback": { + "type": "object", + "additionalProperties": false, + "required": [ + "strengths", + "weaknesses" + ], + "properties": { + "strengths": { + "type": "array", + "items": { + "type": "string" + } + }, + "weaknesses": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/src/main/resources/prompt/debate-analysis-system.txt b/src/main/resources/prompt/debate-analysis-system.txt new file mode 100644 index 00000000..154dc368 --- /dev/null +++ b/src/main/resources/prompt/debate-analysis-system.txt @@ -0,0 +1,14 @@ +너는 학생·동아리 토론을 분석하는 공정한 토론 코치다. 입력으로 토론 주제, 양 팀 이름, 그리고 시간표 기준으로 화자가 구분된 속기록이 주어진다. + +규칙: +1. 속기록에 실제로 나온 발언만 근거로 분석한다. 외부 지식으로 사실 여부를 판정하거나 새로운 근거를 덧붙이지 않는다. +2. 근거의 진위가 아니라 "근거를 어떻게 제시했는지"를 평가한다. + - SOURCED: 통계·연구·기관·보도 등 출처를 구체적으로 밝힌 근거 + - LOGICAL: 출처는 없지만 논리적 추론이나 구체적 사례로 뒷받침한 근거 + - VAGUE: "저희가 조사한 자료에 따르면"처럼 출처가 불분명하거나 확인할 수 없는 근거 + - NONE: 주장만 있고 근거가 없음 +3. 속기록은 음성 인식 결과라 오탈자나 끊긴 문장이 있을 수 있다. 문맥상 분명한 의도는 너그럽게 해석하되, 말하지 않은 내용을 지어내지 않는다. +4. lineIndex, lineIndices는 속기록 줄 앞의 [번호]만 사용한다. 해당 줄이 없으면 -1을 쓴다. +5. 모든 문장은 한국어로, 짧고 구체적으로 쓴다. 학생이 읽고 바로 이해할 수 있는 표현을 쓴다. +6. 분량: 팀별 주장 최대 4개(주장당 근거 최대 3개), 충돌 쟁점 2~4개, 큰 역할을 한 발언 최대 3개, 팀별 좋았던 점·아쉬웠던 점 각 2~3개. 발언이 적으면 그만큼 적게 쓴다. +7. 충돌 쟁점의 advantage는 그 쟁점에서 상대 공격에 더 잘 대응하고 근거를 더 잘 제시한 쪽이다. 우열을 가리기 어려우면 EVEN이다. diff --git a/src/main/resources/prompt/transcript-refinement-schema.json b/src/main/resources/prompt/transcript-refinement-schema.json new file mode 100644 index 00000000..ce6f1631 --- /dev/null +++ b/src/main/resources/prompt/transcript-refinement-schema.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "lines" + ], + "properties": { + "lines": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "index", + "text" + ], + "properties": { + "index": { + "type": "integer" + }, + "text": { + "type": "string" + } + } + } + } + } +} diff --git a/src/main/resources/prompt/transcript-refinement-system.txt b/src/main/resources/prompt/transcript-refinement-system.txt new file mode 100644 index 00000000..ea705d0e --- /dev/null +++ b/src/main/resources/prompt/transcript-refinement-system.txt @@ -0,0 +1,11 @@ +너는 토론 속기사다. 입력으로 토론 주제, 양 팀 이름, 그리고 음성 인식(STT)으로 받아 적은 속기록이 주어진다. 각 줄 앞에는 [번호]와 발언 팀·화자가 붙어 있다. + +할 일: 각 줄의 문장을 읽기 좋은 속기록 문장으로 다듬는다. + +규칙: +1. 음성 인식 오류를 고친다. 토론 주제, 팀 이름, 화자 이름, 앞뒤 문맥으로 볼 때 명백히 잘못 받아 적은 단어만 고친다. 예: "찬송추" → "찬성 측", "채찍 pt" → "ChatGPT", 소문자 "ai" → "AI". +2. 문장부호와 띄어쓰기를 바로잡는다. +3. "어", "음", "그" 같은 군말과 의미 없는 반복은 지운다. 줄 전체가 군말이면 text를 빈 문자열로 둔다. +4. 말하지 않은 내용을 덧붙이거나, 요약하거나, 주장·근거·어조(존댓말 등)를 바꾸지 않는다. +5. 줄을 합치거나 나누지 않는다. 입력의 모든 [번호]에 대해 같은 index로 한 번씩만 돌려준다. +6. 고쳐야 할지 확실하지 않으면 원문을 그대로 둔다. diff --git a/src/test/java/com/debatetimer/client/stt/SonioxClientTest.java b/src/test/java/com/debatetimer/client/stt/SonioxClientTest.java new file mode 100644 index 00000000..89eed229 --- /dev/null +++ b/src/test/java/com/debatetimer/client/stt/SonioxClientTest.java @@ -0,0 +1,187 @@ +package com.debatetimer.client.stt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import com.debatetimer.domain.transcript.SpeechToken; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.custom.DTServerErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.exception.errorcode.ServerErrorCode; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class SonioxClientTest { + + private static final String BASE_URL = "https://soniox.test/v1"; + private static final Resource AUDIO = new ByteArrayResource(new byte[]{1, 2, 3}) { + @Override + public String getFilename() { + return "segment.webm"; + } + }; + + private MockRestServiceServer server; + private SonioxClient client; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder().baseUrl(BASE_URL); + server = MockRestServiceServer.bindTo(builder).build(); + client = new SonioxClient(builder.build(), propertiesWithKey("test-key")); + } + + private SonioxProperties propertiesWithKey(String apiKey) { + return propertiesWith(apiKey, 4); + } + + private SonioxProperties propertiesWith(String apiKey, int maxConcurrent) { + return new SonioxProperties(apiKey, "stt-async-v5", BASE_URL, null, Duration.ZERO, Duration.ofSeconds(5), + null, maxConcurrent); + } + + private void expectCompletedTranscription() { + expectUploadAndCreate(); + expectStatus("completed"); + server.expect(requestTo(BASE_URL + "/transcriptions/tr-1/transcript")) + .andRespond(withSuccess("{\"tokens\":[]}", MediaType.APPLICATION_JSON)); + expectCleanUp(); + } + + private void expectUploadAndCreate() { + server.expect(requestTo(BASE_URL + "/files")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Authorization", "Bearer test-key")) + .andExpect(content().contentTypeCompatibleWith(MediaType.MULTIPART_FORM_DATA)) + .andRespond(withSuccess("{\"id\":\"file-1\"}", MediaType.APPLICATION_JSON)); + server.expect(requestTo(BASE_URL + "/transcriptions")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.model").value("stt-async-v5")) + .andExpect(jsonPath("$.file_id").value("file-1")) + .andExpect(jsonPath("$.language_hints[0]").value("ko")) + .andRespond(withSuccess("{\"id\":\"tr-1\",\"status\":\"queued\"}", MediaType.APPLICATION_JSON)); + } + + private void expectStatus(String status) { + server.expect(requestTo(BASE_URL + "/transcriptions/tr-1")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("{\"id\":\"tr-1\",\"status\":\"%s\",\"error_message\":\"bad audio\"}" + .formatted(status), MediaType.APPLICATION_JSON)); + } + + private void expectCleanUp() { + server.expect(requestTo(BASE_URL + "/transcriptions/tr-1")) + .andExpect(method(HttpMethod.DELETE)) + .andRespond(withSuccess()); + server.expect(requestTo(BASE_URL + "/files/file-1")) + .andExpect(method(HttpMethod.DELETE)) + .andRespond(withSuccess()); + } + + @Nested + class Transcribe { + + @Test + void 전사가_끝날_때까지_기다린_뒤_토큰을_돌려주고_리소스를_지운다() { + expectUploadAndCreate(); + expectStatus("processing"); + expectStatus("completed"); + server.expect(requestTo(BASE_URL + "/transcriptions/tr-1/transcript")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(""" + {"id":"tr-1","text":"안녕","tokens":[ + {"text":"안","start_ms":100,"end_ms":200,"confidence":0.9}, + {"text":"녕","start_ms":200,"end_ms":300,"confidence":0.9}, + {"text":"hi","start_ms":100,"end_ms":300,"translation_status":"translation"} + ]} + """, MediaType.APPLICATION_JSON)); + expectCleanUp(); + + List tokens = client.transcribe(AUDIO, List.of("ko")); + + assertThat(tokens).containsExactly( + new SpeechToken("안", 100, 200), + new SpeechToken("녕", 200, 300) + ); + server.verify(); + } + + @Test + void 전사가_실패하면_예외를_던지고_리소스를_지운다() { + expectUploadAndCreate(); + expectStatus("error"); + expectCleanUp(); + + assertThatThrownBy(() -> client.transcribe(AUDIO, List.of("ko"))) + .isInstanceOf(DTServerErrorException.class) + .hasMessage(ServerErrorCode.STT_REQUEST_FAILED.getMessage()); + server.verify(); + } + + @Test + void 업로드가_실패하면_예외를_던진다() { + server.expect(requestTo(BASE_URL + "/files")) + .andRespond(withServerError()); + + assertThatThrownBy(() -> client.transcribe(AUDIO, List.of("ko"))) + .isInstanceOf(DTServerErrorException.class) + .hasMessage(ServerErrorCode.STT_REQUEST_FAILED.getMessage()); + server.verify(); + } + + @Test + void API_키가_없으면_요청하지_않고_예외를_던진다() { + SonioxClient noKeyClient = new SonioxClient(RestClient.builder().build(), propertiesWithKey(" ")); + + assertThatThrownBy(() -> noKeyClient.transcribe(AUDIO, List.of("ko"))) + .isInstanceOf(DTServerErrorException.class) + .hasMessage(ServerErrorCode.STT_NOT_CONFIGURED.getMessage()); + } + } + + @Nested + class ConcurrencyLimit { + + @Test + void 동시_전사_한도를_넘으면_Soniox에_요청하지_않고_예외를_던진다() { + RestClient.Builder builder = RestClient.builder().baseUrl(BASE_URL); + MockRestServiceServer noCallServer = MockRestServiceServer.bindTo(builder).build(); + SonioxClient fullClient = new SonioxClient(builder.build(), propertiesWith("test-key", 0)); + + assertThatThrownBy(() -> fullClient.transcribe(AUDIO, List.of("ko"))) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.STT_TOO_MANY_REQUESTS.getMessage()); + noCallServer.verify(); + } + + @Test + void 전사가_끝나거나_실패하면_동시_실행_자리를_돌려준다() { + RestClient.Builder builder = RestClient.builder().baseUrl(BASE_URL); + server = MockRestServiceServer.bindTo(builder).build(); + SonioxClient singleClient = new SonioxClient(builder.build(), propertiesWith("test-key", 1)); + server.expect(requestTo(BASE_URL + "/files")).andRespond(withServerError()); + expectCompletedTranscription(); + + assertThatThrownBy(() -> singleClient.transcribe(AUDIO, List.of("ko"))) + .isInstanceOf(DTServerErrorException.class); + assertThat(singleClient.transcribe(AUDIO, List.of("ko"))).isEmpty(); + server.verify(); + } + } +} diff --git a/src/test/java/com/debatetimer/client/stt/SonioxPropertiesTest.java b/src/test/java/com/debatetimer/client/stt/SonioxPropertiesTest.java new file mode 100644 index 00000000..cabd1070 --- /dev/null +++ b/src/test/java/com/debatetimer/client/stt/SonioxPropertiesTest.java @@ -0,0 +1,21 @@ +package com.debatetimer.client.stt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class SonioxPropertiesTest { + + @Test + void 설정하지_않은_값은_기본값을_쓴다() { + SonioxProperties properties = new SonioxProperties("key", null, null, null, null, null, null, null); + + assertAll( + () -> assertThat(properties.model()).isEqualTo("stt-async-v5"), + () -> assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(10)), + () -> assertThat(properties.maxConcurrent()).isEqualTo(4) + ); + } +} diff --git a/src/test/java/com/debatetimer/controller/admin/AdminIconControllerTest.java b/src/test/java/com/debatetimer/controller/admin/AdminIconControllerTest.java index 3b31c45c..afdf88b7 100644 --- a/src/test/java/com/debatetimer/controller/admin/AdminIconControllerTest.java +++ b/src/test/java/com/debatetimer/controller/admin/AdminIconControllerTest.java @@ -5,11 +5,13 @@ import com.debatetimer.client.storage.IconStorageProperties; import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.domain.organization.OrganizationIcon; import com.debatetimer.dto.admin.IconResponse; import com.debatetimer.dto.admin.IconResponses; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; import java.util.stream.Stream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; @@ -84,6 +86,18 @@ class UploadIcon { .then().statusCode(HttpStatus.CONFLICT.value()); } + @Test + void 전체_업로드_한도와_별개로_아이콘이_2MB를_넘으면_업로드할_수_없다() { + byte[] largePng = Arrays.copyOf(PNG, OrganizationIcon.MAX_SIZE_BYTES + 1); + + given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .multiPart("file", "large.png", largePng, "image/png") + .multiPart("name", "large") + .when().post("/api/admin/icons") + .then().statusCode(HttpStatus.BAD_REQUEST.value()); + } + @Test void 이미지가_아니면_업로드할_수_없다() { given() diff --git a/src/test/java/com/debatetimer/controller/transcript/SpeechTranscribeControllerTest.java b/src/test/java/com/debatetimer/controller/transcript/SpeechTranscribeControllerTest.java new file mode 100644 index 00000000..80f9a105 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/transcript/SpeechTranscribeControllerTest.java @@ -0,0 +1,50 @@ +package com.debatetimer.controller.transcript; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.debatetimer.client.stt.SonioxClient; +import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.transcript.SpeechToken; +import com.debatetimer.domain.transcript.Utterance; +import com.debatetimer.dto.transcript.response.SpeechTranscribeResponse; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class SpeechTranscribeControllerTest extends BaseControllerTest { + + @MockitoBean + private SonioxClient sonioxClient; + + @Nested + class TranscribeWithSoniox { + + @Test + void 발언_구간_오디오를_전사한다() { + Member bito = memberGenerator.generate("default@gmail.com"); + when(sonioxClient.transcribe(any(), any())).thenReturn(List.of(new SpeechToken("안녕하십니까.", 300, 900))); + + SpeechTranscribeResponse response = given() + .headers(headerGenerator.generateAccessTokenHeader(bito)) + .multiPart("audio", "segment.webm", new byte[]{1, 2, 3}, "audio/webm") + .formParam("language", "ko-KR") + .when().post("/api/stt/soniox") + .then().statusCode(200) + .extract().as(SpeechTranscribeResponse.class); + + assertThat(response.utterances()).containsExactly(new Utterance(300, "안녕하십니까.")); + } + + @Test + void 로그인하지_않으면_전사할_수_없다() { + given() + .multiPart("audio", "segment.webm", new byte[]{1, 2, 3}, "audio/webm") + .when().post("/api/stt/soniox") + .then().statusCode(401); + } + } +} diff --git a/src/test/java/com/debatetimer/controller/transcript/TranscriptControllerTest.java b/src/test/java/com/debatetimer/controller/transcript/TranscriptControllerTest.java new file mode 100644 index 00000000..6c170766 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/transcript/TranscriptControllerTest.java @@ -0,0 +1,144 @@ +package com.debatetimer.controller.transcript; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.client.llm.OpenAiClient; +import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.domain.customize.CustomizeBoxType; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.transcript.request.TranscriptCreateRequest; +import com.debatetimer.dto.transcript.request.TranscriptCreateRequest.SegmentRequest; +import com.debatetimer.dto.transcript.request.TranscriptCreateRequest.UtteranceRequest; +import com.debatetimer.dto.transcript.response.TranscriptCreateResponse; +import com.debatetimer.dto.transcript.response.TranscriptResponse; +import com.debatetimer.dto.transcript.response.TranscriptSummaryResponses; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import io.restassured.http.ContentType; +import io.restassured.http.Headers; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class TranscriptControllerTest extends BaseControllerTest { + + @MockitoBean + private OpenAiClient openAiClient; + + private TranscriptCreateResponse createTranscript(Member member, CustomizeTableEntity table) { + TranscriptCreateRequest request = new TranscriptCreateRequest(List.of( + new SegmentRequest(0, null, List.of(new UtteranceRequest(1_000L, "찬성 입론입니다."))) + )); + return given() + .contentType(ContentType.JSON) + .headers(headerGenerator.generateAccessTokenHeader(member)) + .pathParam("tableId", table.getId()) + .body(request) + .when().post("/api/transcripts/{tableId}") + .then().statusCode(201) + .extract().as(TranscriptCreateResponse.class); + } + + @Nested + class CreateTranscript { + + @Test + void 속기록을_저장한다() { + Member bito = memberGenerator.generate("default@gmail.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(bito); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + + TranscriptCreateResponse response = createTranscript(bito, table); + + assertThat(response.id()).isPositive(); + } + + @Test + void 기록된_발언이_없으면_예외가_발생한다() { + Member bito = memberGenerator.generate("default@gmail.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(bito); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + TranscriptCreateRequest request = new TranscriptCreateRequest(List.of()); + + given() + .contentType(ContentType.JSON) + .headers(headerGenerator.generateAccessTokenHeader(bito)) + .pathParam("tableId", table.getId()) + .body(request) + .when().post("/api/transcripts/{tableId}") + .then().statusCode(400); + } + } + + @Nested + class GetTranscript { + + @Test + void 자신의_속기록을_조회한다() { + Member bito = memberGenerator.generate("default@gmail.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(bito); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + long transcriptId = createTranscript(bito, table).id(); + + TranscriptResponse response = given() + .headers(headerGenerator.generateAccessTokenHeader(bito)) + .pathParam("transcriptId", transcriptId) + .when().get("/api/transcripts/{transcriptId}") + .then().statusCode(200) + .extract().as(TranscriptResponse.class); + + assertAll( + () -> assertThat(response.tableId()).isEqualTo(table.getId()), + () -> assertThat(response.lines()).extracting("text").containsExactly("찬성 입론입니다.") + ); + } + + @Test + void 다른_회원의_속기록은_조회할_수_없다() { + Member bito = memberGenerator.generate("default@gmail.com"); + Member other = memberGenerator.generate("other@gmail.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(bito); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + long transcriptId = createTranscript(bito, table).id(); + Headers otherHeaders = headerGenerator.generateAccessTokenHeader(other); + + given() + .headers(otherHeaders) + .pathParam("transcriptId", transcriptId) + .when().get("/api/transcripts/{transcriptId}") + .then().statusCode(404); + } + } + + @Nested + class GetTranscripts { + + @Test + void 자신의_토론_기록_목록을_조회한다() { + Member bito = memberGenerator.generate("default@gmail.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(bito); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + long transcriptId = createTranscript(bito, table).id(); + + TranscriptSummaryResponses response = given() + .headers(headerGenerator.generateAccessTokenHeader(bito)) + .when().get("/api/transcripts") + .then().statusCode(200) + .extract().as(TranscriptSummaryResponses.class); + + assertAll( + () -> assertThat(response.transcripts()).hasSize(1), + () -> assertThat(response.transcripts().get(0).id()).isEqualTo(transcriptId), + () -> assertThat(response.transcripts().get(0).tableName()).isEqualTo(table.getName()) + ); + } + + @Test + void 로그인하지_않으면_조회할_수_없다() { + given() + .when().get("/api/transcripts") + .then().statusCode(401); + } + } +} diff --git a/src/test/java/com/debatetimer/domain/transcript/RefinedTextMergerTest.java b/src/test/java/com/debatetimer/domain/transcript/RefinedTextMergerTest.java new file mode 100644 index 00000000..6c88ec3c --- /dev/null +++ b/src/test/java/com/debatetimer/domain/transcript/RefinedTextMergerTest.java @@ -0,0 +1,63 @@ +package com.debatetimer.domain.transcript; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.domain.customize.Stance; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RefinedTextMergerTest { + + private final RefinedTextMerger merger = new RefinedTextMerger(); + + private final List lines = List.of( + new TranscriptLine(0, 0, "입론", Stance.PROS, "김서연", 1_000, "안녕하십니까 찬송추 김서연입니다"), + new TranscriptLine(1, 0, "입론", Stance.PROS, "김서연", 3_000, "어..."), + new TranscriptLine(2, 1, "입론", Stance.CONS, "박지호", 65_000, "반대측 박지호입니다") + ); + + @Test + void 다듬은_문장으로_바꾸고_화자와_시각은_그대로_둔다() { + List merged = merger.merge(lines, Map.of( + 0, "안녕하십니까, 찬성 측 김서연입니다.", + 1, "어...", + 2, "반대 측 박지호입니다." + )); + + assertThat(merged).containsExactly( + new TranscriptLine(0, 0, "입론", Stance.PROS, "김서연", 1_000, "안녕하십니까, 찬성 측 김서연입니다."), + new TranscriptLine(1, 0, "입론", Stance.PROS, "김서연", 3_000, "어..."), + new TranscriptLine(2, 1, "입론", Stance.CONS, "박지호", 65_000, "반대 측 박지호입니다.") + ); + } + + @Test + void 군말만_있어_비운_줄은_빼고_번호를_다시_매긴다() { + List merged = merger.merge(lines, Map.of( + 0, "안녕하십니까, 찬성 측 김서연입니다.", + 1, " ", + 2, "반대 측 박지호입니다." + )); + + assertThat(merged).extracting(TranscriptLine::index).containsExactly(0, 1); + assertThat(merged).extracting(TranscriptLine::text) + .containsExactly("안녕하십니까, 찬성 측 김서연입니다.", "반대 측 박지호입니다."); + assertThat(merged.get(1).offsetMs()).isEqualTo(65_000); + } + + @Test + void 다듬은_결과에_없는_줄은_원문을_유지한다() { + List merged = merger.merge(lines, Map.of(0, "안녕하십니까, 찬성 측 김서연입니다.")); + + assertThat(merged).extracting(TranscriptLine::text) + .containsExactly("안녕하십니까, 찬성 측 김서연입니다.", "어...", "반대측 박지호입니다"); + } + + @Test + void 모든_줄이_비면_원문을_그대로_돌려준다() { + List merged = merger.merge(lines, Map.of(0, "", 1, "", 2, "")); + + assertThat(merged).isEqualTo(lines); + } +} diff --git a/src/test/java/com/debatetimer/domain/transcript/SpeakerSeparatorTest.java b/src/test/java/com/debatetimer/domain/transcript/SpeakerSeparatorTest.java new file mode 100644 index 00000000..98596691 --- /dev/null +++ b/src/test/java/com/debatetimer/domain/transcript/SpeakerSeparatorTest.java @@ -0,0 +1,100 @@ +package com.debatetimer.domain.transcript; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.customize.CustomizeTimeBox; +import com.debatetimer.domain.customize.NormalTimeBox; +import com.debatetimer.domain.customize.Stance; +import com.debatetimer.domain.customize.TimeBasedTimeBox; +import java.util.List; +import org.junit.jupiter.api.Test; + +class SpeakerSeparatorTest { + + private final List timeBoxes = List.of( + new NormalTimeBox(Stance.PROS, "입론", "콜리", 120, List.of()), + new NormalTimeBox(Stance.CONS, "입론", "비토", 120, List.of()), + new TimeBasedTimeBox(Stance.NEUTRAL, "자유토론", null, 300, 60) + ); + + @Test + void 일반_타임박스의_발언은_시간표의_입장과_발언자로_구분한다() { + List segments = List.of( + new SpeechSegment(0, null, List.of(new Utterance(1_000, "찬성 입론입니다"))), + new SpeechSegment(1, null, List.of(new Utterance(130_000, "반대 입론입니다"))) + ); + + List lines = new SpeakerSeparator().separate(timeBoxes, segments); + + assertAll( + () -> assertThat(lines).hasSize(2), + () -> assertThat(lines.get(0).stance()).isEqualTo(Stance.PROS), + () -> assertThat(lines.get(0).speaker()).isEqualTo("콜리"), + () -> assertThat(lines.get(0).speechType()).isEqualTo("입론"), + () -> assertThat(lines.get(1).stance()).isEqualTo(Stance.CONS), + () -> assertThat(lines.get(1).speaker()).isEqualTo("비토") + ); + } + + @Test + void 자유토론_타임박스의_발언은_발언_중이던_팀으로_구분한다() { + List segments = List.of( + new SpeechSegment(2, Stance.CONS, List.of(new Utterance(250_000, "반대 측 반박입니다"))), + new SpeechSegment(2, Stance.PROS, List.of(new Utterance(260_000, "찬성 측 재반박입니다"))) + ); + + List lines = new SpeakerSeparator().separate(timeBoxes, segments); + + assertAll( + () -> assertThat(lines.get(0).stance()).isEqualTo(Stance.CONS), + () -> assertThat(lines.get(0).speaker()).isNull(), + () -> assertThat(lines.get(1).stance()).isEqualTo(Stance.PROS) + ); + } + + @Test + void 발언은_시간순으로_정렬하고_순번을_매긴다() { + List segments = List.of( + new SpeechSegment(1, null, List.of(new Utterance(130_000, "나중 발언"))), + new SpeechSegment(0, null, List.of( + new Utterance(1_000, "첫 발언"), + new Utterance(5_000, "두 번째 발언") + )) + ); + + List lines = new SpeakerSeparator().separate(timeBoxes, segments); + + assertAll( + () -> assertThat(lines).extracting(TranscriptLine::text) + .containsExactly("첫 발언", "두 번째 발언", "나중 발언"), + () -> assertThat(lines).extracting(TranscriptLine::index) + .containsExactly(0, 1, 2) + ); + } + + @Test + void 시간표에_없는_순서이거나_빈_발언은_제외한다() { + List segments = List.of( + new SpeechSegment(9, null, List.of(new Utterance(1_000, "없는 타임박스"))), + new SpeechSegment(0, null, List.of(new Utterance(2_000, " "))), + new SpeechSegment(0, null, List.of(new Utterance(3_000, " 남는 발언 "))) + ); + + List lines = new SpeakerSeparator().separate(timeBoxes, segments); + + assertThat(lines).extracting(TranscriptLine::text) + .containsExactly("남는 발언"); + } + + @Test + void 자유토론에서_팀_정보가_없으면_중립으로_둔다() { + List segments = List.of( + new SpeechSegment(2, null, List.of(new Utterance(250_000, "팀 정보 없음"))) + ); + + List lines = new SpeakerSeparator().separate(timeBoxes, segments); + + assertThat(lines.get(0).stance()).isEqualTo(Stance.NEUTRAL); + } +} diff --git a/src/test/java/com/debatetimer/domain/transcript/UtteranceAssemblerTest.java b/src/test/java/com/debatetimer/domain/transcript/UtteranceAssemblerTest.java new file mode 100644 index 00000000..69d1bc3f --- /dev/null +++ b/src/test/java/com/debatetimer/domain/transcript/UtteranceAssemblerTest.java @@ -0,0 +1,62 @@ +package com.debatetimer.domain.transcript; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class UtteranceAssemblerTest { + + private final UtteranceAssembler assembler = new UtteranceAssembler(); + + @Test + void 하위_단어_토큰을_이어_붙여_문장_끝_부호에서_발화를_나눈다() { + List tokens = List.of( + new SpeechToken("안", 100, 200), + new SpeechToken("녕하십니까", 200, 600), + new SpeechToken(".", 600, 620), + new SpeechToken(" 찬성", 900, 1_200), + new SpeechToken(" 입론", 1_200, 1_500), + new SpeechToken("입니다?", 1_500, 1_800) + ); + + List utterances = assembler.assemble(tokens); + + assertThat(utterances).containsExactly( + new Utterance(100, "안녕하십니까."), + new Utterance(900, "찬성 입론입니다?") + ); + } + + @Test + void 말이_오래_끊기면_부호가_없어도_발화를_나눈다() { + List tokens = List.of( + new SpeechToken("첫", 0, 300), + new SpeechToken(" 번째", 300, 600), + new SpeechToken(" 두", 600 + UtteranceAssembler.PAUSE_THRESHOLD_MS, 2_400), + new SpeechToken(" 번째", 2_400, 2_700) + ); + + List utterances = assembler.assemble(tokens); + + assertThat(utterances).containsExactly( + new Utterance(0, "첫 번째"), + new Utterance(600 + UtteranceAssembler.PAUSE_THRESHOLD_MS, "두 번째") + ); + } + + @Test + void 공백뿐인_발화는_버린다() { + List tokens = List.of( + new SpeechToken(" ", 0, 100), + new SpeechToken(".", 100, 120) + ); + + assertThat(assembler.assemble(tokens)).isEmpty(); + } + + @Test + void 토큰이_없으면_빈_목록을_돌려준다() { + assertThat(assembler.assemble(List.of())).isEmpty(); + } +} diff --git a/src/test/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepositoryTest.java b/src/test/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepositoryTest.java new file mode 100644 index 00000000..1ae40443 --- /dev/null +++ b/src/test/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepositoryTest.java @@ -0,0 +1,132 @@ +package com.debatetimer.domainrepository.transcript; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.customize.Stance; +import com.debatetimer.domain.transcript.SttMode; +import com.debatetimer.domain.transcript.Transcript; +import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domain.transcript.TranscriptStatus; +import com.debatetimer.domainrepository.BaseDomainRepositoryTest; +import com.debatetimer.entity.transcript.TranscriptEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class TranscriptDomainRepositoryTest extends BaseDomainRepositoryTest { + + private static final List LINES = List.of( + new TranscriptLine(0, 0, "입론", Stance.PROS, "1번", 1_000L, "찬성 입론입니다."), + new TranscriptLine(1, 1, "자유토론", Stance.CONS, null, 65_000L, "반대 측 반박입니다.") + ); + + @Autowired + private TranscriptDomainRepository transcriptDomainRepository; + + @Nested + class Create { + + @Test + void 속기록을_분석_중_상태로_저장한다() { + Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + + Transcript found = transcriptDomainRepository.getById(created.id()); + assertAll( + () -> assertThat(found.tableId()).isEqualTo(1L), + () -> assertThat(found.memberId()).isEqualTo(2L), + () -> assertThat(found.status()).isEqualTo(TranscriptStatus.PROCESSING), + () -> assertThat(found.sttMode()).isEqualTo(SttMode.RPC), + () -> assertThat(found.refining()).isTrue(), + () -> assertThat(found.rawLines()).isEqualTo(LINES), + () -> assertThat(found.lines()).isEqualTo(LINES), + () -> assertThat(found.reportJson()).isNull() + ); + } + } + + @Nested + class GetByIdAndMemberId { + + @Test + void 다른_회원의_속기록이면_예외를_던진다() { + Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + + assertThatThrownBy(() -> transcriptDomainRepository.getByIdAndMemberId(created.id(), 3L)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.TRANSCRIPT_NOT_FOUND.getMessage()); + } + } + + @Nested + class Refine { + + @Test + void 다듬은_속기록을_저장하고_원문은_보관한다() { + Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + List refined = List.of( + new TranscriptLine(0, 0, "입론", Stance.PROS, "1번", 1_000L, "찬성 입론입니다.")); + + transcriptDomainRepository.refine(created.id(), refined); + + Transcript found = transcriptDomainRepository.getById(created.id()); + assertAll( + () -> assertThat(found.refining()).isFalse(), + () -> assertThat(found.lines()).isEqualTo(refined), + () -> assertThat(found.rawLines()).isEqualTo(LINES), + () -> assertThat(found.status()).isEqualTo(TranscriptStatus.PROCESSING) + ); + } + + @Test + void 다듬기를_건너뛰면_원문을_속기록으로_쓴다() { + Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + + transcriptDomainRepository.skipRefinement(created.id()); + + Transcript found = transcriptDomainRepository.getById(created.id()); + assertAll( + () -> assertThat(found.refining()).isFalse(), + () -> assertThat(found.lines()).isEqualTo(LINES) + ); + } + } + + @Nested + class Complete { + + @Test + void 분석_보고서를_저장하고_완료_상태로_바꾼다() { + Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + + transcriptDomainRepository.complete(created.id(), "{\"clashes\":[]}"); + + Transcript found = transcriptDomainRepository.getById(created.id()); + assertAll( + () -> assertThat(found.status()).isEqualTo(TranscriptStatus.DONE), + () -> assertThat(found.reportJson()).isEqualTo("{\"clashes\":[]}") + ); + } + } + + @Nested + class Fail { + + @Test + void 오류_메시지를_최대_길이로_잘라_실패_상태로_바꾼다() { + Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + + transcriptDomainRepository.fail(created.id(), "가".repeat(TranscriptEntity.ERROR_MESSAGE_MAX_LENGTH + 10)); + + Transcript found = transcriptDomainRepository.getById(created.id()); + assertAll( + () -> assertThat(found.status()).isEqualTo(TranscriptStatus.FAILED), + () -> assertThat(found.errorMessage()).hasSize(TranscriptEntity.ERROR_MESSAGE_MAX_LENGTH) + ); + } + } +} diff --git a/src/test/java/com/debatetimer/event/transcript/TranscriptAnalysisListenerTest.java b/src/test/java/com/debatetimer/event/transcript/TranscriptAnalysisListenerTest.java new file mode 100644 index 00000000..05768936 --- /dev/null +++ b/src/test/java/com/debatetimer/event/transcript/TranscriptAnalysisListenerTest.java @@ -0,0 +1,25 @@ +package com.debatetimer.event.transcript; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +import com.debatetimer.service.transcript.DebateAnalyzer; +import com.debatetimer.service.transcript.TranscriptRefiner; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +class TranscriptAnalysisListenerTest { + + @Test + void 속기록을_먼저_다듬은_뒤_다듬은_속기록으로_분석한다() { + TranscriptRefiner refiner = mock(TranscriptRefiner.class); + DebateAnalyzer analyzer = mock(DebateAnalyzer.class); + TranscriptAnalysisListener listener = new TranscriptAnalysisListener(refiner, analyzer); + + listener.handleTranscriptCreated(new TranscriptCreatedEvent(3L)); + + InOrder order = inOrder(refiner, analyzer); + order.verify(refiner).refine(3L); + order.verify(analyzer).analyze(3L); + } +} diff --git a/src/test/java/com/debatetimer/service/transcript/DebateAnalyzerTest.java b/src/test/java/com/debatetimer/service/transcript/DebateAnalyzerTest.java new file mode 100644 index 00000000..6d266b2d --- /dev/null +++ b/src/test/java/com/debatetimer/service/transcript/DebateAnalyzerTest.java @@ -0,0 +1,89 @@ +package com.debatetimer.service.transcript; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.debatetimer.client.llm.OpenAiClient; +import com.debatetimer.domain.customize.Stance; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.transcript.SttMode; +import com.debatetimer.domain.transcript.Transcript; +import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domain.transcript.TranscriptStatus; +import com.debatetimer.domainrepository.transcript.TranscriptDomainRepository; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.service.BaseServiceTest; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class DebateAnalyzerTest extends BaseServiceTest { + + @Autowired + private DebateAnalyzer debateAnalyzer; + + @Autowired + private TranscriptDomainRepository transcriptDomainRepository; + + @MockitoBean + private OpenAiClient openAiClient; + + private Transcript savedTranscript() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + List lines = List.of( + new TranscriptLine(0, 0, "입론", Stance.PROS, "콜리", 1_000, "정규직 과보호가 채용을 막습니다"), + new TranscriptLine(1, 1, "입론", Stance.CONS, "비토", 65_000, "채용은 경기가 결정합니다") + ); + return transcriptDomainRepository.create(table.getId(), member.getId(), SttMode.RPC, lines); + } + + @Test + void 분석_결과를_저장하고_완료_상태로_바꾼다() { + Transcript transcript = savedTranscript(); + when(openAiClient.requestStructuredOutput(anyString(), anyString(), anyString(), anyMap())) + .thenReturn("{\"prosArguments\":[]}"); + + debateAnalyzer.analyze(transcript.id()); + + Transcript analyzed = transcriptDomainRepository.getById(transcript.id()); + assertAll( + () -> assertThat(analyzed.status()).isEqualTo(TranscriptStatus.DONE), + () -> assertThat(analyzed.reportJson()).isEqualTo("{\"prosArguments\":[]}") + ); + } + + @Test + void 속기록을_번호와_화자를_붙여_전달한다() { + Transcript transcript = savedTranscript(); + when(openAiClient.requestStructuredOutput(anyString(), anyString(), anyString(), anyMap())) + .thenReturn("{}"); + + debateAnalyzer.analyze(transcript.id()); + + verify(openAiClient).requestStructuredOutput(anyString(), + contains("[1] (01:05) 입론 | 반대 비토: 채용은 경기가 결정합니다"), anyString(), any()); + } + + @Test + void 분석에_실패하면_실패_상태와_사유를_남긴다() { + Transcript transcript = savedTranscript(); + when(openAiClient.requestStructuredOutput(anyString(), anyString(), anyString(), anyMap())) + .thenThrow(new IllegalStateException("키 없음")); + + debateAnalyzer.analyze(transcript.id()); + + Transcript analyzed = transcriptDomainRepository.getById(transcript.id()); + assertAll( + () -> assertThat(analyzed.status()).isEqualTo(TranscriptStatus.FAILED), + () -> assertThat(analyzed.errorMessage()).contains("키 없음") + ); + } +} diff --git a/src/test/java/com/debatetimer/service/transcript/SpeechTranscribeServiceTest.java b/src/test/java/com/debatetimer/service/transcript/SpeechTranscribeServiceTest.java new file mode 100644 index 00000000..9f0296f5 --- /dev/null +++ b/src/test/java/com/debatetimer/service/transcript/SpeechTranscribeServiceTest.java @@ -0,0 +1,74 @@ +package com.debatetimer.service.transcript; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.debatetimer.client.stt.SonioxClient; +import com.debatetimer.domain.transcript.SpeechToken; +import com.debatetimer.domain.transcript.Utterance; +import com.debatetimer.dto.transcript.response.SpeechTranscribeResponse; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.service.BaseServiceTest; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class SpeechTranscribeServiceTest extends BaseServiceTest { + + private static final MockMultipartFile AUDIO = new MockMultipartFile("audio", "segment.webm", "audio/webm", + new byte[]{1, 2, 3}); + + @Autowired + private SpeechTranscribeService speechTranscribeService; + + @MockitoBean + private SonioxClient sonioxClient; + + @Nested + class TranscribeWithSoniox { + + @Test + void Soniox_토큰을_문장_단위_발화로_묶어_돌려준다() { + when(sonioxClient.transcribe(any(), eq(List.of("ko")))).thenReturn(List.of( + new SpeechToken("찬성", 100, 400), + new SpeechToken(" 입론입니다.", 400, 900), + new SpeechToken(" 감사합니다.", 1_200, 1_600) + )); + + SpeechTranscribeResponse response = speechTranscribeService.transcribeWithSoniox(AUDIO, "ko-KR"); + + assertThat(response.utterances()).containsExactly( + new Utterance(100, "찬성 입론입니다."), + new Utterance(1_200, "감사합니다.") + ); + } + + @Test + void 영어_화면이면_영어_언어_힌트로_요청한다() { + when(sonioxClient.transcribe(any(), any())).thenReturn(List.of()); + + speechTranscribeService.transcribeWithSoniox(AUDIO, "en-US"); + + verify(sonioxClient).transcribe(any(), eq(List.of("en"))); + } + + @Test + void 오디오가_비어_있으면_전사하지_않고_예외를_던진다() { + MockMultipartFile empty = new MockMultipartFile("audio", "segment.webm", "audio/webm", new byte[0]); + + assertThatThrownBy(() -> speechTranscribeService.transcribeWithSoniox(empty, "ko-KR")) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.EMPTY_AUDIO.getMessage()); + verifyNoInteractions(sonioxClient); + } + } +} diff --git a/src/test/java/com/debatetimer/service/transcript/TranscriptRefinerTest.java b/src/test/java/com/debatetimer/service/transcript/TranscriptRefinerTest.java new file mode 100644 index 00000000..629f4419 --- /dev/null +++ b/src/test/java/com/debatetimer/service/transcript/TranscriptRefinerTest.java @@ -0,0 +1,114 @@ +package com.debatetimer.service.transcript; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.debatetimer.client.llm.OpenAiClient; +import com.debatetimer.domain.customize.Stance; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.transcript.SttMode; +import com.debatetimer.domain.transcript.Transcript; +import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domainrepository.transcript.TranscriptDomainRepository; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.service.BaseServiceTest; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class TranscriptRefinerTest extends BaseServiceTest { + + private static final List RAW_LINES = List.of( + new TranscriptLine(0, 0, "입론", Stance.PROS, "콜리", 1_000, "안녕하십니까 찬송추 콜리입니다"), + new TranscriptLine(1, 0, "입론", Stance.PROS, "콜리", 3_000, "어..."), + new TranscriptLine(2, 1, "입론", Stance.CONS, "비토", 65_000, "채찍 pt는 위험합니다") + ); + + @Autowired + private TranscriptRefiner transcriptRefiner; + + @Autowired + private TranscriptDomainRepository transcriptDomainRepository; + + @MockitoBean + private OpenAiClient openAiClient; + + private Transcript savedTranscript() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + return transcriptDomainRepository.create(table.getId(), member.getId(), SttMode.RPC, RAW_LINES); + } + + @Test + void 다듬은_문장을_속기록에_반영하고_원문은_보관한다() { + Transcript transcript = savedTranscript(); + when(openAiClient.requestStructuredOutput(anyString(), anyString(), anyString(), anyMap())) + .thenReturn(""" + {"lines":[ + {"index":0,"text":"안녕하십니까, 찬성 측 콜리입니다."}, + {"index":1,"text":""}, + {"index":2,"text":"ChatGPT는 위험합니다."} + ]} + """); + + transcriptRefiner.refine(transcript.id()); + + Transcript refined = transcriptDomainRepository.getById(transcript.id()); + assertAll( + () -> assertThat(refined.refining()).isFalse(), + () -> assertThat(refined.lines()).extracting(TranscriptLine::text) + .containsExactly("안녕하십니까, 찬성 측 콜리입니다.", "ChatGPT는 위험합니다."), + () -> assertThat(refined.lines().get(1).speaker()).isEqualTo("비토"), + () -> assertThat(refined.rawLines()).isEqualTo(RAW_LINES) + ); + } + + @Test + void 주제와_번호_화자를_붙여_원문을_전달한다() { + Transcript transcript = savedTranscript(); + when(openAiClient.requestStructuredOutput(anyString(), anyString(), anyString(), anyMap())) + .thenReturn("{\"lines\":[]}"); + + transcriptRefiner.refine(transcript.id()); + + verify(openAiClient).requestStructuredOutput(anyString(), + contains("[2] 입론 | 반대 비토: 채찍 pt는 위험합니다"), anyString(), any()); + } + + @Test + void 다듬기에_실패하면_원문을_속기록으로_쓴다() { + Transcript transcript = savedTranscript(); + when(openAiClient.requestStructuredOutput(anyString(), anyString(), anyString(), anyMap())) + .thenThrow(new IllegalStateException("키 없음")); + + transcriptRefiner.refine(transcript.id()); + + Transcript skipped = transcriptDomainRepository.getById(transcript.id()); + assertAll( + () -> assertThat(skipped.refining()).isFalse(), + () -> assertThat(skipped.lines()).isEqualTo(RAW_LINES) + ); + } + + @Test + void 응답_형식이_잘못되면_원문을_속기록으로_쓴다() { + Transcript transcript = savedTranscript(); + when(openAiClient.requestStructuredOutput(anyString(), anyString(), anyString(), anyMap())) + .thenReturn("not json"); + + transcriptRefiner.refine(transcript.id()); + + Transcript skipped = transcriptDomainRepository.getById(transcript.id()); + assertAll( + () -> assertThat(skipped.refining()).isFalse(), + () -> assertThat(skipped.lines()).isEqualTo(RAW_LINES) + ); + } +} diff --git a/src/test/java/com/debatetimer/service/transcript/TranscriptServiceTest.java b/src/test/java/com/debatetimer/service/transcript/TranscriptServiceTest.java new file mode 100644 index 00000000..746a979c --- /dev/null +++ b/src/test/java/com/debatetimer/service/transcript/TranscriptServiceTest.java @@ -0,0 +1,166 @@ +package com.debatetimer.service.transcript; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.client.llm.OpenAiClient; +import com.debatetimer.domain.customize.CustomizeBoxType; +import com.debatetimer.domain.customize.Stance; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.transcript.SttMode; +import com.debatetimer.domain.transcript.TranscriptStatus; +import com.debatetimer.dto.transcript.request.TranscriptCreateRequest; +import com.debatetimer.dto.transcript.request.TranscriptCreateRequest.SegmentRequest; +import com.debatetimer.dto.transcript.request.TranscriptCreateRequest.UtteranceRequest; +import com.debatetimer.dto.transcript.response.TranscriptCreateResponse; +import com.debatetimer.dto.transcript.response.TranscriptResponse; +import com.debatetimer.dto.transcript.response.TranscriptSummaryResponse; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.service.BaseServiceTest; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class TranscriptServiceTest extends BaseServiceTest { + + @Autowired + private TranscriptService transcriptService; + + @MockitoBean + private OpenAiClient openAiClient; + + private TranscriptCreateRequest requestOf(int timeBoxIndex, String... texts) { + List utterances = java.util.stream.IntStream.range(0, texts.length) + .mapToObj(i -> new UtteranceRequest(i * 1_000L, texts[i])) + .toList(); + return new TranscriptCreateRequest(List.of(new SegmentRequest(timeBoxIndex, null, utterances))); + } + + @Nested + class Create { + + @Test + void 시간표_기준으로_화자를_구분해_속기록을_저장한다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + + TranscriptCreateResponse created = transcriptService.create(table.getId(), member, + requestOf(0, "안녕하십니까", "찬성 입론입니다")); + + TranscriptResponse found = transcriptService.get(created.id(), member); + assertAll( + () -> assertThat(found.lines()).hasSize(2), + () -> assertThat(found.lines().get(0).stance()).isEqualTo(Stance.PROS), + () -> assertThat(found.lines().get(0).speaker()).isEqualTo("콜리"), + () -> assertThat(found.agenda()).isEqualTo(table.getAgenda()) + ); + } + + @Test + void 요청한_STT_모드를_함께_저장한다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + List segments = requestOf(0, "발언").segments(); + + TranscriptCreateResponse created = transcriptService.create(table.getId(), member, + new TranscriptCreateRequest(segments, SttMode.SONIOX)); + + assertThat(transcriptService.get(created.id(), member).sttMode()).isEqualTo(SttMode.SONIOX); + } + + @Test + void STT_모드가_없으면_RPC_모드로_저장한다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + + TranscriptCreateResponse created = transcriptService.create(table.getId(), member, requestOf(0, "발언")); + + assertThat(transcriptService.get(created.id(), member).sttMode()).isEqualTo(SttMode.RPC); + } + + @Test + void 인식된_발언이_없으면_예외를_던진다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + + assertThatThrownBy(() -> transcriptService.create(table.getId(), member, requestOf(0, " "))) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.EMPTY_TRANSCRIPT.getMessage()); + } + } + + @Nested + class Get { + + @Test + void 다른_회원의_기록은_조회할_수_없다() { + Member owner = memberGenerator.generate("owner@email.com"); + Member other = memberGenerator.generate("other@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(owner); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + TranscriptCreateResponse created = transcriptService.create(table.getId(), owner, requestOf(0, "발언")); + + assertThatThrownBy(() -> transcriptService.get(created.id(), other)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.TRANSCRIPT_NOT_FOUND.getMessage()); + } + + @Test + void 분석_전_기록은_진행_중_상태다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + TranscriptCreateResponse created = transcriptService.create(table.getId(), member, requestOf(0, "발언")); + + TranscriptResponse found = transcriptService.get(created.id(), member); + + assertThat(found.status()).isIn(TranscriptStatus.PROCESSING, TranscriptStatus.DONE, + TranscriptStatus.FAILED); + } + } + + @Nested + class FindAll { + + @Test + void 자신의_토론_기록을_최근_생성순으로_시간표_정보와_함께_조회한다() { + Member member = memberGenerator.generate("email@email.com"); + Member other = memberGenerator.generate("other@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + CustomizeTableEntity otherTable = customizeTableEntityGenerator.generate(other); + customizeTimeBoxEntityGenerator.generate(otherTable, CustomizeBoxType.NORMAL, 1); + long first = transcriptService.create(table.getId(), member, requestOf(0, "첫 기록")).id(); + long second = transcriptService.create(table.getId(), member, + new TranscriptCreateRequest(requestOf(0, "둘째 기록").segments(), SttMode.SONIOX)).id(); + transcriptService.create(otherTable.getId(), other, requestOf(0, "다른 회원 기록")); + + List transcripts = transcriptService.findAll(member).transcripts(); + + assertAll( + () -> assertThat(transcripts).extracting(TranscriptSummaryResponse::id) + .containsExactly(second, first), + () -> assertThat(transcripts.get(0).sttMode()).isEqualTo(SttMode.SONIOX), + () -> assertThat(transcripts.get(0).tableName()).isEqualTo(table.getName()), + () -> assertThat(transcripts.get(0).agenda()).isEqualTo(table.getAgenda()), + () -> assertThat(transcripts.get(0).createdAt()).isNotNull() + ); + } + + @Test + void 기록이_없으면_빈_목록을_돌려준다() { + Member member = memberGenerator.generate("email@email.com"); + + assertThat(transcriptService.findAll(member).transcripts()).isEmpty(); + } + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 8cf402dd..01414cbe 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,6 +1,11 @@ spring: profiles: active: test + # 메인 설정과 같은 업로드 한도. 테스트 설정이 메인 application.yml을 대신하므로 따로 둔다 + servlet: + multipart: + max-file-size: 30MB + max-request-size: 31MB cors: origin: