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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ domain/{도메인}/
**AI 톤 교정**
- 모든 사용자 하루 5회 (KST 날짜별 1행)
- 요청 시 `used_count < 한도` 조건부 증가로 1회를 먼저 잡고, 외부 AI 오류·20초 타임아웃·혐오 표현·욕설 차단이면 되돌린다.
- 출력 토큰 상한 4000을 반드시 설정한다.
- 출력 토큰 상한 6000을 반드시 설정한다 (생각 토큰 포함. 3000자 원문 보고/공문 교정이 4000에서는 잘렸다).
- **원문과 결과는 저장하지도, 로그에 남기지도 않는다.** 예외 로그에도 넣지 않는다.

**인증**
Expand Down
8 changes: 5 additions & 3 deletions docs/s1-api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
| --- | --- |
| 버전 | v0.8 (2026-11-01, PRO 결제 기능 제거·정렬 고정 반영) |
| v0.7 대비 변경 | 플랜 구분 삭제. 템플릿 한도는 모든 사용자 50개, AI 톤 교정은 모든 사용자 하루 5회 (기능명세서 COM-03·05). 6번 사용량 응답에서 `plan`·`aiTone.available` 삭제, 22번 `AI403_1` 삭제, 공통 규칙 3·6·7·9장 정리. **내 템플릿 정렬을 생성 순(오래된 것 먼저)으로 고정**(기능명세서 MYT-03 삭제): 7번 정렬 저장 API 삭제, 5번 `templateSort`·8번 `sort` 파라미터와 응답 필드·`TEMPLATE400_8` 삭제. API는 21개 (번호는 그대로 두고 7번을 비움) |
| v0.8 수정 (2026-11-13) | 22번 비고: 서버 내부 오류(500)도 횟수를 차감하지 않음 (선점한 1회를 되돌림, PR #86 리뷰) |
| v0.8 수정 (2026-11-10) | 22번 비고: 출력 토큰 상한 4,000 → 6,000 (#64 실제 모델 확인에서 3000자 원문 보고/공문 교정이 4,000에 잘림), 처리 중 이탈은 감지하지 않아 차감됨으로 변경 |
| v0.8 수정 (2026-11-10) | AI 톤 교정: 감성/에세이 문체(`ESSAY`) 삭제, 문체 6개 (공통 규칙 6장, 22번 `style`). 욕설·비속어도 혐오 표현과 같이 차단하고 22번 `AI422_1` 문구·설명 변경 |
| v0.6 → v0.7 | 모든 시각에 `+09:00` 시간대 표시 추가(공통 규칙 4장), 패키지 컨벤션 확정(10장) |
| 기본 경로 | `/api/v1` |
Expand Down Expand Up @@ -2391,9 +2393,9 @@ POST /api/v1/ai/tone-corrections

- **핵심 정보 검사**: 원문과 결과에서 날짜·시간·금액·단위가 붙은 숫자를 뽑아 같은 형태로 바꾼 뒤 비교합니다. 빠지거나 새로 생긴 값이 있으면 `keyInfoWarning`을 채웁니다. 고유명사는 검사하지 않습니다.
- 경고가 있어도 **결과는 정상 응답**이고 횟수도 차감됩니다. 화면은 결과 아래에 `keyInfoWarning.message`를 표시합니다(기획 확정 통합 문구).
- **횟수 처리**: 요청 시 1회를 먼저 잡아두고(남은 횟수 없으면 AI429_1), AI 오류·타임아웃·혐오 표현·욕설 차단이면 되돌립니다.
- **처리 중 이탈**: 서버가 연결 끊김을 감지하면 되돌리지만, 이미 결과가 만들어진 뒤 이탈하면 차감될 수 있습니다.
- 외부 AI 대기 시간은 20초. 넘으면 AI504_1. 출력 토큰 상한(4,000)을 반드시 설정합니다(비용 상한).
- **횟수 처리**: 요청 시 1회를 먼저 잡아두고(남은 횟수 없으면 AI429_1), AI 오류·타임아웃·혐오 표현·욕설 차단이면 되돌립니다. 서버 내부 오류(500)도 횟수를 차감하지 않습니다.
- **처리 중 이탈**: 지금은 서버가 연결 끊김을 감지하지 않아, 처리 중 화면을 떠나도 횟수가 차감됩니다.
- 외부 AI 대기 시간은 20초. 넘으면 AI504_1. 출력 토큰 상한(6,000)을 반드시 설정합니다(비용 상한).
- 원문과 결과는 저장하지도, 로그에 남기지도 않습니다 (정책표 8). 에러 로그에도 원문 제외.
- "이전 버전으로 돌아가기"(AIT-04)용 이력은 화면에서만 보관합니다.
- 모든 사용자가 쓸 수 있습니다. v0.7의 `AI403_1`(Pro 전용)은 삭제했고, 번호는 다시 쓰지 않습니다.
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/com/sniptext/domain/ai/controller/AiSuccessCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.sniptext.domain.ai.controller;

import org.springframework.http.HttpStatus;

import com.sniptext.global.response.ResultCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum AiSuccessCode implements ResultCode {

TONE_CORRECTED(HttpStatus.OK, "AI200_1", "톤 교정에 성공했습니다.");

private final HttpStatus httpStatus;
private final String code;
private final String message;

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

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.sniptext.domain.ai.controller.dto.AiToneCorrectRequest;
import com.sniptext.domain.ai.controller.dto.AiToneCorrectResponse;
import com.sniptext.domain.ai.service.AiToneCorrectionCommandService;
import com.sniptext.domain.ai.service.dto.result.AiToneCorrectResult;
import com.sniptext.global.response.ApiResponse;
import com.sniptext.global.security.LoginUser;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

/**
* AI 톤 교정 (로그인 필요, 캐시 금지).
*/
@RestController
@RequestMapping("/api/v1/ai/tone-corrections")
@RequiredArgsConstructor
public class AiToneCorrectionController {

private final AiToneCorrectionCommandService aiToneCorrectionCommandService;

@PostMapping
public ResponseEntity<ApiResponse<AiToneCorrectResponse>> correct(
@LoginUser Long userId,
@Valid @RequestBody AiToneCorrectRequest request) {

AiToneCorrectResult result = aiToneCorrectionCommandService.correct(request.toCommand(userId));

return ApiResponse.success(AiSuccessCode.TONE_CORRECTED, AiToneCorrectResponse.from(result));
}

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

import com.sniptext.domain.ai.annotation.ValidToneStyle;
import com.sniptext.domain.ai.annotation.ValidToneText;
import com.sniptext.domain.ai.entity.ToneStyle;
import com.sniptext.domain.ai.service.dto.command.AiToneCorrectCommand;

/**
* AI 톤 교정 (명세 22번). 여러 개가 틀리면 선언 순서상 첫 에러 하나만 내려주므로 style → text 순서를 지킨다.
* style을 ToneStyle로 받으면 없는 값이 JSON 변환 실패(COMMON400_1)가 되므로 문자열로 받는다.
* 원문은 저장·로그 금지 대상이라 toString에는 길이만 보인다 (디버그 로그가 요청 객체를 toString으로 찍는다).
*/
public record AiToneCorrectRequest(
@ValidToneStyle String style,
@ValidToneText String text
) {

public AiToneCorrectCommand toCommand(Long userId) {
return new AiToneCorrectCommand(userId, ToneStyle.valueOf(style), text);
}

@Override
public String toString() {
return "AiToneCorrectRequest[style=%s, textLength=%s]".formatted(style, text == null ? null : text.length());
}

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

import java.time.LocalDateTime;
import java.util.List;

import com.sniptext.domain.ai.entity.ToneStyle;
import com.sniptext.domain.ai.service.dto.result.AiToneCorrectResult;
import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult;
import com.sniptext.domain.ai.support.KeyInfoDiff;

/**
* AI 톤 교정 결과 (명세 22번). 핵심 정보 차이가 없으면 keyInfoWarning은 null이다.
* 교정 결과는 저장·로그 금지 대상이라 toString에는 길이만 보인다.
*/
public record AiToneCorrectResponse(
String correctedText,
ToneStyle style,
KeyInfoWarning keyInfoWarning,
Usage usage
) {

/** 기획 확정 통합 문구. 화면이 그대로 띄운다 */
static final String KEY_INFO_WARNING_MESSAGE = "원문의 일부 정보가 빠지거나 변경되었을 수 있어요. 확인 후 사용해주세요.";

public static AiToneCorrectResponse from(AiToneCorrectResult result) {
return new AiToneCorrectResponse(result.correctedText(), result.style(),
KeyInfoWarning.from(result.keyInfoDiff()), Usage.from(result.usage()));
}

@Override
public String toString() {
return "AiToneCorrectResponse[correctedTextLength=%d, style=%s, keyInfoWarning=%s, usage=%s]".formatted(
correctedText.length(), style, keyInfoWarning, usage);
}

public record KeyInfoWarning(String message, List<String> missingValues, List<String> addedValues) {

static KeyInfoWarning from(KeyInfoDiff diff) {
if (!diff.hasDifference()) {
return null;
}
return new KeyInfoWarning(KEY_INFO_WARNING_MESSAGE, diff.missingValues(), diff.addedValues());
}

/** 값은 원문·결과 조각이라 개수만 보인다. */
@Override
public String toString() {
return "KeyInfoWarning[missing=%d, added=%d]".formatted(missingValues.size(), addedValues.size());
}

}

/** 시각은 전역 직렬화 설정이 +09:00을 붙여 내보낸다. */
public record Usage(int usedCount, int limit, int remaining, LocalDateTime resetAt) {

static Usage from(AiToneUsageResult usage) {
return new Usage(usage.usedCount(), usage.limit(), usage.remaining(), usage.resetAt());
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@
import lombok.extern.slf4j.Slf4j;

/**
* AI 톤 교정 (명세 22번). 1회를 먼저 잡고 언어 모델을 부른 뒤, 차단·외부 오류·시간 초과면 되돌린다.
* AI 톤 교정 (명세 22번). 1회를 먼저 잡고 언어 모델을 부른 뒤, 정상 결과를 돌려주지 못하면 되돌린다.
* 차단·외부 오류·시간 초과(BusinessException)와 서버 내부 오류(그 밖의 RuntimeException, 500) 모두 되돌린다.
* 반환은 {@link #correct}의 catch 한 곳에서만 하므로 한 요청에서 두 번 반환되지 않는다.
* <p>
* 클래스·메서드 모두 트랜잭션을 걸지 않는다 ("명령 서비스는 쓰기 메서드마다 @Transactional" 규칙의 예외).
* 외부 호출(최대 20초) 동안 DB 커넥션을 쥐지 않도록, 선점·반환이 각자 짧은 트랜잭션으로 끝난다
* (AiToneUsageCommandService는 호출자 트랜잭션 안에서 부르면 예외를 던진다).
* <p>
* 원문과 결과는 저장하지도 로그에 남기지도 않는다. 로그에는 회원 번호·문체·결과 종류·소요 시간·출력 토큰 수만 남긴다.
* 서버 내부 오류는 메시지에 원문이 섞일 수 있어 예외 클래스 이름만 남긴다.
*/
@Slf4j
@Service
Expand All @@ -46,47 +49,58 @@ 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));
long startedAt = System.nanoTime();
try {
LanguageModelResult modelResult = complete(command, startedAt);
String correctedText = TextWhitespace.strip(modelResult.text());
return new AiToneCorrectResult(correctedText, command.style(),
KeyInfoChecker.compare(command.text(), correctedText), usage(reservation));
} catch (RuntimeException e) {
// 선점 뒤 실패는 모두 여기서 한 번만 반환한다. 업무 에러는 complete()에서 이미 로그를 남겼다
if (!(e instanceof BusinessException)) {
log.error("AI 톤 교정 서버 오류 userId={} style={} elapsedMs={} exception={}", command.userId(),
command.style(), elapsedMillis(startedAt), e.getClass().getName());
}
release(reservation);
throw e;
}
}

/** 언어 모델을 부른다. 차단·외부 오류·시간 초과면 선점한 1회를 되돌리고 에러를 던진다. 반환은 이 세 경우에서만 한다. */
private LanguageModelResult complete(AiToneCorrectCommand command, AiToneReserveResult reservation) {
/**
* 언어 모델을 부른다. 차단·외부 오류·시간 초과면 해당 에러 코드의 BusinessException을 던진다.
* 반환은 하지 않는다 (호출한 {@link #correct}가 이 에러와 서버 내부 오류 모두에서 반환한다).
*/
private LanguageModelResult complete(AiToneCorrectCommand command, long startedAt) {
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);
throw new BusinessException(AiErrorCode.LANGUAGE_MODEL_TIMEOUT);
} catch (LanguageModelException e) {
logFailure(command, startedAt, e);
throw releaseAndFail(reservation, AiErrorCode.LANGUAGE_MODEL_FAILED);
throw new BusinessException(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);
throw new BusinessException(AiErrorCode.INAPPROPRIATE_TEXT);
}
return result;
}

/** 반환이 실패해도 원래 에러를 그대로 돌려준다. 로그에는 회원 번호·날짜와 예외 종류만 남긴다. */
private BusinessException releaseAndFail(AiToneReserveResult reservation, AiErrorCode errorCode) {
/** 반환이 실패해도 호출자가 원래 예외를 그대로 던진다. 로그에는 회원 번호·날짜와 예외 종류만 남긴다. */
private void release(AiToneReserveResult reservation) {
try {
aiToneUsageCommandService.release(reservation);
} catch (RuntimeException e) {
log.error("AI 톤 교정 사용량 반환 실패 userId={} usageDate={} exception={}", reservation.userId(),
reservation.usageDate(), e.getClass().getSimpleName());
}
return new BusinessException(errorCode);
}

// 언어 모델 예외 메시지는 상태 코드·오류 종류만 담는다 (원문·응답 본문 없음)
Expand Down
Loading