diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 5d51f86..2f1dfa6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.character.controller import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService +import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse @@ -12,9 +13,17 @@ import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +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.TranslatedAiCharacterDetail +import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService +import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest import kr.co.vividnext.sodalive.member.Member import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.PageRequest @@ -32,7 +41,10 @@ class ChatCharacterController( private val bannerService: ChatCharacterBannerService, private val chatRoomService: ChatRoomService, private val characterCommentService: CharacterCommentService, - private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService, + private val curationQueryService: CharacterCurationQueryService, + + private val translationService: PapagoTranslationService, + private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -119,6 +131,7 @@ class ChatCharacterController( @GetMapping("/{characterId}") fun getCharacterDetail( @PathVariable characterId: Long, + @RequestParam(required = false, defaultValue = "ko") languageCode: String? = "ko", @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") @@ -148,6 +161,122 @@ class ChatCharacterController( ) } + var translated: TranslatedAiCharacterDetail? = null + if (!languageCode.isNullOrBlank() && languageCode != character.languageCode) { + val locale = languageCode.lowercase() + + val existing = aiCharacterTranslationRepository + .findByCharacterIdAndLocale(character.id!!, locale) + + if (existing != null) { + val payload = existing.renderedPayload + translated = TranslatedAiCharacterDetail( + name = payload.name, + description = payload.description, + gender = payload.gender, + personality = TranslatedAiCharacterPersonality( + trait = payload.personalityTrait, + description = payload.personalityDescription + ).takeIf { + (it.trait?.isNotBlank() == true) || (it.description?.isNotBlank() == true) + }, + background = TranslatedAiCharacterBackground( + topic = payload.backgroundTopic, + description = payload.backgroundDescription + ).takeIf { + (it.topic?.isNotBlank() == true) || (it.description?.isNotBlank() == true) + }, + tags = payload.tags + ) + } else { + val texts = mutableListOf() + 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 entity = AiCharacterTranslation( + characterId = character.id!!, + locale = locale, + translatedName = translatedName, + translatedTags = translatedTags, + renderedPayload = payload + ) + + aiCharacterTranslationRepository.save(entity) + + translated = TranslatedAiCharacterDetail( + name = translatedName, + description = translatedDescription, + gender = translatedGender, + personality = translatedPersonality, + background = translatedBackground, + tags = translatedTags + ) + } + } + } + // 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외) val others = service.getOtherCharactersBySharedTags(characterId, 10) .map { other -> @@ -184,7 +313,8 @@ class ChatCharacterController( characterType = character.characterType, others = others, latestComment = latestComment, - totalComments = characterCommentService.getTotalCommentCount(character.id!!) + totalComments = characterCommentService.getTotalCommentCount(character.id!!), + translated = translated ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt index 59a594a..aa3767d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.character.dto import kr.co.vividnext.sodalive.chat.character.CharacterType import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse +import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail data class CharacterDetailResponse( val characterId: Long, @@ -20,7 +21,8 @@ data class CharacterDetailResponse( val characterType: CharacterType, val others: List, val latestComment: CharacterCommentResponse?, - val totalComments: Int + val totalComments: Int, + val translated: TranslatedAiCharacterDetail? ) data class OtherCharacter( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt new file mode 100644 index 0000000..b168e0b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt @@ -0,0 +1,89 @@ +package kr.co.vividnext.sodalive.chat.character.translate + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.AttributeConverter +import javax.persistence.Column +import javax.persistence.Convert +import javax.persistence.Converter +import javax.persistence.Entity +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + uniqueConstraints = [ + UniqueConstraint(columnNames = ["character_id", "locale"]) + ] +) +class AiCharacterTranslation( + val characterId: Long, + val locale: String, + val translatedName: String, + val translatedTags: String, + + @Column(columnDefinition = "json") + @Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class) + val renderedPayload: AiCharacterTranslationRenderedPayload +) : BaseEntity() + +data class AiCharacterTranslationRenderedPayload( + val name: String, + val description: String, + val gender: String, + val personalityTrait: String, + val personalityDescription: String, + val backgroundTopic: String, + val backgroundDescription: String, + val tags: String +) + +@Converter(autoApply = false) +class AiCharacterTranslationRenderedPayloadConverter : + AttributeConverter { + + override fun convertToDatabaseColumn(attribute: AiCharacterTranslationRenderedPayload?): String { + if (attribute == null) return "{}" + return objectMapper.writeValueAsString(attribute) + } + + override fun convertToEntityAttribute(dbData: String?): AiCharacterTranslationRenderedPayload { + if (dbData.isNullOrBlank()) { + return AiCharacterTranslationRenderedPayload( + name = "", + description = "", + gender = "", + personalityTrait = "", + personalityDescription = "", + backgroundTopic = "", + backgroundDescription = "", + tags = "" + ) + } + return objectMapper.readValue(dbData) + } + + companion object { + private val objectMapper = jacksonObjectMapper() + } +} + +data class TranslatedAiCharacterDetail( + val name: String?, + val description: String?, + val gender: String?, + val personality: TranslatedAiCharacterPersonality?, + val background: TranslatedAiCharacterBackground?, + val tags: String? +) + +data class TranslatedAiCharacterPersonality( + val trait: String?, + val description: String? +) + +data class TranslatedAiCharacterBackground( + val topic: String?, + val description: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt new file mode 100644 index 0000000..430f253 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.chat.character.translate + +import org.springframework.data.jpa.repository.JpaRepository + +interface AiCharacterTranslationRepository : JpaRepository { + fun findByCharacterIdAndLocale(characterId: Long, locale: String): AiCharacterTranslation? +}