diff --git a/.github/workflows/service-deploy.yml b/.github/workflows/service-deploy.yml index b508a164..93034d83 100644 --- a/.github/workflows/service-deploy.yml +++ b/.github/workflows/service-deploy.yml @@ -1,4 +1,3 @@ - # 워크 플로우 이름 name: CD - Docker Build & Deploy to EC2 @@ -56,6 +55,7 @@ jobs: echo "CLAUDE_API_KEY=${{ secrets.CLAUDE_API_KEY }}" >> .env echo "AWS_SES_ACCESS_KEY=${{ secrets.AWS_SES_ACCESS_KEY }}" >> .env echo "AWS_SES_SECRET_KEY=${{ secrets.AWS_SES_SECRET_KEY }}" >> .env + echo "BRAVE_API_KEY=${{ secrets.BRAVE_API_KEY }}" >> .env # EC2 접속 후 .env 파일 업로드 - name: Upload .env to EC2 @@ -79,7 +79,7 @@ jobs: echo "[1] 현재 nginx가 사용하는 포트 확인" CURRENT_PORT=$(grep -o 'proxy_pass http://localhost:[0-9]*;' /etc/nginx/conf.d/api.conf | grep -o '[0-9]*') - + if [ "$CURRENT_PORT" = "8080" ]; then NEW_PORT=8081 OLD_CONTAINER=cs25-8080 @@ -87,7 +87,7 @@ jobs: NEW_PORT=8080 OLD_CONTAINER=cs25-8081 fi - + echo "[2] 새로운 포트($NEW_PORT)로 컨테이너 실행" docker pull baekjonghyun/cs25-service:latest docker run -d \ @@ -95,15 +95,15 @@ jobs: --env-file .env \ -p $NEW_PORT:8080 \ baekjonghyun/cs25-service:latest - + echo "[3] nginx 설정 포트 교체 및 reload" sudo sed -i "s/$CURRENT_PORT/$NEW_PORT/" /etc/nginx/conf.d/api.conf sudo nginx -t && sudo nginx -s reload - + echo "[4] 이전 컨테이너 종료 및 삭제" docker stop $OLD_CONTAINER || echo "No previous container" docker rm $OLD_CONTAINER || echo "No previous container" - + echo "[✔] 무중단 배포 완료! 현재 포트: $NEW_PORT" diff --git a/cs25-batch/src/main/java/com/example/cs25batch/batch/component/reader/RedisStreamReader.java b/cs25-batch/src/main/java/com/example/cs25batch/batch/component/reader/RedisStreamReader.java index 2abb1dae..620fe302 100644 --- a/cs25-batch/src/main/java/com/example/cs25batch/batch/component/reader/RedisStreamReader.java +++ b/cs25-batch/src/main/java/com/example/cs25batch/batch/component/reader/RedisStreamReader.java @@ -1,5 +1,6 @@ package com.example.cs25batch.batch.component.reader; +import com.example.cs25batch.sender.context.MailSenderContext; import io.github.bucket4j.Bucket; import java.time.Duration; import java.util.HashMap; @@ -9,6 +10,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.batch.item.ItemReader; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.connection.stream.Consumer; import org.springframework.data.redis.connection.stream.MapRecord; import org.springframework.data.redis.connection.stream.ReadOffset; @@ -18,6 +20,7 @@ import org.springframework.stereotype.Component; @Slf4j +@RequiredArgsConstructor @Component("redisConsumeReader") public class RedisStreamReader implements ItemReader> { @@ -25,20 +28,17 @@ public class RedisStreamReader implements ItemReader> { private static final String GROUP = "mail-consumer-group"; private static final String CONSUMER = "mail-worker"; - private final StringRedisTemplate redisTemplate; - private final Bucket bucket; + @Value("${mail.strategy:javaBatchMailSender}") + private String strategyKey; - public RedisStreamReader( - StringRedisTemplate redisTemplate, - @Qualifier("bucketEmail") Bucket bucket - ) { - this.redisTemplate = redisTemplate; - this.bucket = bucket; - } + private final StringRedisTemplate redisTemplate; + private final MailSenderContext mailSenderContext; @Override public Map read() throws InterruptedException { //long start = System.currentTimeMillis(); + Bucket bucket = mailSenderContext.getBucket(strategyKey); + while (!bucket.tryConsume(1)) { Thread.sleep(200); //토큰을 얻을 때까지 간격을 두고 재시도 } diff --git a/cs25-batch/src/main/java/com/example/cs25batch/batch/service/TodayQuizService.java b/cs25-batch/src/main/java/com/example/cs25batch/batch/service/TodayQuizService.java index 77da11fa..6b2057f8 100644 --- a/cs25-batch/src/main/java/com/example/cs25batch/batch/service/TodayQuizService.java +++ b/cs25-batch/src/main/java/com/example/cs25batch/batch/service/TodayQuizService.java @@ -1,5 +1,6 @@ package com.example.cs25batch.batch.service; +import com.example.cs25entity.domain.mail.repository.MailLogRepository; import com.example.cs25entity.domain.quiz.entity.Quiz; import com.example.cs25entity.domain.quiz.enums.QuizFormatType; import com.example.cs25entity.domain.quiz.enums.QuizLevel; @@ -8,12 +9,12 @@ import com.example.cs25entity.domain.quiz.repository.QuizRepository; import com.example.cs25entity.domain.subscription.entity.Subscription; import com.example.cs25entity.domain.subscription.repository.SubscriptionRepository; -import com.example.cs25entity.domain.userQuizAnswer.entity.UserQuizAnswer; import com.example.cs25entity.domain.userQuizAnswer.repository.UserQuizAnswerRepository; + import java.time.LocalDate; -import java.util.HashSet; import java.util.List; import java.util.Set; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -30,6 +31,7 @@ public class TodayQuizService { private final QuizRepository quizRepository; private final SubscriptionRepository subscriptionRepository; private final UserQuizAnswerRepository userQuizAnswerRepository; + private final MailLogRepository mailLogRepository; private final SesMailService mailService; @Transactional @@ -39,48 +41,53 @@ public Quiz getTodayQuizBySubscription(Subscription subscription) { Long subscriptionId = subscription.getId(); // 2. 유저 정답률 계산, 내가 푼 문제 아이디값 - List answerHistory = userQuizAnswerRepository.findBySubscriptionIdAndQuizCategoryId( - subscriptionId, parentCategoryId); - int quizCount = answerHistory.size(); // 사용자가 지금까지 푼 문제 수 - int totalCorrect = 0; - Set solvedQuizIds = new HashSet<>(); - - for (UserQuizAnswer answer : answerHistory) { - if (answer.getIsCorrect()) { - totalCorrect++; - } - solvedQuizIds.add(answer.getQuiz().getId()); - } + Double accuracyResult = userQuizAnswerRepository.getCorrectRate(subscriptionId, + parentCategoryId); + double accuracy = accuracyResult != null ? accuracyResult : 100.0; + + Set sentQuizIds = mailLogRepository.findDistinctQuiz_IdBySubscription_Id(subscriptionId); + int quizCount = sentQuizIds.size(); // 사용자가 지금까지 푼 문제 수 - double accuracy = - quizCount == 0 ? 100.0 : ((double) totalCorrect / quizCount) * 100.0; // 6. 서술형 주기 판단 (풀이 횟수 기반) - boolean isEssayDay = quizCount % 3 == 2; //일단 3배수일때 한번씩은 서술( 조정 필요하면 나중에 하는거롤) + boolean isEssayDay = quizCount % 4 == 3; //일단 3배수일때 한번씩은 서술(0,1,2 객관식 / 3서술형) - List targetTypes = isEssayDay - ? List.of(QuizFormatType.SUBJECTIVE) - : List.of(QuizFormatType.MULTIPLE_CHOICE); + QuizFormatType targetType = isEssayDay + ? QuizFormatType.SUBJECTIVE + : QuizFormatType.MULTIPLE_CHOICE; // 3. 정답률 기반 난이도 바운더리 설정 List allowedDifficulties = getAllowedDifficulties(accuracy); + // 8. 오프셋 계산 (풀이 수 기준) + long seed = LocalDate.now().toEpochDay() + subscriptionId; + int offset = (int) (seed % 20); + // 7. 필터링 조건으로 문제 조회(대분류, 난이도, 내가푼문제 제외, 제외할 카테고리 제외하고, 문제 타입 전부 조건으로) - List candidateQuizzes = quizRepository.findAvailableQuizzesUnderParentCategory( - parentCategoryId, - allowedDifficulties, - solvedQuizIds, - //excludedCategoryIds, - targetTypes - ); //한개만뽑기(find first) - - if (candidateQuizzes.isEmpty()) { // 뽀ㅃ을문제없을때 - throw new QuizException(QuizExceptionCode.NO_QUIZ_EXISTS_ERROR); + + Quiz todayQuiz = quizRepository.findAvailableQuizzesUnderParentCategory( + parentCategoryId, + allowedDifficulties, + sentQuizIds, + //excludedCategoryIds, + targetType, + offset + ); + + // offset이 너무 커서 결과가 없을 수 있으므로, offset=0으로 한 번 더 조회 + if (todayQuiz == null && offset > 0) { + todayQuiz = quizRepository.findAvailableQuizzesUnderParentCategory( + parentCategoryId, + allowedDifficulties, + sentQuizIds, + targetType, + 0 + ); + } + if (todayQuiz == null) { + throw new QuizException(QuizExceptionCode.QUIZ_VALIDATION_FAILED_ERROR); } - // 8. 오프셋 계산 (풀이 수 기준) - long seed = LocalDate.now().toEpochDay() + subscriptionId; - int offset = (int) (seed % candidateQuizzes.size()); - return candidateQuizzes.get(offset); + return todayQuiz; } diff --git a/cs25-batch/src/main/java/com/example/cs25batch/config/RateLimiterConfig.java b/cs25-batch/src/main/java/com/example/cs25batch/config/RateLimiterConfig.java deleted file mode 100644 index 8935363b..00000000 --- a/cs25-batch/src/main/java/com/example/cs25batch/config/RateLimiterConfig.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.cs25batch.config; - -import io.github.bucket4j.Bandwidth; -import io.github.bucket4j.Bucket; -import io.github.bucket4j.local.LocalBucket; -import java.time.Duration; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -@RequiredArgsConstructor -public class RateLimiterConfig { - - @Value("${mail.ratelimiter.capacity:14}") - private Long capacity; - - @Value("${mail.ratelimiter.refill:7}") - private Long refill; - - @Value("${mail.ratelimiter.millis:500}") - private Long millis; - - @Bean(name = "bucketEmail") - public Bucket bucket() { - return Bucket.builder() - .addLimit(limit -> - limit - .capacity(capacity) - .refillIntervally(refill, Duration.ofMillis(millis)) - ) - .build(); - } -} diff --git a/cs25-batch/src/main/java/com/example/cs25batch/sender/JavaMailSenderStrategy.java b/cs25-batch/src/main/java/com/example/cs25batch/sender/JavaMailSenderStrategy.java index 7212cddb..55de3e81 100644 --- a/cs25-batch/src/main/java/com/example/cs25batch/sender/JavaMailSenderStrategy.java +++ b/cs25-batch/src/main/java/com/example/cs25batch/sender/JavaMailSenderStrategy.java @@ -2,16 +2,33 @@ import com.example.cs25batch.batch.dto.MailDto; import com.example.cs25batch.batch.service.JavaMailService; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.time.Duration; + @Component("javaBatchMailSender") @RequiredArgsConstructor public class JavaMailSenderStrategy implements MailSenderStrategy{ private final JavaMailService javaMailService; + private final Bucket bucket = Bucket.builder() + .addLimit( + Bandwidth.builder() + .capacity(4) + .refillGreedy(2, Duration.ofMillis(500)) + .build() + ) + .build(); @Override public void sendQuizMail(MailDto mailDto) { javaMailService.sendQuizEmail(mailDto.getSubscription(), mailDto.getQuiz()); // 커스텀 메서드로 정의 } + + @Override + public Bucket getBucket() { + return bucket; + } } diff --git a/cs25-batch/src/main/java/com/example/cs25batch/sender/MailSenderStrategy.java b/cs25-batch/src/main/java/com/example/cs25batch/sender/MailSenderStrategy.java index 82440acb..1e3f2b89 100644 --- a/cs25-batch/src/main/java/com/example/cs25batch/sender/MailSenderStrategy.java +++ b/cs25-batch/src/main/java/com/example/cs25batch/sender/MailSenderStrategy.java @@ -1,7 +1,10 @@ package com.example.cs25batch.sender; import com.example.cs25batch.batch.dto.MailDto; +import io.github.bucket4j.Bucket; public interface MailSenderStrategy { void sendQuizMail(MailDto mailDto); + + Bucket getBucket(); } diff --git a/cs25-batch/src/main/java/com/example/cs25batch/sender/SesMailSenderStrategy.java b/cs25-batch/src/main/java/com/example/cs25batch/sender/SesMailSenderStrategy.java index fe016cec..265bb6ac 100644 --- a/cs25-batch/src/main/java/com/example/cs25batch/sender/SesMailSenderStrategy.java +++ b/cs25-batch/src/main/java/com/example/cs25batch/sender/SesMailSenderStrategy.java @@ -2,17 +2,34 @@ import com.example.cs25batch.batch.dto.MailDto; import com.example.cs25batch.batch.service.SesMailService; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.time.Duration; + @RequiredArgsConstructor @Component("sesMailSender") public class SesMailSenderStrategy implements MailSenderStrategy{ private final SesMailService sesMailService; + private final Bucket bucket = Bucket.builder() + .addLimit( + Bandwidth.builder() + .capacity(14) + .refillGreedy(7, Duration.ofMillis(500)) + .build() + ) + .build(); @Override public void sendQuizMail(MailDto mailDto) { sesMailService.sendQuizEmail(mailDto.getSubscription(), mailDto.getQuiz()); } + + @Override + public Bucket getBucket() { + return bucket; + } } diff --git a/cs25-batch/src/main/java/com/example/cs25batch/sender/context/MailSenderContext.java b/cs25-batch/src/main/java/com/example/cs25batch/sender/context/MailSenderContext.java index 82684e02..9d0fe2a7 100644 --- a/cs25-batch/src/main/java/com/example/cs25batch/sender/context/MailSenderContext.java +++ b/cs25-batch/src/main/java/com/example/cs25batch/sender/context/MailSenderContext.java @@ -3,6 +3,8 @@ import com.example.cs25batch.batch.dto.MailDto; import com.example.cs25batch.sender.MailSenderStrategy; import java.util.Map; + +import io.github.bucket4j.Bucket; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -12,10 +14,21 @@ public class MailSenderContext { private final Map strategyMap; public void send(MailDto dto, String strategyKey) { + MailSenderStrategy strategy = getValidStrategy(strategyKey); + strategy.sendQuizMail(dto); + } + + public Bucket getBucket(String strategyKey) { + MailSenderStrategy strategy = getValidStrategy(strategyKey); + return strategy.getBucket(); + } + + private MailSenderStrategy getValidStrategy(String strategyKey) { MailSenderStrategy strategy = strategyMap.get(strategyKey); if (strategy == null) { throw new IllegalArgumentException("메일 전략이 존재하지 않습니다: " + strategyKey); } - strategy.sendQuizMail(dto); + return strategy; } + } diff --git a/cs25-batch/src/main/resources/application.properties b/cs25-batch/src/main/resources/application.properties index a904f17e..c663307f 100644 --- a/cs25-batch/src/main/resources/application.properties +++ b/cs25-batch/src/main/resources/application.properties @@ -43,7 +43,4 @@ server.forward-headers-strategy=framework #mail mail.strategy=sesMailSender #mail.strategy=javaBatchMailSender -mail.ratelimiter.capacity=14 -mail.ratelimiter.refill=7 -mail.ratelimiter.millis=1000 server.error.whitelabel.enabled=false \ No newline at end of file diff --git a/cs25-batch/src/test/java/com/example/cs25batch/batch/service/TodayQuizServiceTest.java b/cs25-batch/src/test/java/com/example/cs25batch/batch/service/TodayQuizServiceTest.java index 523d6ee4..81787055 100644 --- a/cs25-batch/src/test/java/com/example/cs25batch/batch/service/TodayQuizServiceTest.java +++ b/cs25-batch/src/test/java/com/example/cs25batch/batch/service/TodayQuizServiceTest.java @@ -1,7 +1,7 @@ package com.example.cs25batch.batch.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -12,7 +12,6 @@ import com.example.cs25entity.domain.quiz.repository.QuizRepository; import com.example.cs25entity.domain.subscription.entity.DayOfWeek; import com.example.cs25entity.domain.subscription.entity.Subscription; -import com.example.cs25entity.domain.subscription.repository.SubscriptionRepository; import com.example.cs25entity.domain.userQuizAnswer.entity.UserQuizAnswer; import com.example.cs25entity.domain.userQuizAnswer.repository.UserQuizAnswerRepository; import java.util.ArrayList; @@ -34,9 +33,6 @@ class TodayQuizServiceTest { @InjectMocks private TodayQuizService quizService; - @Mock - private SubscriptionRepository subscriptionRepository; - @Mock private UserQuizAnswerRepository userQuizAnswerRepository; @@ -95,15 +91,9 @@ void getTodayQuiz_success() { Set solvedQuizIds = Set.of(1L, 2L); - List availableQuizzes = List.of( - createQuiz(3L, QuizFormatType.SUBJECTIVE, QuizLevel.HARD, - subCategories.get(0)), - createQuiz(4L, QuizFormatType.SUBJECTIVE, QuizLevel.EASY, - subCategories.get(1)), + Quiz availableQuiz = createQuiz(5L, QuizFormatType.SUBJECTIVE, QuizLevel.NORMAL, - subCategories.get(2)), - createQuiz(6L, QuizFormatType.SUBJECTIVE, QuizLevel.EASY, subCategories.get(3)) - ); + subCategories.get(2)); //given(subscriptionRepository.findByIdOrElseThrow(subscriptionId)).willReturn( // subscription); @@ -117,8 +107,9 @@ void getTodayQuiz_success() { eq(1L), eq(List.of(QuizLevel.EASY, QuizLevel.NORMAL, QuizLevel.HARD)), eq(solvedQuizIds), - anyList() - )).willReturn(availableQuizzes); + any(QuizFormatType.class), + any(int.class) + )).willReturn(availableQuiz); //when Quiz todayQuiz = quizService.getTodayQuizBySubscription(subscription); diff --git a/cs25-entity/src/main/java/com/example/cs25entity/domain/mail/repository/MailLogRepository.java b/cs25-entity/src/main/java/com/example/cs25entity/domain/mail/repository/MailLogRepository.java index 298bfdda..a9184764 100644 --- a/cs25-entity/src/main/java/com/example/cs25entity/domain/mail/repository/MailLogRepository.java +++ b/cs25-entity/src/main/java/com/example/cs25entity/domain/mail/repository/MailLogRepository.java @@ -5,6 +5,7 @@ import com.example.cs25entity.domain.mail.exception.MailExceptionCode; import java.util.Collection; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -21,4 +22,6 @@ default MailLog findByIdOrElseThrow(Long id) { } void deleteAllByIdIn(Collection ids); + + Set findDistinctQuiz_IdBySubscription_Id(Long subscriptionId); } diff --git a/cs25-entity/src/main/java/com/example/cs25entity/domain/quiz/repository/QuizCustomRepository.java b/cs25-entity/src/main/java/com/example/cs25entity/domain/quiz/repository/QuizCustomRepository.java index 4d35af9a..99fc939b 100644 --- a/cs25-entity/src/main/java/com/example/cs25entity/domain/quiz/repository/QuizCustomRepository.java +++ b/cs25-entity/src/main/java/com/example/cs25entity/domain/quiz/repository/QuizCustomRepository.java @@ -8,9 +8,10 @@ public interface QuizCustomRepository { - List findAvailableQuizzesUnderParentCategory(Long parentCategoryId, + Quiz findAvailableQuizzesUnderParentCategory(Long parentCategoryId, List difficulties, Set solvedQuizIds, - List targetTypes); + QuizFormatType targetType, + int offset); } diff --git a/cs25-entity/src/main/java/com/example/cs25entity/domain/quiz/repository/QuizCustomRepositoryImpl.java b/cs25-entity/src/main/java/com/example/cs25entity/domain/quiz/repository/QuizCustomRepositoryImpl.java index 29e70bc9..aa353ea6 100644 --- a/cs25-entity/src/main/java/com/example/cs25entity/domain/quiz/repository/QuizCustomRepositoryImpl.java +++ b/cs25-entity/src/main/java/com/example/cs25entity/domain/quiz/repository/QuizCustomRepositoryImpl.java @@ -17,29 +17,45 @@ public class QuizCustomRepositoryImpl implements QuizCustomRepository { private final JPAQueryFactory queryFactory; @Override - public List findAvailableQuizzesUnderParentCategory(Long parentCategoryId, + public Quiz findAvailableQuizzesUnderParentCategory(Long parentCategoryId, List difficulties, Set solvedQuizIds, - List targetTypes) { + QuizFormatType targetType, + int offset) { + + /* < 사용되는 쿼리문 > + SELECT q.* + FROM quiz q + JOIN quiz_category qc ON q.quiz_category_id = qc.id + WHERE qc.parent_id = ? + AND q.level IN (?, ?, ...) + AND q.type = ? + AND q.quiz_category_id IS NOT NULL + AND q.id NOT IN (?, ?, ...) + ORDER BY q.id ASC + LIMIT 1 OFFSET ? + * */ QQuiz quiz = QQuiz.quiz; QQuizCategory category = QQuizCategory.quizCategory; - // 2. 퀴즈 조회 BooleanBuilder builder = new BooleanBuilder() - .and(quiz.category.parent.id.eq(parentCategoryId)) //내가 정한 카테고리에 - .and(quiz.level.in(difficulties)) //정해진 난이도 그룹안에있으면서 - .and(quiz.type.in(targetTypes)) //퀴즈 타입은 이거야 + .and(quiz.category.parent.id.eq(parentCategoryId)) + .and(quiz.level.in(difficulties)) + .and(quiz.type.eq(targetType)) .and(quiz.category.id.isNotNull()); if (!solvedQuizIds.isEmpty()) { - builder.and(quiz.id.notIn(solvedQuizIds)); //혹시라도 구독자가 문제를 푼 이력잉 ㅣㅆ으면 그것도 제외해야햄 + builder.and(quiz.id.notIn(solvedQuizIds)); } + return queryFactory .selectFrom(quiz) .join(quiz.category, category) .where(builder) - .limit(20) - .fetch(); + .orderBy(quiz.id.asc()) + .offset(offset) + .limit(1) + .fetchOne(); } } diff --git a/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/entity/UserQuizAnswer.java b/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/entity/UserQuizAnswer.java index 33fe3e88..614d6d85 100644 --- a/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/entity/UserQuizAnswer.java +++ b/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/entity/UserQuizAnswer.java @@ -22,7 +22,8 @@ @Entity @Table(name = "userQuizAnswers") @NoArgsConstructor -public class UserQuizAnswer extends BaseEntity { +public class +UserQuizAnswer extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/repository/UserQuizAnswerCustomRepository.java b/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/repository/UserQuizAnswerCustomRepository.java index d4b8a4c7..3f1c833c 100644 --- a/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/repository/UserQuizAnswerCustomRepository.java +++ b/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/repository/UserQuizAnswerCustomRepository.java @@ -2,9 +2,7 @@ import com.example.cs25entity.domain.userQuizAnswer.dto.UserAnswerDto; import com.example.cs25entity.domain.userQuizAnswer.entity.UserQuizAnswer; -import java.time.LocalDate; import java.util.List; -import java.util.Set; public interface UserQuizAnswerCustomRepository { @@ -12,10 +10,7 @@ public interface UserQuizAnswerCustomRepository { List findByUserIdAndQuizCategoryId(Long userId, Long quizCategoryId); - List findBySubscriptionIdAndQuizCategoryId(Long subscriptionId, - Long quizCategoryId); - - Set findRecentSolvedCategoryIds(Long userId, Long parentCategoryId, LocalDate afterDate); + Double getCorrectRate(Long subscriptionId, Long quizCategoryId); UserQuizAnswer findUserQuizAnswerBySerialIds(String quizId, String subscriptionId); } diff --git a/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/repository/UserQuizAnswerCustomRepositoryImpl.java b/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/repository/UserQuizAnswerCustomRepositoryImpl.java index a6396b77..938939a8 100644 --- a/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/repository/UserQuizAnswerCustomRepositoryImpl.java +++ b/cs25-entity/src/main/java/com/example/cs25entity/domain/userQuizAnswer/repository/UserQuizAnswerCustomRepositoryImpl.java @@ -10,11 +10,10 @@ import com.example.cs25entity.domain.userQuizAnswer.exception.UserQuizAnswerException; import com.example.cs25entity.domain.userQuizAnswer.exception.UserQuizAnswerExceptionCode; import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.impl.JPAQueryFactory; -import java.time.LocalDate; -import java.util.HashSet; import java.util.List; -import java.util.Set; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @@ -52,41 +51,48 @@ public List findByUserIdAndQuizCategoryId(Long userId, Long quiz } @Override - public List findBySubscriptionIdAndQuizCategoryId(Long subscriptionId, - Long quizCategoryId) { + public Double getCorrectRate(Long subscriptionId, Long quizCategoryId) { + /* < 들어가는 쿼리 > + * SELECT SUM(CASE WHEN uqa.is_correct = true THEN 1 ELSE 0 END) / COUNT(*) + FROM user_quiz_answer uqa + JOIN quiz q ON uqa.quiz_id = q.id + JOIN quiz_category c ON q.quiz_category_id = c.id + WHERE + uqa.subscription_id = :subscriptionId + AND c.parent_id = :quizCategoryId + * */ + QUserQuizAnswer answer = QUserQuizAnswer.userQuizAnswer; QQuiz quiz = QQuiz.quiz; QQuizCategory category = QQuizCategory.quizCategory; - return queryFactory - .selectFrom(answer) - .join(answer.quiz, quiz) - .join(quiz.category, category) - .where( - answer.subscription.id.eq(subscriptionId), - category.parent.id.eq(quizCategoryId) - ) - .fetch(); - } + // 정답 수 + NumberExpression correctSum = new CaseBuilder() + .when(answer.isCorrect.isTrue()).then(1) + .otherwise(0) + .sum(); - @Override - public Set findRecentSolvedCategoryIds(Long userId, Long parentCategoryId, - LocalDate afterDate) { - QUserQuizAnswer answer = QUserQuizAnswer.userQuizAnswer; - QQuiz quiz = QQuiz.quiz; - QQuizCategory category = QQuizCategory.quizCategory; + // 전체 수 + NumberExpression totalCount = answer.id.count(); + + // 정답률 계산식 + NumberExpression correctRate = new CaseBuilder() + .when(totalCount.eq(0L)).then(100.0) + .otherwise(correctSum.doubleValue().divide(totalCount.doubleValue())); - return new HashSet<>(queryFactory - .select(category.id) + Double result = queryFactory + .select(correctRate) .from(answer) .join(answer.quiz, quiz) .join(quiz.category, category) .where( - answer.user.id.eq(userId), - category.parent.id.eq(parentCategoryId), - answer.createdAt.goe(afterDate.atStartOfDay()) + answer.subscription.id.eq(subscriptionId), + category.parent.id.eq(quizCategoryId) ) - .fetch()); + .fetchOne(); + + // 답변이 없는 경우 기본값 반환 + return result != null ? result : 100.0; } @Override diff --git a/cs25-service/build.gradle b/cs25-service/build.gradle index 6dd63507..f9215094 100644 --- a/cs25-service/build.gradle +++ b/cs25-service/build.gradle @@ -29,6 +29,10 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-test-autoconfigure' + // MCP + implementation "org.springframework.ai:spring-ai-starter-mcp-client:1.0.0" + implementation "org.springframework.ai:spring-ai-starter-mcp-client-webflux:1.0.0" + //JavaMailSender implementation 'jakarta.mail:jakarta.mail-api:2.1.0' diff --git a/cs25-service/src/main/java/com/example/cs25service/common/aop/LoggingAspect.java b/cs25-service/src/main/java/com/example/cs25service/common/aop/LoggingAspect.java new file mode 100644 index 00000000..80da8562 --- /dev/null +++ b/cs25-service/src/main/java/com/example/cs25service/common/aop/LoggingAspect.java @@ -0,0 +1,49 @@ +package com.example.cs25service.common.aop; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Aspect +@Component +public class LoggingAspect { + + private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class); + + @Pointcut("execution(* com.example.cs25service.domain.userQuizAnswer.controller.UserQuizAnswerController.submitAnswer(..))") + public void submitAnswer() {} + + @Pointcut("execution(* com.example.cs25service.domain.userQuizAnswer.controller.UserQuizAnswerController.evaluateAnswer(..))") + public void evaluateAnswer() {} + + @Pointcut("submitAnswer() || evaluateAnswer()") + public void quizAnswerMethods() {} + + @Around("quizAnswerMethods()") + public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { + // 1) 호출 시간 + String time = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + + // 2) 사용자 정보 + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String username = (auth != null ? auth.getName() : "anonymous"); + + // 3) 퀴즈 정보 + Object[] args = joinPoint.getArgs(); + String quizInfo = (args.length > 0 && args[0] != null) ? args[0].toString() : "no-args"; + + log.info("[{}] user = {} quizInfo = {}", time, username, quizInfo); + + return joinPoint.proceed(); + } +} diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java index e58c207f..8ef9e3f8 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java @@ -2,19 +2,20 @@ import com.example.cs25service.domain.ai.exception.AiException; import com.example.cs25service.domain.ai.exception.AiExceptionCode; -import java.util.function.Consumer; -import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; @Component -@RequiredArgsConstructor public class ClaudeChatClient implements AiChatClient { private final ChatClient anthropicChatClient; + public ClaudeChatClient(@Qualifier("anthropicChatClient") ChatClient anthropicChatClient) { + this.anthropicChatClient = anthropicChatClient; + } + @Override public String call(String systemPrompt, String userPrompt) { return anthropicChatClient.prompt() @@ -39,6 +40,5 @@ public Flux stream(String systemPrompt, String userPrompt) { .onErrorResume(error -> { throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); }); - } } diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java index eab391b6..7ca1c63d 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java @@ -2,30 +2,28 @@ import com.example.cs25service.domain.ai.exception.AiException; import com.example.cs25service.domain.ai.exception.AiExceptionCode; -import java.util.function.Consumer; -import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; @Component -@RequiredArgsConstructor public class OpenAiChatClient implements AiChatClient { private final ChatClient openAiChatClient; + public OpenAiChatClient(@Qualifier("openAiChatModelClient") ChatClient openAiChatClient) { + this.openAiChatClient = openAiChatClient; + } + @Override public String call(String systemPrompt, String userPrompt) { - try { - return openAiChatClient.prompt() - .system(systemPrompt) - .user(userPrompt) - .call() - .content() - .trim(); - } catch (Exception e) { - throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); - } + return openAiChatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .call() + .content() + .trim(); } @Override @@ -45,4 +43,3 @@ public Flux stream(String systemPrompt, String userPrompt) { }); } } - diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/config/AiConfig.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/config/AiConfig.java index ee94a8ea..31169db6 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/config/AiConfig.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/config/AiConfig.java @@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; @Configuration public class AiConfig { @@ -17,38 +18,45 @@ public class AiConfig { @Value("${spring.ai.openai.api-key}") private String openAiKey; - @Bean - public ChatClient AichatClient(OpenAiChatModel chatModel) { - return ChatClient.create(chatModel); - } + @Value("${spring.ai.anthropic.api-key}") + private String claudeKey; - @Bean - public OpenAiChatModel openAiChatModel() { + @Bean(name = "openAiChatModelClient") + @Primary + public ChatClient openAiChatClient() { OpenAiApi api = OpenAiApi.builder() .apiKey(openAiKey) .build(); - return OpenAiChatModel.builder() + OpenAiChatModel chatModel = OpenAiChatModel.builder() .openAiApi(api) .build(); + + return ChatClient.builder(chatModel).build(); } - @Bean - public AnthropicChatModel anthropicChatModel(@Value("${spring.ai.anthropic.api-key}") String claudeKey) { + @Bean(name = "anthropicChatClient") + public ChatClient anthropicChatClient() { AnthropicApi api = AnthropicApi.builder() .apiKey(claudeKey) .build(); - return AnthropicChatModel.builder() + AnthropicChatModel chatModel = AnthropicChatModel.builder() .anthropicApi(api) .build(); + + return ChatClient.builder(chatModel).build(); } + /** + * EmbeddingModel for OpenAI + */ @Bean public EmbeddingModel embeddingModel() { OpenAiApi openAiApi = OpenAiApi.builder() .apiKey(openAiKey) .build(); + return new OpenAiEmbeddingModel(openAiApi); } -} \ No newline at end of file +} diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/controller/AiController.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/controller/AiController.java index 28618c96..25d253d0 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/controller/AiController.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/controller/AiController.java @@ -4,7 +4,6 @@ import com.example.cs25entity.domain.quiz.entity.Quiz; import com.example.cs25service.domain.ai.service.AiFeedbackQueueService; import com.example.cs25service.domain.ai.service.AiQuestionGeneratorService; -import com.example.cs25service.domain.ai.service.AiService; import com.example.cs25service.domain.ai.service.FileLoaderService; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; @@ -19,7 +18,6 @@ @RequiredArgsConstructor public class AiController { - private final AiService aiService; private final AiQuestionGeneratorService aiQuestionGeneratorService; private final FileLoaderService fileLoaderService; private final AiFeedbackQueueService aiFeedbackQueueService; diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/prompt/AiPromptProvider.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/prompt/AiPromptProvider.java index 96abc021..3eda6e1c 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/prompt/AiPromptProvider.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/prompt/AiPromptProvider.java @@ -3,17 +3,23 @@ import com.example.cs25entity.domain.quiz.entity.Quiz; import com.example.cs25entity.domain.userQuizAnswer.entity.UserQuizAnswer; import com.example.cs25service.domain.ai.config.AiPromptProperties; +import com.example.cs25service.domain.ai.service.BraveSearchRagService; +import com.fasterxml.jackson.databind.JsonNode; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class AiPromptProvider { private final AiPromptProperties props; + private final BraveSearchRagService braveSearchRagService; // === [Keyword] === public String getKeywordSystem() { @@ -29,15 +35,40 @@ public String getFeedbackSystem() { return props.getFeedback().getSystem(); } - public String getFeedbackUser(Quiz quiz, UserQuizAnswer answer, List docs) { + public String getFeedbackUser(Quiz quiz, UserQuizAnswer answer, List docs, + Optional braveResults) { String context = docs.stream() .map(doc -> "- 문서: " + doc.getText()) .collect(Collectors.joining("\n")); - return props.getFeedback().getUser() + String searchResults = braveResults + .map(this::formatBraveResults) + .orElse(""); + + String userPrompt = props.getFeedback().getUser() .replace("{context}", context) .replace("{question}", quiz.getQuestion()) - .replace("{userAnswer}", answer.getUserAnswer()); + .replace("{userAnswer}", answer.getUserAnswer()) + .replace("{searchResults}", searchResults); + + log.info("[AI User Prompt]\n{}", userPrompt); // 🔍 여기에 추가 + return userPrompt; + + } + + private String formatBraveResults(JsonNode root) { + JsonNode resultsNode = root.get("results"); + if (resultsNode == null || !resultsNode.isArray()) { + return ""; + } + + List docs = braveSearchRagService.toDocuments(Optional.of(root)); + + return "[브레이브 검색 결과]\n" + + docs.stream() + .map(doc -> "- " + doc.getMetadata().get("title") + ": " + doc.getMetadata() + .get("url")) + .collect(Collectors.joining("\n")); } // === [Generation] === diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackQueueService.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackQueueService.java index 57fbb1a3..2e7e6432 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackQueueService.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackQueueService.java @@ -2,6 +2,8 @@ import com.example.cs25service.domain.ai.config.RedisStreamConfig; import com.example.cs25service.domain.ai.queue.EmitterRegistry; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -25,12 +27,22 @@ public void enqueue(Long answerId, SseEmitter emitter) { // Redis Set을 통한 중복 처리 방지 Long added = redisTemplate.opsForSet() .add(DEDUPLICATION_SET_KEY, String.valueOf(answerId)); + if (added == null || added == 0) { log.info("Duplicate enqueue prevented for answerId {}", answerId); completeWithError(emitter, new IllegalStateException("이미 처리중인 요청입니다.")); return; } - + // 하루 TTL 부여 + if (redisTemplate.opsForSet().size(DEDUPLICATION_SET_KEY) == 1) { + Duration ttl = getDurationUntilMidnight(); + Boolean ttlSet = redisTemplate.expire(DEDUPLICATION_SET_KEY, ttl); + if (Boolean.FALSE.equals(ttlSet)) { + log.warn("중복 방지 Set의 TTL 설정에 실패했습니다."); + } else { + log.info("중복 방지 Set TTL이 자정까지 {}초로 설정되었습니다.", ttl.toSeconds()); + } + } emitterRegistry.register(answerId, emitter); Map message = new HashMap<>(); @@ -56,4 +68,10 @@ private void completeWithError(SseEmitter emitter, Exception e) { } emitter.completeWithError(e); } + + private Duration getDurationUntilMidnight() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime midnight = now.toLocalDate().plusDays(1).atStartOfDay(); + return Duration.between(now, midnight); + } } diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackStreamProcessor.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackStreamProcessor.java index 4028f312..d46196bb 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackStreamProcessor.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackStreamProcessor.java @@ -7,9 +7,14 @@ import com.example.cs25service.domain.ai.exception.AiException; import com.example.cs25service.domain.ai.exception.AiExceptionCode; import com.example.cs25service.domain.ai.prompt.AiPromptProvider; +import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.document.Document; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -26,6 +31,8 @@ public class AiFeedbackStreamProcessor { private final UserRepository userRepository; private final AiChatClient aiChatClient; private final TransactionTemplate transactionTemplate; + private final BraveSearchRagService braveSearchRagService; + private final BraveSearchMcpService braveSearchMcpService; @Transactional public void stream(Long answerId, SseEmitter emitter) { @@ -39,8 +46,23 @@ public void stream(Long answerId, SseEmitter emitter) { } var quiz = answer.getQuiz(); - var docs = ragService.searchRelevant(quiz.getQuestion(), 3, 0.3); - String userPrompt = promptProvider.getFeedbackUser(quiz, answer, docs); + var vectorDocs = ragService.searchRelevant(quiz.getQuestion(), 2, 0.5); + Optional braveResults = Optional.empty(); + List webDocs = new ArrayList<>(); + try { + JsonNode searchResult = braveSearchMcpService.search(quiz.getQuestion(), 2, 0); + braveResults = Optional.ofNullable(searchResult); + webDocs = braveSearchRagService.toDocuments(braveResults); + log.debug(" Brave 검색 결과 문서 {}개를 성공적으로 가져왔습니다.", webDocs.size()); + } catch (Exception e) { + log.warn("⚠ Brave 검색 실패 - 질문: [{}], 벡터 검색만 사용합니다.", quiz.getQuestion(), e); + } + + List docs = new ArrayList<>(); + docs.addAll(vectorDocs); + docs.addAll(webDocs); + + String userPrompt = promptProvider.getFeedbackUser(quiz, answer, docs, braveResults); String systemPrompt = promptProvider.getFeedbackSystem(); User user = answer.getUser(); @@ -69,12 +91,17 @@ public void stream(Long answerId, SseEmitter emitter) { send(emitter, "[종료]"); String feedback = fullFeedbackBuffer.toString(); - boolean isCorrect = feedback.startsWith("정답"); + if (feedback == null || feedback.isEmpty()) { + throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); + } + + boolean isCorrect = isCorrect(feedback); transactionTemplate.executeWithoutResult(status -> { if (user != null && userScore != null) { double score = isCorrect - ? userScore + (quiz.getType().getScore() * quiz.getLevel().getExp()) + ? userScore + (quiz.getType().getScore() * quiz.getLevel() + .getExp()) : userScore + 1; user.updateScore(score); userRepository.save(user); @@ -97,6 +124,21 @@ public void stream(Long answerId, SseEmitter emitter) { } } + public boolean isCorrect(String feedback){ + String prefix = feedback.length() > 6 + ? feedback.substring(0, 6) + : feedback; + + int indexCorrect = prefix.indexOf("정답"); + int indexWrong = prefix.indexOf("오답"); + + if (indexCorrect != -1 && (indexWrong == -1 || indexCorrect < indexWrong)) { + return true; + } + + return false; + } + private void send(SseEmitter emitter, String data) { try { emitter.send(SseEmitter.event().data(data)); diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackStreamWorker.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackStreamWorker.java index 57d5c418..88e8ac4d 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackStreamWorker.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiFeedbackStreamWorker.java @@ -6,10 +6,13 @@ import jakarta.annotation.PreDestroy; import java.time.Duration; import java.util.List; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.connection.stream.Consumer; @@ -27,25 +30,97 @@ public class AiFeedbackStreamWorker { private static final String GROUP_NAME = RedisStreamConfig.GROUP_NAME; - private static final int WORKER_COUNT = 16; + private static final int CORE_WORKER = 2; // 최소 워커 수 + private static final int MAX_WORKER = 16; // 최대 워커 수 + private static final int SCALING_CHECK_INTERVAL = 5; // 워커 상태 체크 주기 (초) private final AiFeedbackStreamProcessor processor; private final RedisTemplate redisTemplate; private final EmitterRegistry emitterRegistry; - private final ExecutorService executor = Executors.newFixedThreadPool(WORKER_COUNT); + private final ThreadPoolExecutor executor = new ThreadPoolExecutor( + CORE_WORKER, + MAX_WORKER, + 60, TimeUnit.SECONDS, // 60초간 작업 없으면 스레드 종료 가능 + new LinkedBlockingQueue<>() + ); + + private final ScheduledExecutorService scalingExecutor = Executors.newSingleThreadScheduledExecutor(); private final AtomicBoolean running = new AtomicBoolean(true); + private final AtomicInteger consumerCounter = new AtomicInteger(0); @PostConstruct public void start() { - for (int i = 0; i < WORKER_COUNT; i++) { - final String consumerName = "consumer-" + i; - executor.submit(() -> poll(consumerName)); + // core 스레드도 idle 상태에서 timeout 허용 + executor.allowCoreThreadTimeOut(true); + + // 초기 워커 실행 + for (int i = 0; i < CORE_WORKER; i++) { + int index = consumerCounter.getAndIncrement(); + final String consumerName = "consumer-" + index; + executor.submit(() -> poll(consumerName, index)); + } + + // 스케일링 워커를 별도 스케줄러에서 실행 + scalingExecutor.scheduleWithFixedDelay(this::autoScaleWorkers, 0, SCALING_CHECK_INTERVAL, + TimeUnit.SECONDS); + } + + private void autoScaleWorkers() { + if (!running.get()) { + return; + } + try { + long queueSize = redisTemplate.opsForStream().size(RedisStreamConfig.STREAM_KEY); + + synchronized (this) { + int currentThreads = executor.getCorePoolSize(); + int targetThreads = calculateTargetWorkerCount(queueSize); + + if (targetThreads > currentThreads) { + // 워커 확장 + log.info("워커 수 확장: {}개 -> {}개 (큐 크기: {})", currentThreads, targetThreads, + queueSize); + executor.setCorePoolSize(targetThreads); + for (int i = currentThreads; i < targetThreads; i++) { + int index = consumerCounter.getAndIncrement(); + final String consumerName = "consumer-" + index; + executor.submit(() -> poll(consumerName, index)); + } + } else if (targetThreads < currentThreads) { + // 워커 축소 (setCorePoolSize 감소) + log.info("워커 수 축소: {}개 -> {}개 (큐 크기: {})", currentThreads, targetThreads, + queueSize); + executor.setCorePoolSize(targetThreads); + } + } + } catch (Exception e) { + log.error("워커 자동 스케일링 중 오류 발생", e); + } + } + + /** + * 큐 크기에 따른 목표 워커 수 계산 + */ + private int calculateTargetWorkerCount(long queueSize) { + if (queueSize > 1000) { + return 16; + } else if (queueSize > 500) { + return 8; + } else if (queueSize > 100) { + return 4; + } else { + return CORE_WORKER; } } - private void poll(String consumerName) { + private void poll(String consumerName, int workerIndex) { while (running.get()) { + int currentTarget = executor.getCorePoolSize(); + if (workerIndex >= currentTarget) { + log.info("워커 {} 종료: currentTarget = {}", consumerName, currentTarget); + break; + } try { List> messages = redisTemplate.opsForStream() .read(Consumer.from(GROUP_NAME, consumerName), @@ -59,7 +134,7 @@ private void poll(String consumerName) { SseEmitter emitter = emitterRegistry.get(answerId); if (emitter == null) { - log.warn("No emitter found for answerId: {}", answerId); + log.warn("해당 answerId={}에 대한 emitter가 없습니다.", answerId); redisTemplate.opsForStream() .acknowledge(RedisStreamConfig.STREAM_KEY, GROUP_NAME, message.getId()); @@ -68,16 +143,14 @@ private void poll(String consumerName) { processor.stream(answerId, emitter); emitterRegistry.remove(answerId); - redisTemplate.opsForSet() .remove(AiFeedbackQueueService.DEDUPLICATION_SET_KEY, answerId); - redisTemplate.opsForStream() .acknowledge(RedisStreamConfig.STREAM_KEY, GROUP_NAME, message.getId()); } } } catch (Exception e) { - log.error("Redis Stream consumer {} error", consumerName, e); + log.error("Redis Stream consumer {}에서 오류 발생", consumerName, e); } } } @@ -86,12 +159,17 @@ private void poll(String consumerName) { public void stop() { running.set(false); executor.shutdown(); + scalingExecutor.shutdown(); try { if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { executor.shutdownNow(); } + if (!scalingExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + scalingExecutor.shutdownNow(); + } } catch (InterruptedException e) { executor.shutdownNow(); + scalingExecutor.shutdownNow(); Thread.currentThread().interrupt(); } } diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiQuestionGeneratorService.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiQuestionGeneratorService.java index 4fbf7cf6..3f3d1ba5 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiQuestionGeneratorService.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiQuestionGeneratorService.java @@ -30,10 +30,10 @@ public class AiQuestionGeneratorService { public Quiz generateQuestionFromContext() { // 1. LLM으로부터 CS 키워드 동적 생성 String keyword = Objects.requireNonNull(chatClient.prompt() - .system(promptProvider.getKeywordSystem()) - .user(promptProvider.getKeywordUser()) - .call() - .content()) + .system(promptProvider.getKeywordSystem()) + .user(promptProvider.getKeywordUser()) + .call() + .content()) .trim(); if (!StringUtils.hasText(keyword)) { @@ -56,24 +56,26 @@ public Quiz generateQuestionFromContext() { // 3. 중심 토픽 추출 String topic = Objects.requireNonNull(chatClient.prompt() - .system(promptProvider.getTopicSystem()) - .user(promptProvider.getTopicUser(context)) - .call() - .content()) + .system(promptProvider.getTopicSystem()) + .user(promptProvider.getTopicUser(context)) + .call() + .content()) .trim(); // 4. 카테고리 분류 (BACKEND / FRONTEND) String categoryType = Objects.requireNonNull(chatClient.prompt() - .system(promptProvider.getCategorySystem()) - .user(promptProvider.getCategoryUser(topic)) - .call() - .content()) + .system(promptProvider.getCategorySystem()) + .user(promptProvider.getCategoryUser(topic)) + .call() + .content()) .trim() .toUpperCase(); - if (!categoryType.equalsIgnoreCase("SoftwareDevelopment") && !categoryType.equalsIgnoreCase("SoftwareDesign") - && !categoryType.equalsIgnoreCase("Programming") && !categoryType.equalsIgnoreCase("Database") - && !categoryType.equalsIgnoreCase("InformationSystemManagement") ) { + if (!categoryType.equalsIgnoreCase("SoftwareDevelopment") && !categoryType.equalsIgnoreCase( + "SoftwareDesign") + && !categoryType.equalsIgnoreCase("Programming") && !categoryType.equalsIgnoreCase( + "Database") + && !categoryType.equalsIgnoreCase("InformationSystemManagement")) { throw new IllegalArgumentException("AI가 반환한 카테고리가 유효하지 않습니다: " + categoryType); } @@ -81,10 +83,10 @@ public Quiz generateQuestionFromContext() { // 5. 문제 생성 (문제, 정답, 해설) String output = Objects.requireNonNull(chatClient.prompt() - .system(promptProvider.getGenerateSystem()) - .user(promptProvider.getGenerateUser(context)) - .call() - .content()) + .system(promptProvider.getGenerateSystem()) + .user(promptProvider.getGenerateUser(context)) + .call() + .content()) .trim(); String[] lines = output.split("\n"); diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiService.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiService.java deleted file mode 100644 index 272eb568..00000000 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/AiService.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.example.cs25service.domain.ai.service; - -import com.example.cs25entity.domain.quiz.repository.QuizRepository; -import com.example.cs25entity.domain.subscription.repository.SubscriptionRepository; -import com.example.cs25entity.domain.user.entity.User; -import com.example.cs25entity.domain.user.repository.UserRepository; -import com.example.cs25entity.domain.userQuizAnswer.repository.UserQuizAnswerRepository; -import com.example.cs25service.domain.ai.client.AiChatClient; -import com.example.cs25service.domain.ai.dto.response.AiFeedbackResponse; -import com.example.cs25service.domain.ai.exception.AiException; -import com.example.cs25service.domain.ai.exception.AiExceptionCode; -import com.example.cs25service.domain.ai.prompt.AiPromptProvider; -import lombok.RequiredArgsConstructor; -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Service; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -@Service -@RequiredArgsConstructor -public class AiService { - - private final ChatClient chatClient; - - @Qualifier("fallbackAiChatClient") - private final AiChatClient aiChatClient; - - private final AiFeedbackQueueService feedbackQueueService; - private final QuizRepository quizRepository; - private final SubscriptionRepository subscriptionRepository; - private final UserQuizAnswerRepository userQuizAnswerRepository; - private final RagService ragService; - private final AiPromptProvider promptProvider; - private final UserRepository userRepository; - - public AiFeedbackResponse getFeedback(Long answerId) { - var answer = userQuizAnswerRepository.findWithQuizAndUserByIdOrElseThrow(answerId); - - var quiz = answer.getQuiz(); - var docs = ragService.searchRelevant(quiz.getQuestion(), 3, 0.3); - - String userPrompt = promptProvider.getFeedbackUser(quiz, answer, docs); - String systemPrompt = promptProvider.getFeedbackSystem(); - - String feedback = aiChatClient.call(systemPrompt, userPrompt); - - boolean isCorrect = feedback.startsWith("정답"); - - User user = answer.getUser(); - if (user != null) { - double score = - isCorrect ? user.getScore() + (quiz.getType().getScore() * quiz.getLevel().getExp()) - : user.getScore() + 1; - user.updateScore(score); - } - - answer.updateIsCorrect(isCorrect); - answer.updateAiFeedback(feedback); - userQuizAnswerRepository.save(answer); - - return AiFeedbackResponse.builder() - .quizId(quiz.getId()) - .quizAnswerId(answer.getId()) - .isCorrect(isCorrect) - .aiFeedback(feedback) - .build(); - } - - public SseEmitter streamFeedback(Long answerId, String mode) { - SseEmitter emitter = new SseEmitter(60_000L); - emitter.onTimeout(emitter::complete); - emitter.onError(emitter::completeWithError); - - feedbackQueueService.enqueue(answerId, emitter); - return emitter; - } -} diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchMcpService.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchMcpService.java new file mode 100644 index 00000000..ae595d61 --- /dev/null +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchMcpService.java @@ -0,0 +1,62 @@ +package com.example.cs25service.domain.ai.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BraveSearchMcpService { + + private static final String BRAVE_WEB_TOOL = "brave_web_search"; + + private final List mcpClients; + + private final ObjectMapper objectMapper; + + public JsonNode search(String query, int count, int offset) { + McpSyncClient braveClient = resolveBraveClient(); + + CallToolRequest request = new CallToolRequest( + BRAVE_WEB_TOOL, + Map.of("query", query, "count", count, "offset", offset) + ); + + CallToolResult result = braveClient.callTool(request); + + JsonNode content = objectMapper.valueToTree(result.content()); + log.info("[Brave MCP Response Raw content]: {}", content.toPrettyString()); + + if (content != null && content.isArray()) { + var root = objectMapper.createObjectNode(); + root.set("results", content); + return root; + } + + return content != null ? content : objectMapper.createObjectNode(); + } + + private McpSyncClient resolveBraveClient() { + for (McpSyncClient client : mcpClients) { + ListToolsResult tools = client.listTools(); + if (tools != null && tools.tools() != null) { + boolean found = tools.tools().stream() + .anyMatch(tool -> BRAVE_WEB_TOOL.equalsIgnoreCase(tool.name())); + if (found) { + return client; + } + } + } + + throw new IllegalStateException("Brave MCP 서버에서 brave_web_search 툴을 찾을 수 없습니다."); + } +} diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchRagService.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchRagService.java new file mode 100644 index 00000000..5a468cdb --- /dev/null +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchRagService.java @@ -0,0 +1,66 @@ +package com.example.cs25service.domain.ai.service; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.document.Document; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BraveSearchRagService { + + public List toDocuments(Optional resultsNodeOpt) { + List documents = new ArrayList<>(); + + resultsNodeOpt.ifPresent(resultsNode -> { + resultsNode.path("results").forEach(result -> { + String text = result.path("text").asText(""); + if (text.isBlank()) { + return; + } + + // 여러 문서가 한 개의 텍스트에 포함되어 있으므로 줄 단위로 분리 + String[] lines = text.split("\\n"); + + String title = null; + String url = null; + StringBuilder contentBuilder = new StringBuilder(); + + for (String line : lines) { + if (line.startsWith("Title:")) { + if (title != null && url != null && contentBuilder.length() > 0) { + // 이전 문서를 저장 + documents.add(new Document( + title, + contentBuilder.toString().trim(), + Map.of("title", title, "url", url) + )); + contentBuilder.setLength(0); + } + title = line.replaceFirst("Title:", "").trim(); + } else if (line.startsWith("URL:")) { + url = line.replaceFirst("URL:", "").trim(); + } else { + contentBuilder.append(line).append("\n"); + } + } + + // 마지막 문서 저장 + if (title != null && url != null && contentBuilder.length() > 0) { + documents.add(new Document( + title, + contentBuilder.toString().trim(), + Map.of("title", title, "url", url) + )); + } + }); + }); + + return documents; + } + +} diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/quiz/service/QuizAccuracyCalculateService.java b/cs25-service/src/main/java/com/example/cs25service/domain/quiz/service/QuizAccuracyCalculateService.java index 4384be98..f1aa0cc0 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/quiz/service/QuizAccuracyCalculateService.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/quiz/service/QuizAccuracyCalculateService.java @@ -1,5 +1,6 @@ package com.example.cs25service.domain.quiz.service; +import com.example.cs25entity.domain.mail.repository.MailLogRepository; import com.example.cs25entity.domain.quiz.entity.Quiz; import com.example.cs25entity.domain.quiz.entity.QuizAccuracy; import com.example.cs25entity.domain.quiz.enums.QuizFormatType; @@ -9,11 +10,10 @@ import com.example.cs25entity.domain.quiz.repository.QuizAccuracyRedisRepository; import com.example.cs25entity.domain.quiz.repository.QuizRepository; import com.example.cs25entity.domain.subscription.entity.Subscription; -import com.example.cs25entity.domain.subscription.repository.SubscriptionRepository; import com.example.cs25entity.domain.userQuizAnswer.entity.UserQuizAnswer; import com.example.cs25entity.domain.userQuizAnswer.repository.UserQuizAnswerRepository; +import java.time.LocalDate; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Set; import lombok.RequiredArgsConstructor; @@ -29,64 +29,52 @@ public class QuizAccuracyCalculateService { private final QuizRepository quizRepository; private final QuizAccuracyRedisRepository quizAccuracyRedisRepository; private final UserQuizAnswerRepository userQuizAnswerRepository; - private final SubscriptionRepository subscriptionRepository; + private final MailLogRepository mailLogRepository; @Transactional - public Quiz getTodayQuizBySubscription(Long subscriptionId) { - Subscription subscription = subscriptionRepository.findByIdOrElseThrow(subscriptionId); - + public Quiz getTodayQuizBySubscription(Subscription subscription) { // 1. 구독자 정보 및 카테고리 조회 Long parentCategoryId = subscription.getCategory().getId(); // 대분류 ID + Long subscriptionId = subscription.getId(); // 2. 유저 정답률 계산, 내가 푼 문제 아이디값 - List answerHistory = userQuizAnswerRepository.findBySubscriptionIdAndQuizCategoryId( - subscriptionId, parentCategoryId); - int quizCount = answerHistory.size(); // 사용자가 지금까지 푼 문제 수 - int totalCorrect = 0; - Set solvedQuizIds = new HashSet<>(); - - for (UserQuizAnswer answer : answerHistory) { - if (answer.getIsCorrect()) { - totalCorrect++; - } - solvedQuizIds.add(answer.getQuiz().getId()); - } + Double accuracyResult = userQuizAnswerRepository.getCorrectRate(subscriptionId, + parentCategoryId); + double accuracy = accuracyResult != null ? accuracyResult : 100.0; + + Set sentQuizIds = mailLogRepository.findDistinctQuiz_IdBySubscription_Id(subscriptionId); + int quizCount = sentQuizIds.size(); // 사용자가 지금까지 푼 문제 수 - double accuracy = - quizCount == 0 ? 100.0 : ((double) totalCorrect / quizCount) * 100.0; // 6. 서술형 주기 판단 (풀이 횟수 기반) - boolean isEssayDay = quizCount % 3 == 2; //일단 3배수일때 한번씩은 서술( 조정 필요하면 나중에 하는거롤) + boolean isEssayDay = quizCount % 4 == 3; //일단 3배수일때 한번씩은 서술(0,1,2 객관식 / 3서술형) - List targetTypes = isEssayDay - ? List.of(QuizFormatType.SUBJECTIVE) - : List.of(QuizFormatType.MULTIPLE_CHOICE); + QuizFormatType targetType = isEssayDay + ? QuizFormatType.SUBJECTIVE + : QuizFormatType.MULTIPLE_CHOICE; // 3. 정답률 기반 난이도 바운더리 설정 List allowedDifficulties = getAllowedDifficulties(accuracy); - System.out.println("Solved IDs: " + solvedQuizIds); + // 8. 오프셋 계산 (풀이 수 기준) + long seed = LocalDate.now().toEpochDay() + subscriptionId; + int offset = (int) (seed % 20); // 7. 필터링 조건으로 문제 조회(대분류, 난이도, 내가푼문제 제외, 제외할 카테고리 제외하고, 문제 타입 전부 조건으로) - List candidateQuizzes = quizRepository.findAvailableQuizzesUnderParentCategory( + + Quiz todayQuiz = quizRepository.findAvailableQuizzesUnderParentCategory( parentCategoryId, allowedDifficulties, - solvedQuizIds, + sentQuizIds, //excludedCategoryIds, - targetTypes - ); //한개만뽑기(find first) - - System.out.println("Candidate count: " + candidateQuizzes.size()); - for (Quiz q : candidateQuizzes) { - System.out.println("Quiz ID: " + q.getId() + ", Content: " + q.getQuestion()); - } + targetType, + offset + ); - if (candidateQuizzes.isEmpty()) { // 뽀ㅃ을문제없을때 - throw new QuizException(QuizExceptionCode.NO_QUIZ_EXISTS_ERROR); + if (todayQuiz == null) { + throw new QuizException(QuizExceptionCode.QUIZ_VALIDATION_FAILED_ERROR); } - // 8. 오프셋 계산 (풀이 수 기준) - long offset = quizCount % candidateQuizzes.size(); - return candidateQuizzes.get((int) offset); + return todayQuiz; } @@ -102,19 +90,6 @@ private List getAllowedDifficulties(double accuracy) { } } - // private double calculateAccuracy(List answers) { -// if (answers.isEmpty()) { -// return 100.0; -// } -// -// int totalCorrect = 0; -// for (UserQuizAnswer answer : answers) { -// if (answer.getIsCorrect()) { -// totalCorrect++; -// } -// } -// return ((double) totalCorrect / answers.size()) * 100.0; -// } public void calculateAndCacheAllQuizAccuracies() { List quizzes = quizRepository.findAll(); diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/security/common/XssRequestWrapper.java b/cs25-service/src/main/java/com/example/cs25service/domain/security/common/XssRequestWrapper.java index 8f439d89..eba6d09b 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/security/common/XssRequestWrapper.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/security/common/XssRequestWrapper.java @@ -15,10 +15,14 @@ import java.util.Arrays; import java.util.stream.Collectors; import org.apache.commons.text.StringEscapeUtils; +import lombok.extern.slf4j.Slf4j; +@Slf4j public class XssRequestWrapper extends HttpServletRequestWrapper { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final String sanitizedJsonBody; + private static final int MAX_DEPTH = 30; public XssRequestWrapper(HttpServletRequest request) throws IOException { super(request); @@ -90,20 +94,27 @@ public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(getInputStream())); } - // 🔽 JSON 필드 값만 escape하는 메서드 + // JSON 필드 값만 escape하는 메서드 private String sanitizeJsonBody(String rawBody) { try { - ObjectMapper mapper = new ObjectMapper(); - JsonNode root = mapper.readTree(rawBody); + JsonNode root = OBJECT_MAPPER.readTree(rawBody); sanitizeJsonNode(root); - return mapper.writeValueAsString(root); + return OBJECT_MAPPER.writeValueAsString(root); } catch (Exception e) { // 문제가 생기면 원본 반환 (fallback) + log.error("Failed to sanitize JSON body", e); return rawBody; } } private void sanitizeJsonNode(JsonNode node) { + sanitizeJsonNode(node, 0); + } + + private void sanitizeJsonNode(JsonNode node, int depth) { + if (depth > MAX_DEPTH) { + throw new IllegalArgumentException("JSON 깊이가 30이상입니다. DoS 공격이 의심됩니다."); + } if (node.isObject()) { ObjectNode objNode = (ObjectNode) node; objNode.fieldNames().forEachRemaining(field -> { @@ -112,12 +123,12 @@ private void sanitizeJsonNode(JsonNode node) { String sanitized = StringEscapeUtils.escapeHtml4(child.asText()); objNode.put(field, sanitized); } else { - sanitizeJsonNode(child); + sanitizeJsonNode(child, depth + 1); } }); } else if (node.isArray()) { for (JsonNode item : node) { - sanitizeJsonNode(item); + sanitizeJsonNode(item, depth + 1); } } } diff --git a/cs25-service/src/main/resources/application.properties b/cs25-service/src/main/resources/application.properties index b7452c74..7fa42814 100644 --- a/cs25-service/src/main/resources/application.properties +++ b/cs25-service/src/main/resources/application.properties @@ -59,6 +59,19 @@ spring.ai.openai.chat.options.temperature=0.7 # Claude spring.ai.anthropic.api-key=${CLAUDE_API_KEY} spring.ai.anthropic.chat.options.model=claude-3-opus-20240229 +# FALLBACK +spring.ai.model.chat=openai,anthropic +spring.ai.chat.client.enabled=false +# MCP +spring.ai.mcp.client.enabled=true +spring.ai.mcp.client.type=SYNC +spring.ai.mcp.client.request-timeout=30s +spring.ai.mcp.client.root-change-notification=false +# STDIO Connect: Brave Search +spring.ai.mcp.client.stdio.connections.brave.command=npx +spring.ai.mcp.client.stdio.connections.brave.args[0]=-y +spring.ai.mcp.client.stdio.connections.brave.args[1]=@modelcontextprotocol/server-brave-search +spring.ai.mcp.client.stdio.connections.brave.env.BRAVE_API_KEY=${BRAVE_API_KEY} #MAIL spring.mail.host=smtp.gmail.com spring.mail.port=587 diff --git a/cs25-service/src/main/resources/prompts/prompt.yaml b/cs25-service/src/main/resources/prompts/prompt.yaml index 24b9b296..6af5df53 100644 --- a/cs25-service/src/main/resources/prompts/prompt.yaml +++ b/cs25-service/src/main/resources/prompts/prompt.yaml @@ -6,12 +6,15 @@ ai: 다른 단어나 표현은 사용하지 말고, 반드시 '정답' 또는 '오답'으로 시작해. 그리고 사용자 답변에 대한 피드백도 반드시 작성해. user: > - 당신은 CS 문제 채점 전문가입니다. 아래 문서를 참고하여 사용자의 답변이 문제의 요구사항에 부합하는지 판단하세요. - 문서가 충분하지 않거나 관련 정보가 없는 경우, 당신이 알고 있는 CS 지식으로 보완해서 판단해도 됩니다. + 당신은 CS 문제 채점 전문가입니다. 아래 문서와 검색 결과를 참고하여 사용자의 답변이 문제의 요구사항에 부합하는지 판단하세요. + 문서나 검색 결과가 충분하지 않거나 관련 정보가 없는 경우, 당신이 알고 있는 CS 지식으로 보완해서 판단해도 됩니다. 문서: {context} + Brave 검색 결과: + {searchResults} + 문제: {question} 사용자 답변: {userAnswer} @@ -40,9 +43,9 @@ ai: - Programming - Database - InformationSystemManagement - + 주제: {topic} - + 결과는 위 다섯 개 중 하나만 출력하세요. generate-system: > 너는 문서 기반으로 문제를 출제하는 전문가야. 정확히 문제/정답/해설 세 부분을 출력해. diff --git a/cs25-service/src/test/java/com/example/cs25service/ai/AiServiceTest.java b/cs25-service/src/test/java/com/example/cs25service/ai/AiServiceTest.java index a15ca40a..bcb820c2 100644 --- a/cs25-service/src/test/java/com/example/cs25service/ai/AiServiceTest.java +++ b/cs25-service/src/test/java/com/example/cs25service/ai/AiServiceTest.java @@ -9,27 +9,33 @@ import com.example.cs25entity.domain.quiz.repository.QuizRepository; import com.example.cs25entity.domain.subscription.entity.Subscription; import com.example.cs25entity.domain.subscription.repository.SubscriptionRepository; +import com.example.cs25entity.domain.user.repository.UserRepository; import com.example.cs25entity.domain.userQuizAnswer.entity.UserQuizAnswer; import com.example.cs25entity.domain.userQuizAnswer.repository.UserQuizAnswerRepository; +import com.example.cs25service.domain.ai.client.AiChatClient; import com.example.cs25service.domain.ai.dto.response.AiFeedbackResponse; +import com.example.cs25service.domain.ai.prompt.AiPromptProvider; +import com.example.cs25service.domain.ai.service.AiFeedbackQueueService; import com.example.cs25service.domain.ai.service.AiFeedbackStreamWorker; import com.example.cs25service.domain.ai.service.AiService; +import com.example.cs25service.domain.ai.service.RagService; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import java.time.LocalDate; import org.junit.jupiter.api.*; +import org.springframework.ai.chat.client.ChatClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; import org.springframework.transaction.annotation.Transactional; +import static org.mockito.Mockito.mock; @SpringBootTest @Transactional @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) // 스프링 컨텍스트 리프레시 @Disabled public class AiServiceTest { - @Autowired private AiService aiService; @@ -140,6 +146,23 @@ void testGetFeedbackForGuest() { System.out.println("[비회원 구독] AI 피드백:\n" + response.getAiFeedback()); } + @Test + @DisplayName("6글자 이내에 정답이 포함된 경우 true 반환") + void testIfAiFeedbackIsCorrectThenReturnTrue(){ + assertThat(aiService.isCorrect("- 정답 : 당신의 답은 완벽합니다.")).isTrue(); + assertThat(aiService.isCorrect("정답 : 당신의 답은 완벽합니다.")).isTrue(); + assertThat(aiService.isCorrect("정답입니다. 당신의 답은 완벽합니다.")).isTrue(); + } + + @Test + @DisplayName("오답인 경우 false 반환") + void testIfAiFeedbackIsWrongThenReturnfalse(){ + assertThat(aiService.isCorrect("- 오답 : 당신의 답은 완벽합니다.")).isFalse(); + assertThat(aiService.isCorrect("오답 : 당신의 답은 완벽합니다.")).isFalse(); + assertThat(aiService.isCorrect("오답입니다. 당신의 답은 완벽합니다.")).isFalse(); + assertThat(aiService.isCorrect("오답: 정답이라고 하기에는 부족합니다.")).isFalse(); + } + @AfterEach void tearDown() { aiFeedbackStreamWorker.stop();