Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
368a9d8
[FEAT] 속기록 발언 도메인과 시간표 기준 화자 구분 추가
coli-geonwoo Oct 22, 2026
3955237
[FEAT] 속기록 저장 엔티티와 저장소 추가
coli-geonwoo Oct 22, 2026
234cbff
[FEAT] OpenAI 기반 토론 분석 보고서 비동기 생성 추가
coli-geonwoo Oct 22, 2026
a1015f1
[FEAT] 속기록 저장·조회 API 추가
coli-geonwoo Oct 22, 2026
361aa92
[FEAT] 속기록에 STT 모드 기록
coli-geonwoo Oct 22, 2026
91d70bb
[FEAT] 배치 음성 인식 토큰을 문장 단위 발화로 묶는 도메인 추가
coli-geonwoo Oct 22, 2026
96f58fa
[FEAT] Soniox 비동기 전사 클라이언트 추가
coli-geonwoo Oct 22, 2026
724eb5a
[FEAT] Soniox 발언 구간 전사 API 추가
coli-geonwoo Oct 22, 2026
ebaaa69
[FIX] SonioxClient 빈 생성자 지정
coli-geonwoo Oct 22, 2026
65a9df1
[FEAT] AI가 다듬은 속기록 문장 반영 도메인 추가
coli-geonwoo Oct 22, 2026
362d953
[FEAT] 속기록 다듬기 상태와 STT 원문 보관 추가
coli-geonwoo Oct 22, 2026
b7606b9
[FEAT] 분석 전 OpenAI로 속기록 STT 원문 다듬기
coli-geonwoo Oct 22, 2026
5444e70
[CHORE] dev 환경 OpenAI·Soniox 키를 dev-secret에서 주입
coli-geonwoo Oct 22, 2026
4ee4aed
[FIX] Soniox 구간 오디오 업로드를 위해 multipart 한도 상향
coli-geonwoo Oct 22, 2026
44d31ea
[FEAT] 회원의 토론 기록 목록 조회 API 추가
coli-geonwoo Oct 23, 2026
a1e5c83
[FIX] Soniox 전사 연결 타임아웃과 동시 실행 수 제한 (CodeRabbit 리뷰 반영)
coli-geonwoo Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/main/java/com/debatetimer/client/llm/OpenAiClient.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> schema
) {
if (!properties.hasApiKey()) {
throw new IllegalStateException("OpenAI API 키가 설정되지 않았습니다. OPENAI_API_KEY를 확인하세요.");
}

Map<String, Object> 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<Choice> choices) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record Choice(Message message) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record Message(String content, String refusal) {
}
}
29 changes: 29 additions & 0 deletions src/main/java/com/debatetimer/client/llm/OpenAiProperties.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
223 changes: 223 additions & 0 deletions src/main/java/com/debatetimer/client/stt/SonioxClient.java
Original file line number Diff line number Diff line change
@@ -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());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return restClientBuilder
.baseUrl(properties.baseUrl())
.requestFactory(requestFactory)
.build();
}

public List<SpeechToken> transcribe(Resource audio, List<String> 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<SpeechToken> transcribeWithPermit(Resource audio, List<String> 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<String, Object> 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<String> languageHints) {
Map<String, Object> 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();
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

private void sleep() {
try {
Thread.sleep(properties.pollInterval().toMillis());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new DTServerErrorException(ServerErrorCode.STT_REQUEST_FAILED);
}
}

private List<SpeechToken> 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<TokenResponse> tokens) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record TokenResponse(
String text,
@JsonProperty("start_ms") long startMs,
@JsonProperty("end_ms") long endMs,
@JsonProperty("translation_status") String translationStatus
) {
}
}
47 changes: 47 additions & 0 deletions src/main/java/com/debatetimer/client/stt/SonioxProperties.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading