Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/docs/asciidoc/app/retrospect-v2-conversation.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ include::{snippets}/retrospect-v2/start/response-fields.adoc[]
== 메시지 전송

동일한 `clientMessageId`와 내용으로 재전송하면 이미 완료된 응답을 반환합니다.
`inputType`은 `TEXT` 또는 `STT`이며 생략하면 `TEXT`로 저장됩니다. STT 변환 텍스트를 사용자가 수정한 뒤 전송할 때는 `STT`를 전달합니다.

=== Request

Expand Down
7 changes: 6 additions & 1 deletion src/docs/asciidoc/app/retrospect.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ include::{snippets}/retrospect/submit-answer/response-fields.adoc[]

== 음성 답변 제출

[WARNING]
====
구버전 앱 호환용 API입니다. 신규 회고에서는 음성 답변 변환 후 사용자가 수정한 텍스트를 일반 메시지 API로 전송해야 합니다.
====

=== Request

include::{snippets}/retrospect/submit-voice-answer/http-request.adoc[]
Expand Down Expand Up @@ -300,4 +305,4 @@ include::{snippets}/retrospect/v2/find-by-id/path-parameters.adoc[]
=== Response

include::{snippets}/retrospect/v2/find-by-id/http-response.adoc[]
include::{snippets}/retrospect/v2/find-by-id/response-fields.adoc[]
include::{snippets}/retrospect/v2/find-by-id/response-fields.adoc[]
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class ClovaSpeechClient(
runCatching {
objectMapper.readValue(response, ClovaSpeechResponse::class.java)
}.getOrElse {
throw SpeechTranscriptionFailedException("CLOVA Speech 응답 파싱 실패. response: $response")
throw SpeechTranscriptionFailedException("CLOVA Speech 응답 파싱 실패")
}

private fun validateResult(result: ClovaSpeechResponse) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class OpenAiSpeechClient(
runCatching {
objectMapper.readValue(response, OpenAiSpeechResponse::class.java)
}.getOrElse {
throw SpeechTranscriptionFailedException("OpenAI STT 응답 파싱 실패. response: $response")
throw SpeechTranscriptionFailedException("OpenAI STT 응답 파싱 실패")
}

private fun createFilePart(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.didit.application.common.exception.ErrorCode
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
Expand All @@ -31,6 +32,19 @@ class ApiControllerAdvice {
}
}

@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleUnreadableMessage(exception: HttpMessageNotReadableException): ProblemDetail {
log.warn("[VALIDATION] 요청 본문 파싱 실패 message={}", exception.message)

return ProblemDetail
.forStatusAndDetail(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_REQUEST.detail)
.apply {
title = HttpStatus.BAD_REQUEST.reasonPhrase
setProperty("timestamp", OffsetDateTime.now().toString())
setProperty("code", ErrorCode.INVALID_REQUEST.name)
}
}

@ExceptionHandler(BusinessException::class)
fun handleBusinessException(exception: BusinessException): ProblemDetail {
val errorCode = exception.errorCode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ class RetrospectApi(
}

@RequireAuth
@Deprecated("Use the transcription endpoint and submit the edited text through the conversation API")
@Suppress("DEPRECATION")
@PostMapping("/api/v1/retrospectives/{retrospectiveId}/answers/voice", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun submitVoiceAnswer(
@CurrentUserId userId: UUID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class RetrospectConversationV2Api(
): SuccessResponse<SubmitConversationMessageV2Response> =
SuccessResponse.of(
SubmitConversationMessageV2Response.from(
conversation.submitMessage(retrospectiveId, userId, request.clientMessageId, request.content),
conversation.submitMessage(retrospectiveId, userId, request.clientMessageId, request.content, request.inputType),
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.didit.application.retrospect.dto.SubmitConversationMessageResult
import com.didit.domain.retrospect.ConversationMessageType
import com.didit.domain.retrospect.ConversationStatus
import com.didit.domain.retrospect.ConversationTurnStatus
import com.didit.domain.retrospect.InputType
import com.didit.domain.retrospect.Sender
import com.didit.domain.retrospect.SummaryGenerationStatus
import jakarta.validation.constraints.NotBlank
Expand All @@ -19,6 +20,7 @@ data class SubmitConversationMessageV2Request(
val clientMessageId: UUID,
@field:NotBlank
val content: String,
val inputType: InputType = InputType.TEXT,
)

data class StartConversationV2Response(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.didit.application.retrospect.exception.RetrospectiveNotFoundException
import com.didit.application.retrospect.exception.RetrospectiveNotInProgressException
import com.didit.application.retrospect.exception.SpeechEmptyFileException
import com.didit.application.retrospect.exception.SpeechEmptyResultException
import com.didit.application.retrospect.exception.SpeechTranscriptionFailedException
import com.didit.application.retrospect.exception.SpeechUnsupportedFileException
import com.didit.application.retrospect.exception.SummaryNotGeneratedException
import com.didit.application.retrospect.provided.RetrospectiveFinder
Expand Down Expand Up @@ -98,6 +99,7 @@ class RetrospectService(
): SubmitAnswerResponse = processAnswer(retrospectiveId, userId, content, InputType.TEXT)

@Transactional
@Deprecated("Use transcribeVoiceAnswer and submit the edited text through the conversation API")
override fun submitVoiceAnswer(
retrospectiveId: UUID,
userId: UUID,
Expand All @@ -113,7 +115,7 @@ class RetrospectService(

if (retrospective.isPending()) retrospective.startProgress()

val content = transcribe(audioBytes, filename)
val content = transcribe(retrospectiveId, audioBytes, filename)
saveUserAnswer(retrospective, content, currentQuestionType, InputType.STT)

return routeAnswer(retrospective, currentQuestionType).copy(content = content)
Expand All @@ -129,7 +131,7 @@ class RetrospectService(
val retrospective = retrospectiveFinder.findById(retrospectiveId, userId)
validateRetrospectiveInProgress(retrospective, retrospectiveId)

return transcribe(audioBytes, filename)
return transcribe(retrospectiveId, audioBytes, filename)
}

@Transactional
Expand Down Expand Up @@ -318,23 +320,54 @@ class RetrospectService(
}

private fun transcribe(
retrospectiveId: UUID,
audioBytes: ByteArray,
filename: String,
): String {
if (audioBytes.isEmpty()) throw SpeechEmptyFileException()

val supportedExtensions = listOf("flac", "mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm")
val extension = filename.substringAfterLast('.', "").lowercase()

if (extension !in supportedExtensions) {
throw SpeechUnsupportedFileException(filename, null)
return try {
if (audioBytes.isEmpty()) throw SpeechEmptyFileException()

val supportedExtensions = listOf("flac", "mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm")
if (extension !in supportedExtensions) {
throw SpeechUnsupportedFileException(filename, null)
}

val text =
try {
speechClient.transcribe(audioBytes, filename).trim()
} catch (exception: SpeechTranscriptionFailedException) {
throw exception
} catch (exception: Exception) {
throw SpeechTranscriptionFailedException(
"providerFailure: ${exception::class.simpleName}",
exception,
)
}

if (text.isBlank()) throw SpeechEmptyResultException()
text
} catch (exception: Exception) {
if (exception is SpeechTranscriptionFailedException) {
logger.warn(
"음성 변환 실패 - retrospectiveId: {}, extension: {}, fileSize: {}, failureType: {}",
retrospectiveId,
extension,
audioBytes.size,
exception::class.simpleName,
exception,
)
} else {
logger.warn(
"음성 변환 실패 - retrospectiveId: {}, extension: {}, fileSize: {}, failureType: {}",
retrospectiveId,
extension,
audioBytes.size,
exception::class.simpleName,
)
}
throw exception
}

val text = speechClient.transcribe(audioBytes, filename).trim()

if (text.isBlank()) throw SpeechEmptyResultException()

return text
}

private fun validateRetrospectiveInProgress(
Expand All @@ -343,6 +376,9 @@ class RetrospectService(
) {
if (retrospective.isCompleted()) throw RetrospectiveAlreadyCompletedException(retrospectiveId)
if (retrospective.isDeleted()) throw RetrospectiveNotInProgressException(retrospectiveId)
if (retrospective.isV2() && !retrospective.isConversationActive()) {
throw RetrospectiveNotInProgressException(retrospectiveId)
}
}

private fun saveUserAnswer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import com.didit.domain.retrospect.ChatMessage
import com.didit.domain.retrospect.ConversationMessageType
import com.didit.domain.retrospect.ConversationStatus
import com.didit.domain.retrospect.ConversationTurnStatus
import com.didit.domain.retrospect.InputType
import com.didit.domain.retrospect.MessageRelevance
import com.didit.domain.retrospect.Retrospective
import com.didit.domain.retrospect.RetrospectiveAnalysisEvidence
Expand Down Expand Up @@ -98,10 +99,11 @@ class RetrospectiveConversationV2Service(
userId: UUID,
clientMessageId: UUID,
content: String,
inputType: InputType,
): SubmitConversationMessageResult {
val preparation =
metrics.recordStage("conversation_v2", "prepare") {
prepareTurn(retrospectiveId, userId, clientMessageId, content)
prepareTurn(retrospectiveId, userId, clientMessageId, content, inputType)
}
preparation.cachedResult?.let {
metrics.incrementConversationDuplicate()
Expand Down Expand Up @@ -182,6 +184,7 @@ class RetrospectiveConversationV2Service(
userId: UUID,
clientMessageId: UUID,
content: String,
inputType: InputType,
): TurnPreparation =
transactionTemplate.execute {
val retrospective = findV2ForUpdate(retrospectiveId, userId)
Expand All @@ -190,7 +193,9 @@ class RetrospectiveConversationV2Service(
val existing = turnRepository.findByRetrospectiveIdAndClientMessageId(retrospectiveId, clientMessageId)
if (existing != null) {
val userMessage = chatMessageRepository.findById(existing.userMessageId) ?: error("사용자 메시지를 찾을 수 없습니다.")
if (userMessage.content != content) throw DuplicateMessageContentMismatchException(clientMessageId)
if (userMessage.content != content || userMessage.inputType != inputType) {
throw DuplicateMessageContentMismatchException(clientMessageId)
}
when (existing.status) {
ConversationTurnStatus.PROCESSING -> throw ConversationTurnInProgressException(retrospectiveId)
ConversationTurnStatus.COMPLETED ->
Expand Down Expand Up @@ -219,7 +224,7 @@ class RetrospectiveConversationV2Service(
}

if (retrospective.isPending()) retrospective.startProgress()
val userMessage = ChatMessage.v2UserMessage(retrospective, content)
val userMessage = ChatMessage.v2UserMessage(retrospective, content, inputType)
retrospective.addMessage(userMessage)
retrospectiveRepository.save(retrospective)
val turn =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,15 @@ class SpeechUnsupportedFileException(

class SpeechTranscriptionFailedException(
message: String,
cause: Throwable? = null,
) : BusinessException(
RetrospectErrorCode.SPEECH_TRANSCRIPTION_FAILED,
message,
)
) {
init {
if (cause != null) initCause(cause)
}
}

class SpeechEmptyResultException : BusinessException(RetrospectErrorCode.SPEECH_EMPTY_RESULT)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.didit.application.retrospect.dto.ConversationV2Result
import com.didit.application.retrospect.dto.FinishConversationV2Result
import com.didit.application.retrospect.dto.StartConversationV2Result
import com.didit.application.retrospect.dto.SubmitConversationMessageResult
import com.didit.domain.retrospect.InputType
import java.util.UUID

interface RetrospectiveConversationV2 {
Expand All @@ -14,6 +15,14 @@ interface RetrospectiveConversationV2 {
userId: UUID,
clientMessageId: UUID,
content: String,
): SubmitConversationMessageResult = submitMessage(retrospectiveId, userId, clientMessageId, content, InputType.TEXT)

fun submitMessage(
retrospectiveId: UUID,
userId: UUID,
clientMessageId: UUID,
content: String,
inputType: InputType,
): SubmitConversationMessageResult

fun getConversation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface RetrospectiveRegister {
content: String,
): SubmitAnswerResponse

@Deprecated("Use transcribeVoiceAnswer and submit the edited text through the conversation API")
fun submitVoiceAnswer(
retrospectiveId: UUID,
userId: UUID,
Expand Down
8 changes: 7 additions & 1 deletion src/main/kotlin/com/didit/domain/retrospect/ChatMessage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,20 @@ class ChatMessage(
fun v2UserMessage(
retrospective: Retrospective,
content: String,
): ChatMessage = v2UserMessage(retrospective, content, InputType.TEXT)

fun v2UserMessage(
retrospective: Retrospective,
content: String,
inputType: InputType,
): ChatMessage {
require(content.isNotBlank()) { "회고 내용은 비어 있을 수 없습니다." }
return ChatMessage(
retrospective = retrospective,
sender = Sender.USER,
content = content,
questionType = QuestionType.V2_CHAT,
inputType = InputType.TEXT,
inputType = inputType,
messageType = ConversationMessageType.CONVERSATION,
includedInResult = false,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import java.time.LocalDate
import java.time.LocalDateTime
import java.util.UUID

@Suppress("DEPRECATION")
class RetrospectApiTest : AuthenticatedRestDocsSupport() {
private val retrospectiveRegister: RetrospectiveRegister = mock(RetrospectiveRegister::class.java)
private val retrospectiveFinder: RetrospectiveFinder = mock(RetrospectiveFinder::class.java)
Expand Down
Loading
Loading