파파고 번역 요청을 길제 제한에 맞춰서 분할 #468
+39
-15
@@ -43,32 +43,54 @@ class PapagoTranslationService(
|
||||
|
||||
request.texts.forEach { text ->
|
||||
try {
|
||||
val body = mapOf(
|
||||
"source" to request.sourceLanguage,
|
||||
"target" to request.targetLanguage,
|
||||
"text" to text
|
||||
)
|
||||
val translatedText = StringBuilder()
|
||||
for (chunk in splitText(text)) {
|
||||
val body = mapOf(
|
||||
"source" to request.sourceLanguage,
|
||||
"target" to request.targetLanguage,
|
||||
"text" to chunk
|
||||
)
|
||||
|
||||
val requestEntity = HttpEntity(body, headers)
|
||||
val requestEntity = HttpEntity(body, headers)
|
||||
|
||||
val response = restTemplate.postForEntity<PapagoTranslationResponse>(
|
||||
papagoTranslateUrl,
|
||||
requestEntity
|
||||
)
|
||||
val response = restTemplate.postForEntity<PapagoTranslationResponse>(
|
||||
papagoTranslateUrl,
|
||||
requestEntity
|
||||
)
|
||||
|
||||
if (!response.statusCode.is2xxSuccessful) {
|
||||
return@forEach
|
||||
if (!response.statusCode.is2xxSuccessful) {
|
||||
return TranslateResult(emptyList())
|
||||
}
|
||||
|
||||
val translated = response.body?.message?.result?.translatedText
|
||||
if (translated.isNullOrBlank()) {
|
||||
return TranslateResult(emptyList())
|
||||
}
|
||||
translatedText.append(translated)
|
||||
}
|
||||
|
||||
val translated = response.body?.message?.result?.translatedText
|
||||
translatedTexts.add(translated ?: "")
|
||||
translatedTexts.add(translatedText.toString())
|
||||
} catch (_: Exception) {
|
||||
return TranslateResult(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
return TranslateResult(translatedTexts)
|
||||
}
|
||||
|
||||
private fun splitText(text: String): List<String> {
|
||||
val chunks = mutableListOf<String>()
|
||||
var start = 0
|
||||
do {
|
||||
var end = minOf(start + MAX_REQUEST_TEXT_LENGTH, text.length)
|
||||
if (end < text.length && text[end - 1].isHighSurrogate() && text[end].isLowSurrogate()) {
|
||||
end -= 1
|
||||
}
|
||||
chunks.add(text.substring(start, end))
|
||||
start = end
|
||||
} while (start < text.length)
|
||||
return chunks
|
||||
}
|
||||
|
||||
private fun validateLanguages(sourceLanguage: String, targetLanguage: String): Boolean {
|
||||
return requireSupportedLanguage(sourceLanguage) && requireSupportedLanguage(targetLanguage)
|
||||
}
|
||||
@@ -79,6 +101,8 @@ class PapagoTranslationService(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_REQUEST_TEXT_LENGTH = 5000
|
||||
|
||||
private val SUPPORTED_LANGUAGE_CODES = setOf(
|
||||
"ko",
|
||||
"en",
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.CsvSource
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.test.web.client.MockRestServiceServer
|
||||
import org.springframework.test.web.client.ResponseActions
|
||||
import org.springframework.test.web.client.match.MockRestRequestMatchers.content
|
||||
import org.springframework.test.web.client.match.MockRestRequestMatchers.header
|
||||
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
|
||||
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
|
||||
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
|
||||
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess
|
||||
import org.springframework.web.client.RestTemplate
|
||||
|
||||
@SpringBootTest(
|
||||
classes = [PapagoTranslationService::class],
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE,
|
||||
properties = [
|
||||
"cloud.naver.papago-client-id=test-client-id",
|
||||
"cloud.naver.papago-client-secret=test-client-secret"
|
||||
]
|
||||
)
|
||||
class PapagoTranslationServiceTest @Autowired constructor(
|
||||
private val service: PapagoTranslationService
|
||||
) {
|
||||
private val objectMapper = ObjectMapper()
|
||||
private lateinit var server: MockRestServiceServer
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
val restTemplate = RestTemplate()
|
||||
server = MockRestServiceServer.bindTo(restTemplate).build()
|
||||
ReflectionTestUtils.setField(service, "restTemplate", restTemplate)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun verifyRequests() {
|
||||
server.verify()
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = [12, 5000])
|
||||
@DisplayName("5,000자 이하 원문은 기존 형식으로 한 번만 요청한다")
|
||||
fun shouldTranslateTextWithinLimitInOneRequest(length: Int) {
|
||||
val source = "a".repeat(length)
|
||||
expectTranslation(source, "translated")
|
||||
|
||||
assertEquals(listOf("translated"), translate(source).translatedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5,001자 원문은 두 번 요청하고 번역을 구분자 없이 결합한다")
|
||||
fun shouldSplit5001UnitsAndJoinTranslationsWithoutSeparator() {
|
||||
val first = "a".repeat(5000)
|
||||
expectTranslation(first, "first")
|
||||
expectTranslation("b", "second")
|
||||
|
||||
assertEquals(listOf("firstsecond"), translate(first + "b").translatedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("10,001자 원문은 순서대로 세 번 요청하고 결과 하나를 반환한다")
|
||||
fun shouldSplit10001UnitsInSourceOrder() {
|
||||
val first = "a".repeat(5000)
|
||||
val second = "b".repeat(5000)
|
||||
expectTranslation(first, "first")
|
||||
expectTranslation(second, "second")
|
||||
expectTranslation("c", "third")
|
||||
|
||||
assertEquals(listOf("firstsecondthird"), translate(first + second + "c").translatedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("한글 원문의 줄바꿈과 공백을 분할 요청에 그대로 보존한다")
|
||||
fun shouldPreserveKoreanTextAndWhitespaceAcrossChunks() {
|
||||
val first = "가".repeat(4999) + "\n"
|
||||
expectTranslation(first, "translated ")
|
||||
expectTranslation(" ", "tail")
|
||||
|
||||
assertEquals(listOf("translated tail"), translate(first + " ").translatedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5,000자 경계의 서로게이트 쌍은 다음 조각에 함께 포함한다")
|
||||
fun shouldKeepSurrogatePairTogetherAtChunkBoundary() {
|
||||
val first = "a".repeat(4999)
|
||||
val second = "\uD83D\uDE00b"
|
||||
expectTranslation(first, "first")
|
||||
expectTranslation(second, "second")
|
||||
|
||||
assertEquals(listOf("firstsecond"), translate(first + second).translatedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("여러 원문의 조각을 순차 요청하고 원문별 결과 대응을 유지한다")
|
||||
fun shouldReturnOneJoinedTranslationPerOriginalSource() {
|
||||
val first = "a".repeat(5000)
|
||||
val second = "b".repeat(5000)
|
||||
expectTranslation(first, "first-")
|
||||
expectTranslation("a", "tail")
|
||||
expectTranslation(second, "second-")
|
||||
expectTranslation("b", "tail")
|
||||
|
||||
assertEquals(listOf("first-tail", "second-tail"), translate(first + "a", second + "b").translatedText)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = [400, 500])
|
||||
@DisplayName("중간 조각의 HTTP 오류는 전체 결과를 버리고 이후 조각과 원문을 요청하지 않는다")
|
||||
fun shouldAbortAllTranslationsAfterHttpErrorInMiddleChunk(status: Int) {
|
||||
val first = "a".repeat(5000)
|
||||
val second = "b".repeat(5000)
|
||||
expectTranslation("earlier", "earlier translation")
|
||||
expectTranslation(first, "partial")
|
||||
expectRequest(second).andRespond(withStatus(HttpStatus.valueOf(status)))
|
||||
|
||||
assertEquals(emptyList<String>(), translate("earlier", first + second + "c", "later").translatedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("다음 원문의 HTTP 오류도 앞선 성공 결과를 버리고 후속 요청을 중단한다")
|
||||
fun shouldDiscardEarlierSourcesAndStopAfterHttpFailure() {
|
||||
expectTranslation("earlier", "earlier translation")
|
||||
expectRequest("broken").andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR))
|
||||
|
||||
assertEquals(emptyList<String>(), translate("earlier", "broken", "later").translatedText)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["", " \t\n"])
|
||||
@DisplayName("중간 조각의 번역이 비었거나 공백이면 전체 결과를 버리고 후속 요청을 중단한다")
|
||||
fun shouldAbortAllTranslationsAfterBlankMiddleChunk(translated: String) {
|
||||
val first = "a".repeat(5000)
|
||||
val second = "b".repeat(5000)
|
||||
expectTranslation("earlier", "earlier translation")
|
||||
expectTranslation(first, "partial")
|
||||
expectTranslation(second, translated)
|
||||
|
||||
assertEquals(emptyList<String>(), translate("earlier", first + second + "c", "later").translatedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("응답 본문이 없으면 앞선 성공 결과를 포함하지 않는 빈 결과를 반환한다")
|
||||
fun shouldDiscardAllTranslationsWhenResponseBodyIsMissing() {
|
||||
expectTranslation("earlier", "earlier translation")
|
||||
expectRequest("empty").andRespond(withSuccess("", MediaType.APPLICATION_JSON))
|
||||
|
||||
assertEquals(emptyList<String>(), translate("earlier", "empty").translatedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("빈 입력 목록은 HTTP 요청 없이 빈 결과를 반환한다")
|
||||
fun shouldNotRequestTranslationForEmptySources() {
|
||||
assertEquals(emptyList<String>(), translate().translatedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("동일한 출발 언어와 도착 언어는 HTTP 요청 없이 빈 결과를 반환한다")
|
||||
fun shouldNotRequestTranslationForSameLanguage() {
|
||||
val result = service.translate(TranslateRequest(listOf("source"), "ko", "ko"))
|
||||
|
||||
assertEquals(emptyList<String>(), result.translatedText)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource("fr,en", "ko,fr")
|
||||
@DisplayName("미지원 출발 언어 또는 도착 언어는 HTTP 요청 없이 빈 결과를 반환한다")
|
||||
fun shouldNotRequestTranslationForUnsupportedLanguage(sourceLanguage: String, targetLanguage: String) {
|
||||
val result = service.translate(TranslateRequest(listOf("source"), sourceLanguage, targetLanguage))
|
||||
|
||||
assertEquals(emptyList<String>(), result.translatedText)
|
||||
}
|
||||
|
||||
private fun translate(vararg texts: String): TranslateResult {
|
||||
return service.translate(TranslateRequest(texts.toList(), "ko", "en"))
|
||||
}
|
||||
|
||||
private fun expectTranslation(source: String, translated: String) {
|
||||
val response = PapagoTranslationResponse(
|
||||
PapagoTranslationResponse.Message(PapagoTranslationResponse.Result("ko", "en", translated))
|
||||
)
|
||||
expectRequest(source).andRespond(withSuccess(objectMapper.writeValueAsString(response), MediaType.APPLICATION_JSON))
|
||||
}
|
||||
|
||||
private fun expectRequest(source: String): ResponseActions {
|
||||
val body = objectMapper.writeValueAsString(mapOf("source" to "ko", "target" to "en", "text" to source))
|
||||
return server.expect(requestTo("https://papago.apigw.ntruss.com/nmt/v1/translation"))
|
||||
.andExpect(method(HttpMethod.POST))
|
||||
.andExpect(header("X-NCP-APIGW-API-KEY-ID", "test-client-id"))
|
||||
.andExpect(header("X-NCP-APIGW-API-KEY", "test-client-secret"))
|
||||
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(content().json(body, true))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user