Repository navigation
feat: AI 톤 교정 준비 - 출력 토큰 수 · 회원 존재 확인 · 에러 코드 · 요청 검증 (#65 1/3) #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
| 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)가 되므로, 문자열로 받아 이것으로 검사한다. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
적절한 것 같아여 |
||
| */ | ||
| @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 {}; | ||
|
|
||
| } | ||
| 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 {}; | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
좋은 것 같아요 ! |
||
| } | ||
| 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(); | ||
| } | ||
|
|
||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
원문 길이를 정규화 없이 세는 건 본문에 적어 주셔서 확인했는데, 화면 톤 교정 입력창도 같은 기준으로 세는지만 프론트랑 맞춰 보면 좋을 것 같아요 !
There was a problem hiding this comment.
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로 세면 같은 기준이라 어긋나지 않을 것 같은데, 프론트에 한번 확인해 보겠습니다!