diff --git a/.gitignore b/.gitignore index 4ea4adf..3bbf44a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ out/ .vscode/ # macOS -.DS_Store \ No newline at end of file +.DS_Store +/src/main/resources/gcp-service-account-key.json diff --git a/build.gradle b/build.gradle index 8ce312e..a661b34 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,11 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' } +ext { + springAiVersion = "1.1.0" + springCloudVersion = "2025.0.0" +} + group = 'com.earseo' version = '0.0.1-SNAPSHOT' description = 'backend-core' @@ -21,6 +26,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' runtimeOnly 'io.micrometer:micrometer-registry-prometheus' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -49,6 +55,16 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.kafka:spring-kafka' + + implementation 'org.springframework.ai:spring-ai-starter-model-openai' + implementation 'com.google.cloud:google-cloud-texttospeech:2.78.0' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion" + mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion" + } } tasks.named('test') { diff --git a/src/main/java/com/earseo/core/BackendCoreApplication.java b/src/main/java/com/earseo/core/BackendCoreApplication.java index 51c5c14..41bf335 100644 --- a/src/main/java/com/earseo/core/BackendCoreApplication.java +++ b/src/main/java/com/earseo/core/BackendCoreApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; @SpringBootApplication +@EnableFeignClients public class BackendCoreApplication { public static void main(String[] args) { diff --git a/src/main/java/com/earseo/core/common/config/AiConfig.java b/src/main/java/com/earseo/core/common/config/AiConfig.java new file mode 100644 index 0000000..88c611f --- /dev/null +++ b/src/main/java/com/earseo/core/common/config/AiConfig.java @@ -0,0 +1,29 @@ +package com.earseo.core.common.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class AiConfig { + private final OpenAiChatModel openAiChatModel; + + @Bean + public OpenAiChatOptions openAiChatOptions() { + return OpenAiChatOptions.builder() + .model("gpt-4.1-mini") + .temperature(0.3) + .build(); + } + + @Bean + public ChatClient chatClient() { + return ChatClient.builder(openAiChatModel) + .defaultOptions(openAiChatOptions()) + .build(); + } +} diff --git a/src/main/java/com/earseo/core/common/config/GoogleCloudConfig.java b/src/main/java/com/earseo/core/common/config/GoogleCloudConfig.java new file mode 100644 index 0000000..3135a25 --- /dev/null +++ b/src/main/java/com/earseo/core/common/config/GoogleCloudConfig.java @@ -0,0 +1,38 @@ +package com.earseo.core.common.config; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.auth.Credentials; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.texttospeech.v1.TextToSpeechClient; +import com.google.cloud.texttospeech.v1.TextToSpeechSettings; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Base64; + +@Configuration +@Profile("!test") +public class GoogleCloudConfig { + + @Value("${gcp.credentials.base64:}") + private String credentialsBase64; + + @Bean + public TextToSpeechClient textToSpeechClient() throws IOException { + byte[] decodedBytes = Base64.getDecoder().decode(credentialsBase64); + InputStream credentialsStream = new ByteArrayInputStream(decodedBytes); + Credentials credentials = GoogleCredentials.fromStream(credentialsStream); + + TextToSpeechSettings settings = TextToSpeechSettings.newBuilder() + .setCredentialsProvider(FixedCredentialsProvider.create(credentials)) + .build(); + + return TextToSpeechClient.create(settings); + } +} diff --git a/src/main/java/com/earseo/core/controller/DocentController.java b/src/main/java/com/earseo/core/controller/DocentController.java new file mode 100644 index 0000000..819180d --- /dev/null +++ b/src/main/java/com/earseo/core/controller/DocentController.java @@ -0,0 +1,23 @@ +package com.earseo.core.controller; + +import com.earseo.core.common.BaseResponse; +import com.earseo.core.service.DocentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class DocentController { + + private final DocentService docentService; + + @PostMapping("/admin/core/docent") + public ResponseEntity> initDocent() { + docentService.initDocent(); + docentService.getDocent(); + docentService.getDocentJson(); + return ResponseEntity.ok(BaseResponse.ok(null)); + } +} diff --git a/src/main/java/com/earseo/core/controller/internal/CoreInternalController.java b/src/main/java/com/earseo/core/controller/internal/CoreInternalController.java new file mode 100644 index 0000000..76e43c4 --- /dev/null +++ b/src/main/java/com/earseo/core/controller/internal/CoreInternalController.java @@ -0,0 +1,23 @@ +package com.earseo.core.controller.internal; + +import com.earseo.core.dto.internal.StoryDocentRequest; +import com.earseo.core.dto.internal.StoryDocentResponse; +import com.earseo.core.service.internal.InternalService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class CoreInternalController { + + private final InternalService internalService; + + @PostMapping("/internal/core/docent/story") + public List getStoryDocent(@RequestBody List request) { + return internalService.createStorySpotDocent(request); + } +} diff --git a/src/main/java/com/earseo/core/dto/etl/DocentItemDto.java b/src/main/java/com/earseo/core/dto/etl/DocentItemDto.java new file mode 100644 index 0000000..59b4229 --- /dev/null +++ b/src/main/java/com/earseo/core/dto/etl/DocentItemDto.java @@ -0,0 +1,8 @@ +package com.earseo.core.dto.etl; + +public record DocentItemDto( + String contentId, + String script, + String docentUrl +) { +} diff --git a/src/main/java/com/earseo/core/dto/etl/JoinItemDto.java b/src/main/java/com/earseo/core/dto/etl/JoinItemDto.java new file mode 100644 index 0000000..e253135 --- /dev/null +++ b/src/main/java/com/earseo/core/dto/etl/JoinItemDto.java @@ -0,0 +1,10 @@ +package com.earseo.core.dto.etl; + +public record JoinItemDto( + Long id, + String contentId, + String title, + String outl, + String script +) { +} diff --git a/src/main/java/com/earseo/core/dto/internal/StoryDocentRequest.java b/src/main/java/com/earseo/core/dto/internal/StoryDocentRequest.java new file mode 100644 index 0000000..f59964e --- /dev/null +++ b/src/main/java/com/earseo/core/dto/internal/StoryDocentRequest.java @@ -0,0 +1,8 @@ +package com.earseo.core.dto.internal; + +public record StoryDocentRequest( + Long summaryId, + String summary, + String locale +) { +} diff --git a/src/main/java/com/earseo/core/dto/internal/StoryDocentResponse.java b/src/main/java/com/earseo/core/dto/internal/StoryDocentResponse.java new file mode 100644 index 0000000..f65c03c --- /dev/null +++ b/src/main/java/com/earseo/core/dto/internal/StoryDocentResponse.java @@ -0,0 +1,8 @@ +package com.earseo.core.dto.internal; + +public record StoryDocentResponse( + Long summaryId, + String docentScript, + String docentUrl +) { +} diff --git a/src/main/java/com/earseo/core/entity/Docent.java b/src/main/java/com/earseo/core/entity/Docent.java new file mode 100644 index 0000000..28e0f6a --- /dev/null +++ b/src/main/java/com/earseo/core/entity/Docent.java @@ -0,0 +1,28 @@ +package com.earseo.core.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Docent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content_id", nullable = false, unique = true) + private String contentId; + + @Column(name = "script", columnDefinition = "TEXT") + private String script; + + @Column(name = "docent_url") + private String docentUrl; +} diff --git a/src/main/java/com/earseo/core/entity/Master.java b/src/main/java/com/earseo/core/entity/Master.java index 0ac42bf..8602683 100644 --- a/src/main/java/com/earseo/core/entity/Master.java +++ b/src/main/java/com/earseo/core/entity/Master.java @@ -67,7 +67,7 @@ public class Master { @Column(name = "modifiedtime") private String modifiedtime; - @Column(name = "tel") + @Column(name = "tel", columnDefinition = "TEXT") private String tel; @Column(name = "m_level") diff --git a/src/main/java/com/earseo/core/entity/OdiiData.java b/src/main/java/com/earseo/core/entity/OdiiData.java new file mode 100644 index 0000000..0df4b14 --- /dev/null +++ b/src/main/java/com/earseo/core/entity/OdiiData.java @@ -0,0 +1,25 @@ +package com.earseo.core.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OdiiData { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "title") + private String title; + + @Column(name = "script", columnDefinition = "TEXT") + private String script; +} diff --git a/src/main/java/com/earseo/core/repository/DocentRepository.java b/src/main/java/com/earseo/core/repository/DocentRepository.java new file mode 100644 index 0000000..df006a5 --- /dev/null +++ b/src/main/java/com/earseo/core/repository/DocentRepository.java @@ -0,0 +1,7 @@ +package com.earseo.core.repository; + +import com.earseo.core.entity.Docent; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DocentRepository extends JpaRepository { +} diff --git a/src/main/java/com/earseo/core/repository/OdiiDataRepository.java b/src/main/java/com/earseo/core/repository/OdiiDataRepository.java new file mode 100644 index 0000000..be1d781 --- /dev/null +++ b/src/main/java/com/earseo/core/repository/OdiiDataRepository.java @@ -0,0 +1,21 @@ +package com.earseo.core.repository; + +import com.earseo.core.dto.etl.JoinItemDto; +import com.earseo.core.entity.OdiiData; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface OdiiDataRepository extends JpaRepository { + + @Query(value = """ + SELECT DISTINCT m.id, m.content_id, m.title, MIN(o.script), m.outl + FROM odii_data o + RIGHT JOIN master m ON m.title = o.title + WHERE m.title IS NOT NULL + GROUP BY m.id, m.content_id, m.title, m.outl + ORDER BY m.id + """, nativeQuery = true) + List joinWithMaster(); +} diff --git a/src/main/java/com/earseo/core/service/DocentService.java b/src/main/java/com/earseo/core/service/DocentService.java new file mode 100644 index 0000000..b3c6e44 --- /dev/null +++ b/src/main/java/com/earseo/core/service/DocentService.java @@ -0,0 +1,255 @@ +package com.earseo.core.service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.earseo.core.dto.etl.DocentItemDto; +import com.earseo.core.dto.etl.JoinItemDto; +import com.earseo.core.entity.Docent; +import com.earseo.core.entity.OdiiData; +import com.earseo.core.repository.DocentRepository; +import com.earseo.core.repository.OdiiDataRepository; +import com.fasterxml.jackson.core.StreamWriteConstraints; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.google.cloud.texttospeech.v1.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.net.URI; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import static com.earseo.core.service.ai.Prompts.DOCENT_SCRIPT; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DocentService { + + private final OdiiDataRepository odiiDataRepository; + private final DocentRepository docentRepository; + private final ChatClient chatClient; + private final AmazonS3 amazonS3; + private final TextToSpeechClient textToSpeechClient; + private final ObjectMapper objectMapper; + + + @Value("${api.key}") + private String ApiKeys; + @Value(("${cloud.aws.s3.bucket}")) + private String bucketName; + @Value("${cloud.aws.cloudfront.domain}") + private String cloudFrontDomain; + + List voices = List.of("Kore", "Algieba", "Despina", "Enceladus"); + + private String key; + private int index; + + @Transactional + public void initDocent() { + + int pageNo = 1; + int numOfRows = 100; + + while (true) { + JsonNode jsonNode = fetchOdiiApi(pageNo, numOfRows); + JsonNode body = jsonNode.path("response").path("body"); + + if (body.path("numOfRows").asInt() == 0) { + break; + } + + List odiiDataList = new ArrayList<>(); + + if (body.path("items").path("item").isArray()) { + System.out.println("hello"); + for (JsonNode item : body.path("items").path("item")) { + odiiDataList.add(OdiiData.builder().title(item.path("title").asText()).script(item.path("script").asText()).build()); + } + } + + odiiDataRepository.saveAll(odiiDataList); + pageNo++; + } + } + + public void getDocent() { + List joinItems = odiiDataRepository.joinWithMaster(); + + int chunkSize = 10; + for (int i = 0; i < joinItems.size(); i += chunkSize) { + List chunk = joinItems.subList(i, Math.min(i + chunkSize, joinItems.size())); + + try { + processChunk(chunk); + } catch (Exception e) { + return; + } + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void processChunk(List chunk) { + List docents = new ArrayList<>(); + + for (JoinItemDto data : chunk) { + log.info("current item id : " + data.id()); + String source = data.outl(); + + if (data.script() != null) { + source = data.script(); + } + + String prompt = String.format(DOCENT_SCRIPT.message, data.title(), source); + try { + String script = chatClient.prompt(prompt).call().content(); + String docentUrl = getDocentUrl(data.contentId(), script, "ko"); + + Docent docent = Docent.builder() + .contentId(data.contentId()) + .script(script) + .docentUrl(docentUrl) + .build(); + + docents.add(docent); + } catch (Exception e) { + log.error(e.getMessage()); + log.info("failed item id : " + data.id()); + } + } + + docentRepository.saveAll(docents); + } + + public String getDocentJson(){ + try{ + List docents = docentRepository.findAll(); + + List exportData = docents.stream() + .map(docent -> new DocentItemDto( + docent.getContentId(), + docent.getScript(), + docent.getDocentUrl() + )) + .toList(); + + File tempFile = File.createTempFile("docent_data_", ".json"); + + objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + objectMapper.writeValue(tempFile, exportData); + + String s3Key = "core/docent/docent_data.json"; + + objectMapper + .getFactory() + .setStreamWriteConstraints( + StreamWriteConstraints.builder() + .maxNestingDepth(3000) + .build() + ); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType("application/json"); + metadata.setContentLength(tempFile.length()); + + amazonS3.putObject( + new PutObjectRequest(bucketName, s3Key, tempFile) + ); + + tempFile.delete(); + + return String.format("https://%s/%s", cloudFrontDomain, s3Key); + } + catch (Exception e){ + return null; + } + } + + public String getDocentUrl(String contentId, String script, String lang) { + try { + SynthesisInput input = SynthesisInput.newBuilder() + .setText(script) + .build(); + + String langCode = switch (lang) { + case "ko" -> "ko-KR"; + case "en" -> "en-US"; + default -> throw new IllegalStateException("Unexpected value: " + lang); + }; + + String voiceName = voices.get(Integer.parseInt(contentId) % voices.size()); + + VoiceSelectionParams voice = VoiceSelectionParams.newBuilder() + .setLanguageCode(langCode) + .setName(langCode + "-Chirp3-HD-"+ voiceName) + .build(); + + AudioConfig audioConfig = AudioConfig.newBuilder() + .setAudioEncoding(AudioEncoding.MP3) + .setSpeakingRate(1.0) + .build(); + + SynthesizeSpeechResponse response = textToSpeechClient.synthesizeSpeech(input, voice, audioConfig); + + byte[] audioContent = response.getAudioContent().toByteArray(); + String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String fileName = contentId + "_" + lang + "_" + date + ".mp3"; + + String s3Key = "core/docent/" + contentId + "/" + fileName; + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(audioContent.length); + metadata.setContentType("audio/mpeg"); + + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(audioContent)) { + amazonS3.putObject( + new PutObjectRequest( + bucketName, + s3Key, + inputStream, + metadata + ) + ); + } + + return String.format("https://%s/%s", cloudFrontDomain, s3Key); + + } catch (Exception e) { + log.error("Failed to generate TTS for: {}", contentId, e); + return null; + } + } + + public JsonNode fetchOdiiApi(int pageNo, int numOfRows) { + this.key = ApiKeys.split(",")[0]; + this.index = 0; + RestClient client = RestClient.create(); + + URI uri = UriComponentsBuilder.fromHttpUrl("https://apis.data.go.kr/B551011/Odii/storyBasedList").queryParam("serviceKey", this.key).queryParam("MobileApp", "AppTest").queryParam("MobileOS", "ETC").queryParam("pageNo", pageNo).queryParam("numOfRows", numOfRows).queryParam("_type", "json").queryParam("langCode", "ko").build(true).toUri(); + try { + return client.get().uri(uri).retrieve().body(JsonNode.class); + } catch (Exception e) { + System.out.println(e.getMessage()); + if (this.index + 1 < ApiKeys.split(",").length) { + this.index++; + this.key = ApiKeys.split(",")[this.index]; + return fetchOdiiApi(pageNo, numOfRows); + } + return null; + } + } + +} diff --git a/src/main/java/com/earseo/core/service/ai/Prompts.java b/src/main/java/com/earseo/core/service/ai/Prompts.java new file mode 100644 index 0000000..521cf1b --- /dev/null +++ b/src/main/java/com/earseo/core/service/ai/Prompts.java @@ -0,0 +1,39 @@ +package com.earseo.core.service.ai; + +public enum Prompts { + + DOCENT_SCRIPT(""" + 다음 관광지에 대한 도슨트 스크립트를 생성해주세요. + + 제목: %s + 설명: %s + + # 작성 규칙 + - 존댓말 사용 (~이에요, ~보세요, ~할 수 있어요) + - 역할극, 대사 형식, 3인칭 서술 금지 + - 질문형 문장 금지 + - 차분하고 따뜻한 해설톤 유지 + - 분량: 900~1,500자 (약 3분 분량) + + # 금지 표현 + "안녕하세요", "와, 정말 멋지죠?", "저는 ~입니다", 해설자 인칭 표현 + + # 구성 + 1. 시작 (2~3문장): 현재 장소 소개 + 2. 본론 (500~800자): 역사, 구조, 특징, 풍경 + 3. 확장 (200~400자): 관련 인물, 전설, 문화행사 + 4. 마무리 (2~3문장): 감성 정리 + + # 표현 방식 + - "이곳은", "여기에서는", "보시면" 활용 + - 객관적 사실 + 감성적 묘사 균형 + - 담백한 감정 표현 + - 정보를 자연스럽게 재구성하되, 핵심 내용은 빠뜨리지 않기 + """), + ; + + public final String message; + Prompts(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/earseo/core/service/internal/InternalService.java b/src/main/java/com/earseo/core/service/internal/InternalService.java new file mode 100644 index 0000000..e8d50d9 --- /dev/null +++ b/src/main/java/com/earseo/core/service/internal/InternalService.java @@ -0,0 +1,93 @@ +package com.earseo.core.service.internal; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.earseo.core.dto.internal.StoryDocentRequest; +import com.earseo.core.dto.internal.StoryDocentResponse; +import com.google.cloud.texttospeech.v1.*; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class InternalService { + + private final AmazonS3 amazonS3; + private final TextToSpeechClient textToSpeechClient; + + @Value(("${cloud.aws.s3.bucket}")) + private String bucketName; + @Value("${cloud.aws.cloudfront.domain}") + private String cloudFrontDomain; + + List voices = List.of("Kore", "Algieba", "Despina", "Enceladus"); + + public List createStorySpotDocent(List requests) { + List responses = new ArrayList<>(); + + for(StoryDocentRequest request : requests) { + try { + SynthesisInput input = SynthesisInput.newBuilder() + .setText(request.summary()) + .build(); + + String langCode = switch (request.locale()) { + case "KO" -> "ko-KR"; + case "EN" -> "en-US"; + default -> throw new IllegalStateException("Unexpected value: " + request.locale()); + }; + + int index = (int) (Math.random() * 4); + + String voiceName = voices.get(index); + + VoiceSelectionParams voice = VoiceSelectionParams.newBuilder() + .setLanguageCode(langCode) + .setName(langCode + "-Chirp3-HD-"+ voiceName) + .build(); + + AudioConfig audioConfig = AudioConfig.newBuilder() + .setAudioEncoding(AudioEncoding.MP3) + .setSpeakingRate(1.0) + .build(); + + SynthesizeSpeechResponse response = textToSpeechClient.synthesizeSpeech(input, voice, audioConfig); + + byte[] audioContent = response.getAudioContent().toByteArray(); + String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddhhmm")); + + String s3Key = "story/docent/" + request.summaryId() + "/" + date + ".mp3"; + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(audioContent.length); + metadata.setContentType("audio/mpeg"); + + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(audioContent)) { + amazonS3.putObject( + new PutObjectRequest( + bucketName, + s3Key, + inputStream, + metadata + ) + ); + } + + String docentUrl = String.format("https://%s/%s", cloudFrontDomain, s3Key); + + responses.add(new StoryDocentResponse(request.summaryId(), request.summary(), docentUrl)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + return responses; + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index cb07d82..d9485c7 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -52,6 +52,9 @@ spring: master: ${SPRING_DATA_REDIS_SENTINEL_MASTER:valkey-master} nodes: ${SPRING_DATA_REDIS_SENTINEL_NODES:localhost:26379} database: 1 + ai: + openai: + api-key: ${OPEN_AI_API_KEY} otel: propagators: diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 7af9a03..f54246b 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -50,7 +50,9 @@ spring: master: ${SPRING_DATA_REDIS_SENTINEL_MASTER:valkey-master} nodes: ${SPRING_DATA_REDIS_SENTINEL_NODES:localhost:26379,localhost:26380,localhost:26381} database: 6 - + ai: + openai: + api-key: ${OPEN_AI_API_KEY} management: endpoints: web: @@ -86,3 +88,7 @@ logging: api: key: ${API_KEY} + +gcp: + credentials: + base64: ${GCP_CREDENTIALS_BASE64:key} \ No newline at end of file diff --git a/src/main/resources/application-test.yaml b/src/main/resources/application-test.yaml index baa0d53..0c77a7b 100644 --- a/src/main/resources/application-test.yaml +++ b/src/main/resources/application-test.yaml @@ -46,7 +46,9 @@ spring: database: 6 host: localhost port: 6379 - + ai: + openai: + api-key: openaikey management: endpoints: web: @@ -75,4 +77,8 @@ cloud: domain: domain api: - key: key \ No newline at end of file + key: key + +gcp: + credentials: + base64: ${GCP_CREDENTIALS_BASE64:key} \ No newline at end of file diff --git a/src/test/java/com/earseo/core/BackendCoreApplicationTests.java b/src/test/java/com/earseo/core/BackendCoreApplicationTests.java index b304de8..8f95712 100644 --- a/src/test/java/com/earseo/core/BackendCoreApplicationTests.java +++ b/src/test/java/com/earseo/core/BackendCoreApplicationTests.java @@ -1,13 +1,27 @@ package com.earseo.core; +import com.google.cloud.texttospeech.v1.TextToSpeechClient; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; import org.springframework.test.context.ActiveProfiles; @ActiveProfiles("test") @SpringBootTest class BackendCoreApplicationTests { + @TestConfiguration + static class TestConfig { + @Bean + @Primary + public TextToSpeechClient textToSpeechClient() { + return Mockito.mock(TextToSpeechClient.class); + } + } + @Test void contextLoads() { }