Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ domain/{도메인}/
| AI 오늘 사용량 | `AiToneUsageResult getTodayUsage(Long userId)` (`AiToneUsageQueryService`) | ai → user |
| 가입 시 회원 생성 | `Long signUp(UserSignUpCommand)` | user → auth |
| 회원 행 잠금 | `void lockUser(Long userId)` (`UserCommandService`, 호출자 트랜잭션 필수) | user → template |
| 회원 존재 확인 | `void validateActive(Long userId)` (`UserQueryService`, 없거나 탈퇴면 `USER404_1`) | user → ai |

- **다른 도메인 서비스를 조합하는 조회는 별도 서비스로 분리한다.** 예: 사용량 API는 `UserUsageQueryService`
- 순환 참조를 막기 위해서다. 사용량 조합(template·ai 서비스 호출)을 `UserQueryService`에 넣으면, template·ai가 `UserQueryService`를 부르게 될 때 생성자 주입 순환으로 앱이 뜨지 않는다
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.sniptext.domain.ai.annotation;

import java.util.Arrays;

import com.sniptext.domain.ai.entity.ToneStyle;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

/** ToneStyle 상수 이름 중 하나와 대소문자까지 같을 때만 통과한다. */
public class ToneStyleConstraintValidator implements ConstraintValidator<ValidToneStyle, String> {

@Override
public boolean isValid(String style, ConstraintValidatorContext context) {
return style != null && Arrays.stream(ToneStyle.values()).anyMatch(each -> each.name().equals(style));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.sniptext.domain.ai.annotation;

import com.sniptext.domain.ai.exception.AiErrorCode;
import com.sniptext.global.text.TextWhitespace;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

/** 비었으면 AI400_2, 길면 AI400_3을 메시지로 남긴다. */
public class ToneTextConstraintValidator implements ConstraintValidator<ValidToneText, String> {

/** 교정할 원문 최대 길이 (명세 22번, 템플릿 본문 한도와 같다) */
static final int MAX_LENGTH = 3000;

@Override
public boolean isValid(String text, ConstraintValidatorContext context) {
if (text == null || TextWhitespace.isBlank(text)) {
return reject(context, AiErrorCode.TEXT_EMPTY);
}
if (text.length() > MAX_LENGTH) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

원문 길이를 정규화 없이 세는 건 본문에 적어 주셔서 확인했는데, 화면 톤 교정 입력창도 같은 기준으로 세는지만 프론트랑 맞춰 보면 좋을 것 같아요 !

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버는 String.length() 기준이라 이모지는 2자, 줄바꿈은 1자로 셉니다. 프론트에서 입력창 최대 길이 속성이나 value.length로 세면 같은 기준이라 어긋나지 않을 것 같은데, 프론트에 한번 확인해 보겠습니다!

return reject(context, AiErrorCode.TEXT_TOO_LONG);
}
return true;
}

private static boolean reject(ConstraintValidatorContext context, AiErrorCode errorCode) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(errorCode.getCode()).addConstraintViolation();
return false;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.sniptext.domain.ai.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

/**
* 요청 객체의 문체 검증. null·빈 값·ToneStyle 상수 이름과 정확히 같지 않은 값(소문자 포함)이면 AI400_1.
* 요청 필드를 ToneStyle로 받으면 없는 값이 JSON 변환 실패(COMMON400_1)가 되므로, 문자열로 받아 이것으로 검사한다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ValidToneStyle을 문자열로 받는 판단

적절한 것 같아여ToneStyle로 바로 받으면 없는 값이 JSON 변환 실패로 COMMON400_1이 돼서, 명세의 "style 누락 또는 값 오류 → AI400_1"을 못 맞추는 걸로 이해했습니당 대소문자까지 정확히 비교하는 것도 명세 값과 맞습니다아

*/
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ToneStyleConstraintValidator.class)
public @interface ValidToneStyle {

String message() default "AI400_1";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};

}
29 changes: 29 additions & 0 deletions src/main/java/com/sniptext/domain/ai/annotation/ValidToneText.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.sniptext.domain.ai.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

/**
* 요청 객체의 교정할 원문 검증. null·빈 값·공백만(TextWhitespace 기준, NBSP·BOM 포함)이면 AI400_2,
* String.length()가 3000을 넘으면 AI400_3. 정규화하지 않고 받은 그대로 센다 (NFC 규칙은 템플릿 이름·본문 대상).
* @NotBlank·@Size를 같이 붙이지 않는다 (공백 기준이 화면과 달라진다).
*/
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ToneTextConstraintValidator.class)
public @interface ValidToneText {

String message() default "AI400_2";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};

}
14 changes: 13 additions & 1 deletion src/main/java/com/sniptext/domain/ai/exception/AiErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,20 @@
@RequiredArgsConstructor
public enum AiErrorCode implements ResultCode {

/** style 누락 또는 없는 값 */
STYLE_INVALID(HttpStatus.BAD_REQUEST, "AI400_1", "문체를 선택해주세요."),
/** text가 비었거나 공백만 입력 */
TEXT_EMPTY(HttpStatus.BAD_REQUEST, "AI400_2", "교정할 문장을 입력해주세요."),
/** text 3000자 초과 */
TEXT_TOO_LONG(HttpStatus.BAD_REQUEST, "AI400_3", "문장은 3,000자까지 입력할 수 있어요."),
/** 혐오·차별 표현 또는 욕설·비속어 감지. 횟수 미차감 */
INAPPROPRIATE_TEXT(HttpStatus.UNPROCESSABLE_CONTENT, "AI422_1", "부적절한 표현이 있어 교정할 수 없어요. 수정 후 다시 시도해 주세요."),
/** 하루 한도 소진. 자정(KST)에 리셋 */
DAILY_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI429_1", "오늘 톤 교정 횟수를 모두 사용했어요.");
DAILY_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI429_1", "오늘 톤 교정 횟수를 모두 사용했어요."),
/** 외부 AI 오류. 횟수 미차감 */
LANGUAGE_MODEL_FAILED(HttpStatus.BAD_GATEWAY, "AI502_1", "일시적인 오류로 요청을 처리하지 못했어요. 다시 한 번 물어봐 주시겠어요?"),
/** 외부 AI 20초 타임아웃. 횟수 미차감 */
LANGUAGE_MODEL_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "AI504_1", "일시적인 오류로 요청을 처리하지 못했어요. 다시 한 번 물어봐 주시겠어요?");

private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.sniptext.domain.user.exception.UserErrorCode;
import com.sniptext.domain.user.repository.UserRepository;
import com.sniptext.global.exception.BusinessException;

import lombok.RequiredArgsConstructor;

Expand All @@ -15,4 +17,14 @@ public class UserQueryService {

private final UserRepository userRepository;

/**
* 탈퇴하지 않은 회원인지 확인한다. 없거나 탈퇴했으면 USER404_1.
* 회원 행을 외래 키로 쓰는 저장(예: AI 사용량 선점) 전에 불러, 없는 회원이 외래 키 위반(500)으로 끝나지 않게 한다.
*/
public void validateActive(Long userId) {
if (userRepository.findByIdAndDeletedAtIsNull(userId).isEmpty()) {
throw new BusinessException(UserErrorCode.USER_NOT_FOUND);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ private static LanguageModelResult toResult(MessagesResponse response) {
throw new LanguageModelException("언어 모델 응답 없음");
}
String stopReason = response.stopReason();
int outputTokens = outputTokens(response);
if (REFUSAL.equals(stopReason)) {
return new LanguageModelResult(joinText(response), true);
return new LanguageModelResult(joinText(response), true, outputTokens);
}
if (MAX_TOKENS.equals(stopReason)) {
// 잘린 결과는 내보내지 않는다
Expand All @@ -105,7 +106,14 @@ private static LanguageModelResult toResult(MessagesResponse response) {
if (TextWhitespace.isBlank(text)) {
throw new LanguageModelException("언어 모델 응답이 빈 텍스트");
}
return new LanguageModelResult(text, false);
return new LanguageModelResult(text, false, outputTokens);
}

private static int outputTokens(MessagesResponse response) {
if (response.usage() == null || response.usage().outputTokens() == null) {
return 0;
}
return response.usage().outputTokens();
}

private static List<ContentBlock> textBlocks(MessagesResponse response) {
Expand All @@ -130,7 +138,12 @@ record Message(String role, String content) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record MessagesResponse(List<ContentBlock> content, @JsonProperty("stop_reason") String stopReason) {
record MessagesResponse(List<ContentBlock> content, @JsonProperty("stop_reason") String stopReason, Usage usage) {
}

// 잭슨 3은 원시 타입에 값이 없으면 실패할 수 있어, 없을 수 있는 값은 Integer로 받는다
@JsonIgnoreProperties(ignoreUnknown = true)
record Usage(@JsonProperty("output_tokens") Integer outputTokens) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

/**
* 로컬·테스트용 가짜 언어 모델. 외부를 부르지 않고 사용자 메시지의 원문에 표시만 붙여 돌려준다.
* 사용자 메시지가 &lt;text&gt;...&lt;/text&gt;로 감싸져 있으면 태그와 그 안쪽 앞뒤 줄바꿈을 벗긴 원문을 쓴다.
* 사용자 메시지가 &lt;text&gt;...&lt;/text&gt;로 감싸져 있으면 태그와 그 안쪽 앞뒤 줄바꿈을 벗긴 원문을 쓴다. 출력 토큰 수는 0이다.
* 원문을 로그에 남기지 않는다.
*/
public class FakeLanguageModelClient implements LanguageModelClient {
Expand All @@ -17,7 +17,7 @@ public class FakeLanguageModelClient implements LanguageModelClient {

@Override
public LanguageModelResult complete(LanguageModelRequest request) {
return new LanguageModelResult(PREFIX + unwrap(request.userText()), false);
return new LanguageModelResult(PREFIX + unwrap(request.userText()), false, 0);
}

private static String unwrap(String userText) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
/**
* 언어 모델 응답. 결과 문장이 들어 있으므로 로그에 남기지 않는다.
*
* @param text 모델이 만든 텍스트
* @param refused 모델이 답변을 거절했는지 (stop_reason이 refusal)
* @param text 모델이 만든 텍스트
* @param refused 모델이 답변을 거절했는지 (stop_reason이 refusal)
* @param outputTokens 출력 토큰 수 (생각 토큰 포함). 응답에 없으면 0
*/
public record LanguageModelResult(String text, boolean refused) {
public record LanguageModelResult(String text, boolean refused, int outputTokens) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LanguageModelResult에 필드를 추가한 global 변경

좋은 것 같아요 ! global이 도메인을 참조하지 않고, usage나 output_tokens가 없어도 0으로 받아서 응답 처리가 깨지지 않는 것 확인했습니다 👍

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.sniptext.domain.ai.annotation;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

import com.sniptext.domain.ai.entity.ToneStyle;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;

class ValidToneStyleTest {

private static ValidatorFactory validatorFactory;
private static Validator validator;

record Request(@ValidToneStyle String style) {
}

@BeforeAll
static void setUp() {
validatorFactory = Validation.buildDefaultValidatorFactory();
validator = validatorFactory.getValidator();
}

@AfterAll
static void tearDown() {
validatorFactory.close();
}

@ParameterizedTest
@EnumSource(ToneStyle.class)
@DisplayName("문체 상수 이름이면 통과한다")
void valid(ToneStyle style) {
assertThat(messagesOf(style.name())).isEmpty();
}

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "ESSAY", "boss_external", "Casual", " CASUAL", "CASUAL ", "UNKNOWN"})
@DisplayName("없거나, 비었거나, 없는 값(삭제한 ESSAY 포함)이거나, 대소문자·공백이 다르면 AI400_1")
void invalid(String style) {
assertThat(messagesOf(style)).containsExactly("AI400_1");
}

private static List<String> messagesOf(String style) {
return validator.validate(new Request(style)).stream()
.map(ConstraintViolation::getMessage)
.toList();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.sniptext.domain.ai.annotation;

import static org.assertj.core.api.Assertions.assertThat;

import java.text.Normalizer;
import java.util.List;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;

class ValidToneTextTest {

private static ValidatorFactory validatorFactory;
private static Validator validator;

record Request(@ValidToneText String text) {
}

@BeforeAll
static void setUp() {
validatorFactory = Validation.buildDefaultValidatorFactory();
validator = validatorFactory.getValidator();
}

@AfterAll
static void tearDown() {
validatorFactory.close();
}

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\r\n\t", " ", "", " ", "   \n"})
@DisplayName("없거나 비었거나 공백만(NBSP·BOM·전각 공백 포함) 있으면 AI400_2")
void empty(String text) {
assertThat(messagesOf(text)).containsExactly("AI400_2");
}

@Test
@DisplayName("3000자는 통과하고 3001자는 AI400_3")
void lengthBoundary() {
assertThat(messagesOf("가".repeat(3000))).isEmpty();
assertThat(messagesOf("가".repeat(3001))).containsExactly("AI400_3");
}

@Test
@DisplayName("정규화하지 않고 받은 그대로 센다 (자모 분리형 1500음절은 3000자라 통과, 1501음절은 AI400_3)")
void countsAsReceived() {
assertThat(messagesOf(Normalizer.normalize("가".repeat(1500), Normalizer.Form.NFD))).isEmpty();
assertThat(messagesOf(Normalizer.normalize("가".repeat(1501), Normalizer.Form.NFD))).containsExactly("AI400_3");
}

@Test
@DisplayName("앞뒤 공백이 있어도 내용이 있으면 통과한다")
void valid() {
assertThat(messagesOf(" 이번 달은 외주 못 받는다고 해줘 ")).isEmpty();
}

private static List<String> messagesOf(String text) {
return validator.validate(new Request(text)).stream()
.map(ConstraintViolation::getMessage)
.toList();
}

}
Loading
Loading