From 0564ccabb9a8a2badb0845196753431f05036bec Mon Sep 17 00:00:00 2001 From: chjpooh2002 Date: Tue, 10 Nov 2026 20:36:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=ED=86=A4=20=EA=B5=90=EC=A0=95=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84=20(=EC=84=A0?= =?UTF-8?q?=EC=A0=90=C2=B7=EC=96=B8=EC=96=B4=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=C2=B7=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=C2=B7=ED=95=B5=EC=8B=AC=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EA=B2=80=EC=82=AC)=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AiToneCorrectionCommandService.java | 110 +++++++++ .../dto/command/AiToneCorrectCommand.java | 16 ++ .../dto/result/AiToneCorrectResult.java | 22 ++ .../AiToneCorrectionCommandServiceTest.java | 224 ++++++++++++++++++ 4 files changed, 372 insertions(+) create mode 100644 src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java create mode 100644 src/main/java/com/sniptext/domain/ai/service/dto/command/AiToneCorrectCommand.java create mode 100644 src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneCorrectResult.java create mode 100644 src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java diff --git a/src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java b/src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java new file mode 100644 index 0000000..da16935 --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java @@ -0,0 +1,110 @@ +package com.sniptext.domain.ai.service; + +import org.springframework.stereotype.Service; + +import com.sniptext.domain.ai.exception.AiErrorCode; +import com.sniptext.domain.ai.service.dto.command.AiToneCorrectCommand; +import com.sniptext.domain.ai.service.dto.result.AiToneCorrectResult; +import com.sniptext.domain.ai.service.dto.result.AiToneReserveResult; +import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult; +import com.sniptext.domain.ai.support.KeyInfoChecker; +import com.sniptext.domain.user.service.UserQueryService; +import com.sniptext.global.client.llm.LanguageModelClient; +import com.sniptext.global.client.llm.LanguageModelException; +import com.sniptext.global.client.llm.LanguageModelRequest; +import com.sniptext.global.client.llm.LanguageModelResult; +import com.sniptext.global.client.llm.LanguageModelTimeoutException; +import com.sniptext.global.exception.BusinessException; +import com.sniptext.global.policy.UsageLimits; +import com.sniptext.global.text.TextWhitespace; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * AI 톤 교정 (명세 22번). 1회를 먼저 잡고 언어 모델을 부른 뒤, 차단·외부 오류·시간 초과면 되돌린다. + *

+ * 클래스·메서드 모두 트랜잭션을 걸지 않는다 ("명령 서비스는 쓰기 메서드마다 @Transactional" 규칙의 예외). + * 외부 호출(최대 20초) 동안 DB 커넥션을 쥐지 않도록, 선점·반환이 각자 짧은 트랜잭션으로 끝난다 + * (AiToneUsageCommandService는 호출자 트랜잭션 안에서 부르면 예외를 던진다). + *

+ * 원문과 결과는 저장하지도 로그에 남기지도 않는다. 로그에는 회원 번호·문체·결과 종류·소요 시간·출력 토큰 수만 남긴다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiToneCorrectionCommandService { + + /** 출력 토큰 상한 (비용 상한). 생각 토큰도 포함되며, 3000자 원문 보고/공문 교정이 4000에서는 잘렸다 (#64 확인) */ + static final int MAX_OUTPUT_TOKENS = 6000; + + private final UserQueryService userQueryService; + private final AiToneUsageCommandService aiToneUsageCommandService; + private final LanguageModelClient languageModelClient; + + public AiToneCorrectResult correct(AiToneCorrectCommand command) { + userQueryService.validateActive(command.userId()); + AiToneReserveResult reservation = aiToneUsageCommandService.reserve(command.userId()); + + LanguageModelResult modelResult = complete(command, reservation); + + String correctedText = TextWhitespace.strip(modelResult.text()); + return new AiToneCorrectResult(correctedText, command.style(), + KeyInfoChecker.compare(command.text(), correctedText), usage(reservation)); + } + + /** 언어 모델을 부른다. 차단·외부 오류·시간 초과면 선점한 1회를 되돌리고 에러를 던진다. 반환은 이 세 경우에서만 한다. */ + private LanguageModelResult complete(AiToneCorrectCommand command, AiToneReserveResult reservation) { + LanguageModelRequest request = new LanguageModelRequest(ToneCorrectionPrompt.systemPrompt(command.style()), + ToneCorrectionPrompt.userMessage(command.text()), MAX_OUTPUT_TOKENS); + long startedAt = System.nanoTime(); + LanguageModelResult result; + try { + result = languageModelClient.complete(request); + } catch (LanguageModelTimeoutException e) { + logFailure(command, startedAt, e); + throw releaseAndFail(reservation, AiErrorCode.LANGUAGE_MODEL_TIMEOUT); + } catch (LanguageModelException e) { + logFailure(command, startedAt, e); + throw releaseAndFail(reservation, AiErrorCode.LANGUAGE_MODEL_FAILED); + } + + boolean blocked = result.refused() || ToneCorrectionPrompt.isBlocked(result.text()); + log.info("AI 톤 교정 userId={} style={} outcome={} elapsedMs={} outputTokens={}", command.userId(), + command.style(), blocked ? "blocked" : "success", elapsedMillis(startedAt), result.outputTokens()); + if (blocked) { + throw releaseAndFail(reservation, AiErrorCode.INAPPROPRIATE_TEXT); + } + return result; + } + + /** 반환이 실패해도 원래 에러를 그대로 돌려준다. 로그에는 회원 번호·날짜와 예외 종류만 남긴다. */ + private BusinessException releaseAndFail(AiToneReserveResult reservation, AiErrorCode errorCode) { + try { + aiToneUsageCommandService.release(reservation); + } catch (RuntimeException e) { + log.error("AI 톤 교정 사용량 반환 실패 userId={} usageDate={} exception={}", reservation.userId(), + reservation.usageDate(), e.getClass().getSimpleName()); + } + return new BusinessException(errorCode); + } + + // 언어 모델 예외 메시지는 상태 코드·오류 종류만 담는다 (원문·응답 본문 없음) + private static void logFailure(AiToneCorrectCommand command, long startedAt, RuntimeException e) { + log.warn("AI 톤 교정 실패 userId={} style={} elapsedMs={} reason={}", command.userId(), command.style(), + elapsedMillis(startedAt), e.getMessage()); + } + + /** 다시 조회하지 않고 선점 결과로 만든다. 리셋 시각은 선점한 날짜의 다음 날 00:00(KST)이다. */ + private static AiToneUsageResult usage(AiToneReserveResult reservation) { + int limit = UsageLimits.AI_TONE_DAILY_LIMIT; + int usedCount = reservation.usedCountAfter(); + return new AiToneUsageResult(usedCount, limit, Math.max(limit - usedCount, 0), + reservation.usageDate().plusDays(1).atStartOfDay()); + } + + private static long elapsedMillis(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000; + } + +} diff --git a/src/main/java/com/sniptext/domain/ai/service/dto/command/AiToneCorrectCommand.java b/src/main/java/com/sniptext/domain/ai/service/dto/command/AiToneCorrectCommand.java new file mode 100644 index 0000000..d45c369 --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/service/dto/command/AiToneCorrectCommand.java @@ -0,0 +1,16 @@ +package com.sniptext.domain.ai.service.dto.command; + +import com.sniptext.domain.ai.entity.ToneStyle; + +/** + * AI 톤 교정 입력. 원문은 요청 단계에서 형식 검증을 마친 그대로다. + * 원문은 저장·로그 금지 대상이라 toString에는 길이만 보인다. + */ +public record AiToneCorrectCommand(Long userId, ToneStyle style, String text) { + + @Override + public String toString() { + return "AiToneCorrectCommand[userId=%d, style=%s, textLength=%d]".formatted(userId, style, text.length()); + } + +} diff --git a/src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneCorrectResult.java b/src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneCorrectResult.java new file mode 100644 index 0000000..39c64bc --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneCorrectResult.java @@ -0,0 +1,22 @@ +package com.sniptext.domain.ai.service.dto.result; + +import com.sniptext.domain.ai.entity.ToneStyle; +import com.sniptext.domain.ai.support.KeyInfoDiff; + +/** + * AI 톤 교정 결과. 교정 결과는 저장·로그 금지 대상이라 toString에는 길이만 보인다. + * + * @param correctedText 앞뒤 공백을 지운 교정 결과 + * @param keyInfoDiff 원문과 결과의 핵심 정보 차이 (차이가 없으면 두 목록이 빈 값) + * @param usage 선점 후 사용량 + */ +public record AiToneCorrectResult(String correctedText, ToneStyle style, KeyInfoDiff keyInfoDiff, + AiToneUsageResult usage) { + + @Override + public String toString() { + return "AiToneCorrectResult[correctedTextLength=%d, style=%s, keyInfoDiff=%s, usage=%s]".formatted( + correctedText.length(), style, keyInfoDiff, usage); + } + +} diff --git a/src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java b/src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java new file mode 100644 index 0000000..40a29e3 --- /dev/null +++ b/src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java @@ -0,0 +1,224 @@ +package com.sniptext.domain.ai.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.sniptext.domain.ai.entity.ToneStyle; +import com.sniptext.domain.ai.exception.AiErrorCode; +import com.sniptext.domain.ai.service.dto.command.AiToneCorrectCommand; +import com.sniptext.domain.ai.service.dto.result.AiToneCorrectResult; +import com.sniptext.domain.ai.service.dto.result.AiToneReserveResult; +import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult; +import com.sniptext.domain.user.exception.UserErrorCode; +import com.sniptext.domain.user.service.UserQueryService; +import com.sniptext.global.client.llm.LanguageModelClient; +import com.sniptext.global.client.llm.LanguageModelException; +import com.sniptext.global.client.llm.LanguageModelRequest; +import com.sniptext.global.client.llm.LanguageModelResult; +import com.sniptext.global.client.llm.LanguageModelTimeoutException; +import com.sniptext.global.exception.BusinessException; + +@ExtendWith(MockitoExtension.class) +class AiToneCorrectionCommandServiceTest { + + private static final Long USER_ID = 7L; + private static final LocalDate USAGE_DATE = LocalDate.of(2026, 10, 11); + private static final String TEXT = "10월 14일까지 300만 원으로 진행 가능하다고 해줘"; + private static final AiToneCorrectCommand COMMAND = new AiToneCorrectCommand(USER_ID, ToneStyle.BOSS_EXTERNAL, TEXT); + private static final AiToneReserveResult RESERVATION = new AiToneReserveResult(USER_ID, USAGE_DATE, 2); + + @Mock + private UserQueryService userQueryService; + + @Mock + private AiToneUsageCommandService aiToneUsageCommandService; + + @Mock + private LanguageModelClient languageModelClient; + + @InjectMocks + private AiToneCorrectionCommandService service; + + // 회원 확인에서 끝나는 테스트는 선점까지 가지 않으므로 lenient + @BeforeEach + void setUp() { + lenient().when(aiToneUsageCommandService.reserve(USER_ID)).thenReturn(RESERVATION); + } + + @Test + @DisplayName("성공하면 앞뒤 공백을 지운 결과와 선점 결과로 만든 사용량을 돌려주고, 반환하지 않는다") + void success() { + givenModelReturns(" \n안녕하세요. 10월 14일까지 300만 원으로 진행 가능합니다.\n감사합니다. \n", false); + + AiToneCorrectResult result = service.correct(COMMAND); + + assertThat(result.correctedText()).isEqualTo("안녕하세요. 10월 14일까지 300만 원으로 진행 가능합니다.\n감사합니다."); + assertThat(result.style()).isEqualTo(ToneStyle.BOSS_EXTERNAL); + assertThat(result.keyInfoDiff().hasDifference()).isFalse(); + assertThat(result.usage()).isEqualTo(new AiToneUsageResult(2, 5, 3, LocalDateTime.of(2026, 10, 12, 0, 0))); + verify(aiToneUsageCommandService, never()).release(any()); + } + + @Test + @DisplayName("언어 모델에 문체 지시문, 로 감싼 원문, 출력 토큰 상한 6000을 보낸다") + void request() { + givenModelReturns("교정 결과", false); + + service.correct(COMMAND); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LanguageModelRequest.class); + verify(languageModelClient).complete(captor.capture()); + LanguageModelRequest request = captor.getValue(); + assertThat(request.systemPrompt()).isEqualTo(ToneCorrectionPrompt.systemPrompt(ToneStyle.BOSS_EXTERNAL)); + assertThat(request.userText()).isEqualTo(ToneCorrectionPrompt.userMessage(TEXT)); + assertThat(request.maxOutputTokens()).isEqualTo(6000); + } + + @Test + @DisplayName("날짜·금액이 빠지거나 바뀌어도 정상 결과이고, 차이를 담으며 반환하지 않는다") + void keyInfoDifference() { + givenModelReturns("안녕하세요. 10월 15일까지 진행 가능합니다.", false); + + AiToneCorrectResult result = service.correct(COMMAND); + + assertThat(result.keyInfoDiff().missingValues()).containsExactly("10월 14일", "300만 원"); + assertThat(result.keyInfoDiff().addedValues()).containsExactly("10월 15일"); + verify(aiToneUsageCommandService, never()).release(any()); + } + + @Test + @DisplayName("없는 회원이면 USER404_1이고 선점·언어 모델 호출을 하지 않는다") + void missingUser() { + willThrow(new BusinessException(UserErrorCode.USER_NOT_FOUND)).given(userQueryService).validateActive(USER_ID); + + assertErrorCode(UserErrorCode.USER_NOT_FOUND.getCode()); + + verify(aiToneUsageCommandService, never()).reserve(any()); + verifyNoInteractions(languageModelClient); + } + + @Test + @DisplayName("남은 횟수가 없으면 AI429_1이고 언어 모델을 부르지 않으며 반환하지 않는다") + void dailyLimitExceeded() { + given(aiToneUsageCommandService.reserve(USER_ID)) + .willThrow(new BusinessException(AiErrorCode.DAILY_LIMIT_EXCEEDED)); + + assertErrorCode("AI429_1"); + + verifyNoInteractions(languageModelClient); + verify(aiToneUsageCommandService, never()).release(any()); + } + + @Test + @DisplayName("모델이 거절(refused)하면 AI422_1이고 선점한 1회를 반환한다") + void refused() { + givenModelReturns("", true); + + assertErrorCode("AI422_1"); + + verify(aiToneUsageCommandService).release(RESERVATION); + } + + @ParameterizedTest + @ValueSource(strings = {"[[SNIPTEXT_BLOCKED]]", " \n[[SNIPTEXT_BLOCKED]]\n ", "[[SNIPTEXT_BLOCKED]] "}) + @DisplayName("표식만 출력하면(앞뒤 공백 무시) AI422_1이고 선점한 1회를 반환한다") + void blockedMarker(String modelText) { + givenModelReturns(modelText, false); + + assertErrorCode("AI422_1"); + + verify(aiToneUsageCommandService).release(RESERVATION); + } + + @Test + @DisplayName("외부 오류면 AI502_1이고 선점한 1회를 반환한다") + void languageModelFailed() { + given(languageModelClient.complete(any())).willThrow(new LanguageModelException("언어 모델 HTTP 오류: 529")); + + assertErrorCode("AI502_1"); + + verify(aiToneUsageCommandService).release(RESERVATION); + } + + @Test + @DisplayName("응답 시간 초과면 AI504_1이고 선점한 1회를 반환한다") + void languageModelTimeout() { + given(languageModelClient.complete(any())).willThrow(new LanguageModelTimeoutException("언어 모델 응답 시간 초과")); + + assertErrorCode("AI504_1"); + + verify(aiToneUsageCommandService).release(RESERVATION); + } + + @Test + @DisplayName("반환이 실패해도 원래 에러 코드를 그대로 던진다") + void releaseFails() { + given(languageModelClient.complete(any())).willThrow(new LanguageModelTimeoutException("언어 모델 응답 시간 초과")); + willThrow(new IllegalStateException("DB 오류")).given(aiToneUsageCommandService).release(RESERVATION); + + assertErrorCode("AI504_1"); + } + + @Test + @DisplayName("그 밖의 예상 못 한 예외는 그대로 올라가고 반환하지 않는다") + void unexpectedException() { + given(languageModelClient.complete(any())).willThrow(new IllegalStateException("예상 못 한 오류")); + + assertThatThrownBy(() -> service.correct(COMMAND)).isInstanceOf(IllegalStateException.class); + + verify(aiToneUsageCommandService, never()).release(any()); + } + + @Test + @DisplayName("명령 객체의 toString에는 원문 대신 길이만 나온다") + void commandToStringHidesText() { + assertThat(COMMAND.toString()).doesNotContain(TEXT).contains("textLength=" + TEXT.length()); + } + + @Test + @DisplayName("결과 객체의 toString에는 교정 결과와 핵심 정보 값이 나오지 않는다") + void resultToStringHidesText() { + givenModelReturns("안녕하세요. 10월 15일까지 진행 가능합니다.", false); + + String described = service.correct(COMMAND).toString(); + + assertThat(described).doesNotContain("안녕하세요", "10월 14일", "10월 15일", "300만 원"); + for (String expected : List.of("correctedTextLength=", "missing=2", "added=1")) { + assertThat(described).contains(expected); + } + } + + private void givenModelReturns(String text, boolean refused) { + given(languageModelClient.complete(any())).willReturn(new LanguageModelResult(text, refused, 100)); + } + + private void assertErrorCode(String code) { + assertThatThrownBy(() -> service.correct(COMMAND)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException)e).getResultCode().getCode()) + .isEqualTo(code); + } + +}