diff --git a/.github/workflows/deploy-ai.yml b/.github/workflows/deploy-ai.yml new file mode 100644 index 00000000..988e194b --- /dev/null +++ b/.github/workflows/deploy-ai.yml @@ -0,0 +1,202 @@ +name: Deploy AI Service To EC2 + +on: + workflow_dispatch: + inputs: + image_tag: + description: "Docker image tag to deploy (default: commit SHA)" + required: false + type: string + pull_request: + types: + - closed + +env: + AWS_REGION: ap-northeast-2 + SERVICE_NAME: machine + ECR_REPO: oplust-machine + +jobs: + build-and-push: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'develop') }} + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Ensure ECR repository exists + run: | + aws ecr describe-repositories --repository-names "${{ env.ECR_REPO }}" >/dev/null 2>&1 || \ + aws ecr create-repository --repository-name "${{ env.ECR_REPO }}" >/dev/null + + - name: Download tagging model + run: aws s3 cp "${{ secrets.AI_TAGGING_MODEL_S3_URI }}" ./apps/machine/models/tagging/ --recursive + + - name: Download recommend model + run: aws s3 cp "${{ secrets.AI_RECOMMEND_MODEL_S3_URI }}" ./apps/machine/models/recommend/ --recursive + + - name: Build and push image + env: + ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com + IMAGE_TAG_INPUT: ${{ github.event.inputs.image_tag }} + run: | + IMAGE_TAG="${IMAGE_TAG_INPUT:-${GITHUB_SHA}}" + IMAGE_URI="${ECR_REGISTRY}/${ECR_REPO}:${IMAGE_TAG}" + IMAGE_URI_LATEST="${ECR_REGISTRY}/${ECR_REPO}:latest" + + docker build \ + -f "apps/machine/Dockerfile" \ + -t "${IMAGE_URI}" \ + -t "${IMAGE_URI_LATEST}" \ + . + + docker push "${IMAGE_URI}" + docker push "${IMAGE_URI_LATEST}" + + deploy: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'develop') }} + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy AI service to EC2 via SSM + env: + ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com + IMAGE_TAG_INPUT: ${{ github.event.inputs.image_tag }} + PROJECT_NAME: oplust + SSM_MACHINE_ENV_PARAM: /oplust/machine/env + run: | + set -euo pipefail + + IMAGE_TAG="${IMAGE_TAG_INPUT:-${GITHUB_SHA}}" + IMAGE_URI="${ECR_REGISTRY}/${ECR_REPO}:${IMAGE_TAG}" + TARGET_TAG="${PROJECT_NAME}-machine-ec2" + CONTAINER_NAME="oplust-machine" + ENV_FILE="/etc/oplust/machine.env" + PORT="8000" + + MONITORING_PRIVATE_IP=$(aws ec2 describe-instances \ + --region "$AWS_REGION" \ + --filters "Name=tag:Name,Values=${PROJECT_NAME}-monitoring-ec2" "Name=instance-state-name,Values=running" \ + --query "Reservations[0].Instances[0].PrivateIpAddress" \ + --output text) + + if [ -z "$MONITORING_PRIVATE_IP" ] || [ "$MONITORING_PRIVATE_IP" = "None" ]; then + echo "No running monitoring instance found for tag: ${PROJECT_NAME}-monitoring-ec2" >&2 + exit 1 + fi + + INSTANCE_ID=$(aws ec2 describe-instances \ + --region "$AWS_REGION" \ + --filters "Name=tag:Name,Values=${TARGET_TAG}" "Name=instance-state-name,Values=running" \ + --query "Reservations[0].Instances[0].InstanceId" \ + --output text) + + if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" = "None" ]; then + echo "No running instance found for tag: ${TARGET_TAG}" >&2 + exit 1 + fi + + PROMTAIL_CONFIG_B64=$(printf '%s\n' \ + 'server:' \ + ' http_listen_port: 9080' \ + ' grpc_listen_port: 0' \ + '' \ + 'positions:' \ + ' filename: /tmp/positions.yaml' \ + '' \ + 'clients:' \ + " - url: http://${MONITORING_PRIVATE_IP}:3100/loki/api/v1/push" \ + '' \ + 'scrape_configs:' \ + ' - job_name: docker' \ + ' static_configs:' \ + ' - targets: [localhost]' \ + ' labels:' \ + ' job: docker' \ + ' role: ai' \ + ' __path__: /var/lib/docker/containers/*/*-json.log' \ + ' pipeline_stages:' \ + ' - docker: {}' \ + | base64 | tr -d '\n') + + PARAMS_FILE=$(mktemp) + COMMANDS=( + "set -e" + "sudo mkdir -p /etc/oplust" + "SERVICE_ENV=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_MACHINE_ENV_PARAM' --with-decryption --query 'Parameter.Value' --output text)" + "printf '%s\n' \"\$SERVICE_ENV\" | sudo tee ${ENV_FILE} >/dev/null" + "sudo chmod 600 ${ENV_FILE}" + "aws ecr get-login-password --region $AWS_REGION | sudo docker login --username AWS --password-stdin $ECR_REGISTRY" + "sudo docker pull ${IMAGE_URI}" + "sudo docker rm -f ${CONTAINER_NAME} || true" + "sudo docker run -d --name ${CONTAINER_NAME} --restart unless-stopped -p ${PORT}:${PORT} --env-file ${ENV_FILE} ${IMAGE_URI}" + "sudo mkdir -p /opt/oplust-promtail /opt/oplust-promtail/positions" + "echo '${PROMTAIL_CONFIG_B64}' | base64 -d | sudo tee /opt/oplust-promtail/promtail.yml >/dev/null" + "sudo docker rm -f promtail || true" + "sudo docker run -d --name promtail --restart unless-stopped -v /opt/oplust-promtail/promtail.yml:/etc/promtail/config.yml:ro -v /opt/oplust-promtail/positions:/tmp -v /var/lib/docker/containers:/var/lib/docker/containers:ro grafana/promtail:2.9.8 -config.file=/etc/promtail/config.yml" + ) + printf '%s\n' "${COMMANDS[@]}" | jq -R . | jq -s '{commands: .}' > "$PARAMS_FILE" + + COMMAND_ID=$(aws ssm send-command \ + --region "$AWS_REGION" \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "Deploy ${CONTAINER_NAME}:${IMAGE_TAG}" \ + --parameters "file://${PARAMS_FILE}" \ + --query 'Command.CommandId' \ + --output text) + + rm -f "$PARAMS_FILE" + + echo "[${CONTAINER_NAME}] command id: ${COMMAND_ID} (instance: ${INSTANCE_ID})" + + for _ in $(seq 1 120); do + STATUS=$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$COMMAND_ID" \ + --instance-id "$INSTANCE_ID" \ + --query 'Status' \ + --output text 2>/dev/null || true) + + case "$STATUS" in + Success) + echo "[${CONTAINER_NAME}] deployment success" + exit 0 + ;; + Failed|Cancelled|TimedOut) + echo "[${CONTAINER_NAME}] deployment failed with status: ${STATUS}" >&2 + aws ssm get-command-invocation --region "$AWS_REGION" --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' --output json || true + exit 1 + ;; + Pending|InProgress|Delayed|"") + sleep 5 + ;; + *) + echo "[${CONTAINER_NAME}] unexpected status: ${STATUS}" >&2 + sleep 5 + ;; + esac + done + + echo "[${CONTAINER_NAME}] deployment timed out waiting for SSM command completion" >&2 + aws ssm get-command-invocation --region "$AWS_REGION" --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' --output json || true + exit 1 diff --git a/.gitignore b/.gitignore index f69201c0..c9c425c5 100644 --- a/.gitignore +++ b/.gitignore @@ -44,13 +44,7 @@ __pycache__/ *.pyc # ML artifacts -apps/machine/models/klue_saved_model/checkpoints/ -apps/machine/models/klue_saved_model/*.bin -apps/machine/models/klue_saved_model/training_args.bin -apps/machine/models/klue_saved_model/optimizer.pt -apps/machine/models/klue_saved_model/scheduler.pt -apps/machine/models/klue_saved_model/rng_state.pth -apps/machine/models/klue_saved_model/trainer_state.json +apps/machine/models/ ### Node ### diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ApiAdminApplication.java b/apps/api-admin/src/main/java/com/ott/api_admin/ApiAdminApplication.java index df9c9a2d..d1970913 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/ApiAdminApplication.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ApiAdminApplication.java @@ -6,12 +6,14 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @ComponentScan(basePackages = "com.ott") @EntityScan(basePackages = "com.ott.domain") @EnableJpaRepositories(basePackages = "com.ott.domain") @EnableJpaAuditing +@EnableAsync public class ApiAdminApplication { public static void main(String[] args) { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java b/apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java index 96465668..307947bb 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.reactive.function.client.WebClient; import java.time.Duration; @@ -19,8 +20,15 @@ public class AiClient { private final WebClient aiWebClient; + @Value("${ai.timeout-ms}") + private Long timeoutMs; + /** * FastAPI 서버에 영상 줄거리를 보내고 감정 태그 리스트를 받아옵니다. + * 현재는 비동기 + 블로킹으로 AI 서버의 응답을 기다리고 있습니다. + * 다만, 관리자 서버의 요청 스레드(Tomcat)가 블로킹하는게 아닌, 관리자 서버 요청 스레드는 비동기로 바로 반환되고 + * 비동기 작업에서 사용되는 스레드(Async)로 해당 AI서버의 응답을 블로킹 하기 때문에 더 효율적이라 판단했습니다. + * 추후, 유저도 업로드로 확장 된다면 비동기 + 논블로킹도 좋은 방법이라 생각됩니다. */ public List getEmotionTags(Long mediaId, String description) { log.info("[Admin AI] 미디어 태깅 요청: mediaId={}", mediaId); @@ -33,7 +41,7 @@ public List getEmotionTags(Long mediaId, String description) { .bodyValue(requestDto) .retrieve() .bodyToMono(TaggingResponse.class) - .timeout(Duration.ofSeconds(5)) + .timeout(Duration.ofMillis(timeoutMs)) // 해당 시간까지 AI작업이 끝나야함을 명시 .block(); // 비동기 작업 내에서 안전하게 블로킹 처리 if (response == null || response.getMoodTags() == null) { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ai/dto/TaggingRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/ai/dto/TaggingRequest.java index f458ab3b..b4100142 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/ai/dto/TaggingRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ai/dto/TaggingRequest.java @@ -9,7 +9,6 @@ @AllArgsConstructor public class TaggingRequest { @JsonProperty("media_id") - private Long mediaId; // 에러 로깅이나 추적을 위해 남겨둠 - - private String description; // 영상 줄거리 (AI 분석의 핵심 재료) + private Long mediaId; + private String description; } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/WebClientConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/WebClientConfig.java index dc9b7619..9abba82e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/WebClientConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/WebClientConfig.java @@ -13,11 +13,11 @@ public class WebClientConfig { @Bean public WebClient aiWebClient( - @Value("${ai.base-url:http://localhost:8000}") String baseUrl, - @Value("${ai.timeout-ms:2000}") long timeoutMs + @Value("${ai.base-url}") String baseUrl, + @Value("${ai.timeout-ms}") long timeoutMs ) { HttpClient httpClient = HttpClient.create() - .responseTimeout(Duration.ofMillis(timeoutMs)); + .responseTimeout(Duration.ofMillis(timeoutMs)); // 응답이 해당 시간까지 안오면 끊겠다. return WebClient.builder() .baseUrl(baseUrl) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index 452b86ec..18645e55 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -7,6 +7,7 @@ import com.ott.api_admin.content.dto.response.ContentsUpdateResponse; import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.api_admin.content.mapper.BackOfficeContentsMapper; +import com.ott.api_admin.tagging.event.AiTaggingRequestedEvent; import com.ott.api_admin.upload.support.MediaTagLinker; import com.ott.api_admin.upload.support.UploadHelper; import com.ott.common.web.exception.BusinessException; @@ -25,6 +26,7 @@ import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -45,6 +47,7 @@ public class BackOfficeContentsService { private final SeriesRepository seriesRepository; private final UploadHelper uploadHelper; private final MediaTagLinker mediaTagLinker; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public PageResponse getContents(int page, int size, String searchWord, PublicStatus publicStatus) { @@ -148,6 +151,9 @@ public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); + // 임시로 해당 위치로 삽입 상태 관리 픽스 후 추후 변경 예정 + eventPublisher.publishEvent(new AiTaggingRequestedEvent(media.getId(), request.description())); + return backOfficeContentsMapper.toContentsUploadResponse( contentsId, mediaCreateUploadResult.posterObjectKey(), diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tagging/event/AiTaggingRequestedEvent.java b/apps/api-admin/src/main/java/com/ott/api_admin/tagging/event/AiTaggingRequestedEvent.java new file mode 100644 index 00000000..94382316 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tagging/event/AiTaggingRequestedEvent.java @@ -0,0 +1,4 @@ +package com.ott.api_admin.tagging.event; + +public record AiTaggingRequestedEvent(Long mediaId, String description) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java b/apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java index c58bb832..95808b65 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java @@ -1,27 +1,25 @@ package com.ott.api_admin.tagging.service; import com.ott.api_admin.ai.client.AiClient; +import com.ott.api_admin.tagging.event.AiTaggingRequestedEvent; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.domain.common.Status; import com.ott.domain.media.domain.Media; import com.ott.domain.media.repository.MediaRepository; import com.ott.domain.media_mood_tag.domain.MediaMoodTag; -import com.ott.domain.media_mood_tag.repository.MediaMoodTagRepository; import com.ott.domain.mood_tag.domain.MoodTag; import com.ott.domain.mood_tag.repository.MoodTagRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; -import java.util.stream.IntStream; @Slf4j @Service @@ -31,62 +29,106 @@ public class AITaggingAsyncService { private final AiClient aiClient; private final MediaRepository mediaRepository; private final MoodTagRepository moodTagRepository; - private final MediaMoodTagRepository mediaMoodTagRepository; + private final MediaMoodTagAppend mediaMoodTagAppend; - // 비동기 실행으로 관리자의 업로드 응답 속도에 영향을 주지 않도록 함 - @Transactional - @Async - public void processAiTagging(Long mediaId, String description) { - log.info("[AI Tagging] 미디어 ID: {} 백그라운드로 태깅 분류 시작", mediaId); + /** + * TransactionPhase : 원본 트랜잭션의 어느 시점에 해당 이벤트를 실행시킬 것인가를 결정 + * 이벤트를 발행한 쪽의 트랜잭션 상태에 따라 해당 리스너가 달린 함수의 호출 시점이 결정됨 + * AFTER_COMMIT - 원본 트랜잭션이 커밋된 이후 해당 함수 실행 + * BEFORE_COMMIT - 커밋 직전에 실행되어, 원본 트랜잭션 안에서 같이 묶이고 싶을 때 사용 + * AFTER_ROLLBACK - 원본 트랜잭션이 롤백된 이후 실행 + * AFTER_COMPLETION - 커밋이든 롤백이는 원본 트랜잭션이 끝나면 실행 -> finally 느낌임 + * + * ========================================== + * Propagation : 트랜잭션을 어떻게 만들거나 참여할 것인가를 결정 + * 트랜잭션 전파 레벨이라고 부르며, 메소드 A가 메소드 B를 호출할 경우, B가 A의 트랜잭션에 참여할지, 새로 만들지 등을 결정 + * REQUIRED(Default) - 기존 트랜잭션이 있으면 참여하고, 없으면 새로 만들어라 + * REQUIRED_NEW - 기존 트랜잭션이 있으며녀 멈추고, 새로운 트랜잭션을 만들어서 먼저 커밋/롤백 시켜라 + * SUPPORTS - 기존 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행해라 -> 읽이 전용 조회일 경우 사용함 + * NOT_SUPPORTED - 트랜잭션 없이 실행하지만, 기존 트랜잭션이 있으면 기존 트랜잭션을 멈추고 수행 + * MANDATORY - 기존 트랜잭션이 없으면 예외처리 + * NEVER - 기존 트랜잭션이 있으면 예외처리 + * NESTED - 잘 안씀 + */ + @Async // 별도 백그라운드 스레드로 진행 + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleAiTagging(AiTaggingRequestedEvent event) { + log.info("[AI Tagging] 미디어 ID: {} 백그라운드로 태깅 분류 시작", event.mediaId()); + + List aiTags; try { - List aiTags = aiClient.getEmotionTags(mediaId, description); + // ML에서 추론된 태그 리스트 -> 순서가 보장됨 + aiTags = aiClient.getEmotionTags(event.mediaId(), event.description()); + + } catch (Exception e) { + log.error("[AI Tagging] mediaId={} AI 호출 실패", event.mediaId(), e); + return; + } + - if (aiTags == null || aiTags.isEmpty()) { - log.info("[AI Tagging] 미디어 ID: {} - AI가 반환한 태그가 없습니다.", mediaId); + if (aiTags == null || aiTags.isEmpty()) { + log.info("[AI Tagging] 미디어 ID: {} - AI가 반환한 태그가 없습니다.", event.mediaId()); return; } - Media media = mediaRepository.findById(mediaId) + Media media = mediaRepository.findById(event.mediaId()) .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND)); + // DB단에서 매핑된 리스트 -> 순서 보장 x List foundMoodTags = moodTagRepository.findByNameInAndStatus(aiTags, Status.ACTIVE); + + // AI 태깅 리스트 <-> DB 리스트 일치 Map moodTagByName = foundMoodTags.stream() - .collect(Collectors.toMap(MoodTag::getName, Function.identity(), (left, right) -> left, LinkedHashMap::new)); - - List newMediaMoodTags = IntStream.range(0, aiTags.size()) - .mapToObj(index -> Map.entry(index, aiTags.get(index))) - .filter(entry -> moodTagByName.containsKey(entry.getValue())) - .filter(entry -> aiTags.indexOf(entry.getValue()) == entry.getKey()) - .map(entry -> MediaMoodTag.builder() - .media(media) - .moodTag(moodTagByName.get(entry.getValue())) - .priority(entry.getKey()) - .build()) - .toList(); + .collect(Collectors.toMap( + MoodTag::getName, // 키: 태그 이름 + Function.identity(), // 값: MoodTag 엔티티 자체 + (left, right) -> left, // 이름 중복 시 먼저 온 걸 사용 + LinkedHashMap::new // 순서 유지 + )); + + // MediaMoodTag에 저장할 리스트 + List newMediaMoodTags = new ArrayList<>(); + + // 중복 방지용 + Set seen = new LinkedHashSet<>(); + + for (int i = 0; i < aiTags.size(); i++) { + String tagName = aiTags.get(i); + MoodTag moodTag = moodTagByName.get(tagName); + + // DB에 없거나 중복태그일 경우 스킵 + if (moodTag == null || !seen.add(tagName)) { + continue; + } + + newMediaMoodTags.add(MediaMoodTag.builder() + .media(media) + .moodTag(moodTag) + .priority(i + 1) // 1부터 시작하는 우선순위 + .build()); + } + if (newMediaMoodTags.isEmpty()) { - log.warn("[AI Tagging] 미디어 ID: {} - DB에 매핑 가능한 mood_tag가 없습니다. aiTags={}", mediaId, aiTags); + log.warn("[AI Tagging] 미디어 ID: {} - DB에 매핑 가능한 mood_tag가 없습니다.", event.mediaId()); return; } + // DB <-> AI 태킹 불일치 확인용 로그 List missingTags = aiTags.stream() .filter(tagName -> !moodTagByName.containsKey(tagName)) .distinct() .toList(); if (!missingTags.isEmpty()) { - log.warn("[AI Tagging] 미디어 ID: {} - DB에 없는 mood_tag를 제외합니다. missingTags={}", mediaId, missingTags); + log.warn("[AI Tagging] mediaId={} DB에 없는 mood tag 제외 {}", event.mediaId(), missingTags); } - mediaMoodTagRepository.deleteByMedia_Id(mediaId); - mediaMoodTagRepository.saveAll(newMediaMoodTags); + // 등록 후, 줄거리가 수정될 경우 변경할 경우 삭제 후 삽입 + mediaMoodTagAppend.replaceMediaMoodTags(event.mediaId(), newMediaMoodTags); + + log.info("[AI Tagging] mediaId={} mood tag {}건 저장 완료", event.mediaId(), newMediaMoodTags.size()); - log.info("[AI Tagging] 미디어 ID: {} - mood tag {}건 저장 완료", mediaId, newMediaMoodTags.size()); - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - log.error("[AI Tagging] 미디어 ID: {} - 태깅 저장 중 예외 발생", mediaId, e); - } } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/MediaMoodTagAppend.java b/apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/MediaMoodTagAppend.java new file mode 100644 index 00000000..94a8ac3e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/MediaMoodTagAppend.java @@ -0,0 +1,22 @@ +package com.ott.api_admin.tagging.service; + +import com.ott.domain.media_mood_tag.domain.MediaMoodTag; +import com.ott.domain.media_mood_tag.repository.MediaMoodTagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MediaMoodTagAppend { + + private final MediaMoodTagRepository mediaMoodTagRepository; + + @Transactional + public void replaceMediaMoodTags(Long mediaId, List newMediaMoodTags) { + mediaMoodTagRepository.deleteByMedia_Id(mediaId); + mediaMoodTagRepository.saveAll(newMediaMoodTags); + } +} diff --git a/apps/api-admin/src/main/resources/application.yml b/apps/api-admin/src/main/resources/application.yml index 9c0ffaff..17403124 100644 --- a/apps/api-admin/src/main/resources/application.yml +++ b/apps/api-admin/src/main/resources/application.yml @@ -59,4 +59,5 @@ cloudfront: ai: - base-url: ${AI_BASE_URL:http://localhost:8000} \ No newline at end of file + base-url: ${AI_BASE_URL:http://localhost:8000} + timeout-ms: 30000 \ No newline at end of file diff --git a/apps/machine/Dockerfile b/apps/machine/Dockerfile index f73bb970..3e85aaba 100644 --- a/apps/machine/Dockerfile +++ b/apps/machine/Dockerfile @@ -5,6 +5,11 @@ WORKDIR /app COPY apps/machine/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +COPY apps/machine/models/tagging /app/models/tagging +COPY apps/machine/models/recommend /app/models/recommend + + + COPY apps/machine/ ./machine/ ENV PYTHONPATH=/app/machine diff --git a/apps/machine/app/config.py b/apps/machine/app/config.py index aedef014..092054f7 100644 --- a/apps/machine/app/config.py +++ b/apps/machine/app/config.py @@ -9,7 +9,9 @@ class Settings(BaseSettings): default_factory=list, env="AI_CORS_ALLOW_ORIGINS" ) - model_path: Optional[str] = None + tagging_model_path: str = Field(env="AI_TAGGING_MODEL_PATH") + # recommend_model_path: str = Field(env="AI_RECOMMEND_MODEL_PATH") + # model_path: Optional[str] = None model_config = SettingsConfigDict( env_prefix="AI_", diff --git a/apps/machine/app/services/tagging.py b/apps/machine/app/services/tagging.py index a9b2fea6..7e1328dd 100644 --- a/apps/machine/app/services/tagging.py +++ b/apps/machine/app/services/tagging.py @@ -1,6 +1,7 @@ import torch import numpy as np from transformers import AutoTokenizer, AutoModelForSequenceClassification +from app.config import settings import logging from app.errors import AppException, ErrorCode @@ -8,7 +9,7 @@ logger = logging.getLogger(__name__) class MoodTagger: - def __init__(self, model_path: str = "models/klue_saved_model"): + def __init__(self, model_path: str): self.model_path = model_path # GPU가 있으면 쓰고, 없으면 CPU 사용 self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") @@ -67,4 +68,4 @@ def predict(self, text: str, top_k: int = 3) -> list[str]: return results # 서버 기동 시 인스턴스를 하나만 생성해 둠 -mood_tagger = MoodTagger() +mood_tagger = MoodTagger(settings.tagging_model_path) diff --git a/apps/machine/models/recommend/.gitkeep b/apps/machine/models/recommend/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docker-compose.yml b/docker-compose.yml index 31dcc37f..d8737a9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,8 @@ services: AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} CF_SIGNED_COOKIE_KEY_PAIR_ID: ${CF_SIGNED_COOKIE_KEY_PAIR_ID} CF_SIGNED_COOKIE_PRIVATE_KEY_BASE64: ${CF_SIGNED_COOKIE_PRIVATE_KEY_BASE64} + AI_BASE_URL: ${AI_BASE_URL} + AI_TIMEOUT_MS: 30000 depends_on: mysql: condition: service_healthy @@ -177,7 +179,8 @@ services: ports: - "8000:8000" environment: - - AI_MODEL_PATH=${AI_MODEL_PATH:-} + - AI_TAGGING_MODEL_PATH=/app/models/tagging + - AI_RECOMMEND_MODEL_PATH=/app/models/recommend - AI_BASE_URL=${AI_BASE_URL:-http://machine:8000} - TIMEOUT_MS=2000 healthcheck: @@ -185,7 +188,6 @@ services: interval: 10s timeout: 5s retries: 5 - volumes: mysql-data: prometheus-data: