AI 캐릭터, 콘텐츠 등록/수정 시 번역 데이터 생성
This commit is contained in:
@@ -15,6 +15,8 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -331,6 +333,13 @@ class AdminChatCharacterController(
|
|||||||
request = request
|
request = request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = request.id,
|
||||||
|
targetType = LanguageTranslationTargetType.CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||||
if (request.originalWorkId != null) {
|
if (request.originalWorkId != null) {
|
||||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class AiCharacterTranslation(
|
|||||||
|
|
||||||
@Column(columnDefinition = "json")
|
@Column(columnDefinition = "json")
|
||||||
@Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class)
|
@Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class)
|
||||||
val renderedPayload: AiCharacterTranslationRenderedPayload
|
var renderedPayload: AiCharacterTranslationRenderedPayload
|
||||||
) : BaseEntity()
|
) : BaseEntity()
|
||||||
|
|
||||||
data class AiCharacterTranslationRenderedPayload(
|
data class AiCharacterTranslationRenderedPayload(
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ import kr.co.vividnext.sodalive.content.order.OrderType
|
|||||||
import kr.co.vividnext.sodalive.content.pin.PinContent
|
import kr.co.vividnext.sodalive.content.pin.PinContent
|
||||||
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
|
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
|
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
|
||||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
@@ -167,6 +171,13 @@ class AudioContentService(
|
|||||||
|
|
||||||
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
|
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = request.contentId,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -357,6 +368,13 @@ class AudioContentService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = audioContent.id!!,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return CreateAudioContentResponse(contentId = audioContent.id!!)
|
return CreateAudioContentResponse(contentId = audioContent.id!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,14 +803,14 @@ class AudioContentService(
|
|||||||
val translatedDetail = translatedTexts[index++]
|
val translatedDetail = translatedTexts[index++]
|
||||||
val translatedTags = translatedTexts[index]
|
val translatedTags = translatedTexts[index]
|
||||||
|
|
||||||
val payload = kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload(
|
val payload = ContentTranslationPayload(
|
||||||
title = translatedTitle,
|
title = translatedTitle,
|
||||||
detail = translatedDetail,
|
detail = translatedDetail,
|
||||||
tags = translatedTags
|
tags = translatedTags
|
||||||
)
|
)
|
||||||
|
|
||||||
contentTranslationRepository.save(
|
contentTranslationRepository.save(
|
||||||
kr.co.vividnext.sodalive.content.translation.ContentTranslation(
|
ContentTranslation(
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
locale = locale,
|
locale = locale,
|
||||||
renderedPayload = payload
|
renderedPayload = payload
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepositor
|
|||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.http.HttpEntity
|
import org.springframework.http.HttpEntity
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
@@ -47,6 +50,8 @@ class LanguageDetectListener(
|
|||||||
private val characterCommentRepository: CharacterCommentRepository,
|
private val characterCommentRepository: CharacterCommentRepository,
|
||||||
private val creatorCheersRepository: CreatorCheersRepository,
|
private val creatorCheersRepository: CreatorCheersRepository,
|
||||||
|
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
@Value("\${cloud.naver.papago-client-id}")
|
@Value("\${cloud.naver.papago-client-id}")
|
||||||
private val papagoClientId: String,
|
private val papagoClientId: String,
|
||||||
|
|
||||||
@@ -102,6 +107,13 @@ class LanguageDetectListener(
|
|||||||
character.languageCode = langCode
|
character.languageCode = langCode
|
||||||
chatCharacterRepository.save(character)
|
chatCharacterRepository.save(character)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = characterId,
|
||||||
|
targetType = LanguageTranslationTargetType.CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}",
|
"[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}",
|
||||||
characterId,
|
characterId,
|
||||||
@@ -135,6 +147,13 @@ class LanguageDetectListener(
|
|||||||
// REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다.
|
// REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다.
|
||||||
audioContentRepository.save(audioContent)
|
audioContentRepository.save(audioContent)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = contentId,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}",
|
"[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}",
|
||||||
contentId,
|
contentId,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ContentTranslation(
|
|||||||
|
|
||||||
@Column(columnDefinition = "json")
|
@Column(columnDefinition = "json")
|
||||||
@Convert(converter = ContentTranslationPayloadConverter::class)
|
@Convert(converter = ContentTranslationPayloadConverter::class)
|
||||||
val renderedPayload: ContentTranslationPayload
|
var renderedPayload: ContentTranslationPayload
|
||||||
) : BaseEntity()
|
) : BaseEntity()
|
||||||
|
|
||||||
data class ContentTranslationPayload(
|
data class ContentTranslationPayload(
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.event.TransactionPhase
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener
|
||||||
|
|
||||||
|
enum class LanguageTranslationTargetType {
|
||||||
|
CONTENT,
|
||||||
|
CHARACTER
|
||||||
|
}
|
||||||
|
|
||||||
|
class LanguageTranslationEvent(
|
||||||
|
val id: Long,
|
||||||
|
val targetType: LanguageTranslationTargetType
|
||||||
|
)
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class LanguageTranslationListener(
|
||||||
|
private val audioContentRepository: AudioContentRepository,
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
|
||||||
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
|
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||||
|
|
||||||
|
private val translationService: PapagoTranslationService
|
||||||
|
) {
|
||||||
|
@Async
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun translation(event: LanguageTranslationEvent) {
|
||||||
|
when (event.targetType) {
|
||||||
|
LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event)
|
||||||
|
LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleContentLanguageTranslation(event: LanguageTranslationEvent) {
|
||||||
|
val audioContent = audioContentRepository.findByIdOrNull(event.id) ?: return
|
||||||
|
val languageCode = audioContent.languageCode
|
||||||
|
if (languageCode != null) return
|
||||||
|
|
||||||
|
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||||
|
val tags = audioContent.audioContentHashTags
|
||||||
|
.mapNotNull { it.hashTag?.tag }
|
||||||
|
.joinToString(",")
|
||||||
|
|
||||||
|
val texts = mutableListOf<String>()
|
||||||
|
texts.add(audioContent.title)
|
||||||
|
texts.add(audioContent.detail)
|
||||||
|
texts.add(tags)
|
||||||
|
|
||||||
|
val sourceLanguage = audioContent.languageCode ?: "ko"
|
||||||
|
|
||||||
|
val response = translationService.translate(
|
||||||
|
request = TranslateRequest(
|
||||||
|
texts = texts,
|
||||||
|
sourceLanguage = sourceLanguage,
|
||||||
|
targetLanguage = locale
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedTexts = response.translatedText
|
||||||
|
if (translatedTexts.size == texts.size) {
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
val translatedTitle = translatedTexts[index++]
|
||||||
|
val translatedDetail = translatedTexts[index++]
|
||||||
|
val translatedTags = translatedTexts[index]
|
||||||
|
|
||||||
|
val payload = ContentTranslationPayload(
|
||||||
|
title = translatedTitle,
|
||||||
|
detail = translatedDetail,
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
|
||||||
|
val existing = contentTranslationRepository
|
||||||
|
.findByContentIdAndLocale(audioContent.id!!, locale)
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
contentTranslationRepository.save(
|
||||||
|
ContentTranslation(
|
||||||
|
contentId = audioContent.id!!,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = payload
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
existing.renderedPayload = payload
|
||||||
|
contentTranslationRepository.save(existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCharacterLanguageTranslation(event: LanguageTranslationEvent) {
|
||||||
|
val character = chatCharacterRepository.findByIdOrNull(event.id) ?: return
|
||||||
|
val languageCode = character.languageCode
|
||||||
|
if (languageCode != null) return
|
||||||
|
|
||||||
|
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||||
|
val personality = character.personalities.firstOrNull()
|
||||||
|
val background = character.backgrounds.firstOrNull()
|
||||||
|
|
||||||
|
val tags = character.tagMappings.joinToString(",") { it.tag.tag }
|
||||||
|
|
||||||
|
val texts = mutableListOf<String>()
|
||||||
|
texts.add(character.name)
|
||||||
|
texts.add(character.description)
|
||||||
|
texts.add(character.gender ?: "")
|
||||||
|
|
||||||
|
val hasPersonality = personality != null
|
||||||
|
if (hasPersonality) {
|
||||||
|
texts.add(personality!!.trait)
|
||||||
|
texts.add(personality.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasBackground = background != null
|
||||||
|
if (hasBackground) {
|
||||||
|
texts.add(background!!.topic)
|
||||||
|
texts.add(background.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
texts.add(tags)
|
||||||
|
|
||||||
|
val sourceLanguage = character.languageCode ?: "ko"
|
||||||
|
|
||||||
|
val response = translationService.translate(
|
||||||
|
request = TranslateRequest(
|
||||||
|
texts = texts,
|
||||||
|
sourceLanguage = sourceLanguage,
|
||||||
|
targetLanguage = locale
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedTexts = response.translatedText
|
||||||
|
if (translatedTexts.size == texts.size) {
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
val translatedName = translatedTexts[index++]
|
||||||
|
val translatedDescription = translatedTexts[index++]
|
||||||
|
val translatedGender = translatedTexts[index++]
|
||||||
|
|
||||||
|
var translatedPersonality: TranslatedAiCharacterPersonality? = null
|
||||||
|
if (hasPersonality) {
|
||||||
|
translatedPersonality = TranslatedAiCharacterPersonality(
|
||||||
|
trait = translatedTexts[index++],
|
||||||
|
description = translatedTexts[index++]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var translatedBackground: TranslatedAiCharacterBackground? = null
|
||||||
|
if (hasBackground) {
|
||||||
|
translatedBackground = TranslatedAiCharacterBackground(
|
||||||
|
topic = translatedTexts[index++],
|
||||||
|
description = translatedTexts[index++]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val translatedTags = translatedTexts[index]
|
||||||
|
|
||||||
|
val payload = AiCharacterTranslationRenderedPayload(
|
||||||
|
name = translatedName,
|
||||||
|
description = translatedDescription,
|
||||||
|
gender = translatedGender,
|
||||||
|
personalityTrait = translatedPersonality?.trait ?: "",
|
||||||
|
personalityDescription = translatedPersonality?.description ?: "",
|
||||||
|
backgroundTopic = translatedBackground?.topic ?: "",
|
||||||
|
backgroundDescription = translatedBackground?.description ?: "",
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
|
||||||
|
val existing = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdAndLocale(character.id!!, locale)
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
val entity = AiCharacterTranslation(
|
||||||
|
characterId = character.id!!,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = payload
|
||||||
|
)
|
||||||
|
|
||||||
|
aiCharacterTranslationRepository.save(entity)
|
||||||
|
} else {
|
||||||
|
existing.renderedPayload = payload
|
||||||
|
aiCharacterTranslationRepository.save(existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,5 +80,16 @@ class PapagoTranslationService(
|
|||||||
"en",
|
"en",
|
||||||
"ja"
|
"ja"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번역 대상 언어 코드 집합을 반환한다.
|
||||||
|
*
|
||||||
|
* @param excludedLanguageCode 번역 대상에서 제외할 언어 코드(대소문자 무시)
|
||||||
|
* @return 지원되는 언어 코드 중 [excludedLanguageCode]를 제외한 집합
|
||||||
|
*/
|
||||||
|
fun getTranslatableLanguageCodes(excludedLanguageCode: String?): Set<String> {
|
||||||
|
val normalized = excludedLanguageCode?.lowercase()
|
||||||
|
return SUPPORTED_LANGUAGE_CODES.filterNot { it == normalized }.toSet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user