diff --git a/docs/api-specs/class-api.md b/docs/api-specs/class-api.md index a41879e7..e155ac9e 100644 --- a/docs/api-specs/class-api.md +++ b/docs/api-specs/class-api.md @@ -414,9 +414,14 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 |---|---|---| | `SUCCEEDED` | 후보 3개 생성. `batchNo`가 1 늘고 제안 횟수가 1 줄어든다 | 후보 표시 | | `NEEDS_CLARIFICATION` | 맥락이 부족함 | `clarificationMessage` 표시 | -| `FAILED` | AI 생성 실패. 기존 후보와 횟수는 그대로다 | 재시도 안내 | +| `FAILED` | AI 생성 실패. 기존 후보와 횟수는 그대로다. 이유는 `failureReason` | 재시도 안내 | | `GENERATING` | 생성 중 | "생각을 정리하고 있어요" | +| failureReason (FAILED 일 때만) | 의미 | 화면 처리 | +|---|---|---| +| `AI_UNAVAILABLE` | AI 서비스 장애 (연결 오류·시간 초과·5xx·크레딧 소진 등) | "잠시 후 다시 시도해 주세요" | +| `INVALID_OUTPUT` | AI 응답이 1회 보정 후에도 후보 규칙을 지키지 못함 | 다시 시도하거나 조건 변경 안내 | + | 상황 | 응답 | |---|---| | 성공 배치 3회 소진 | 409 `CLASS_409_TOPIC_SUGGEST_LIMIT` | @@ -508,6 +513,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 | `topicSeed`, `difficulty`, `categories` | 마지막 성공 배치 기준 조건 | | `suggestStatus` | `NONE` / `GENERATING` / `SUCCEEDED` / `NEEDS_CLARIFICATION` / `FAILED` | | `clarificationMessage` | 맥락 부족 안내 (nullable) | +| `failureReason` | `AI_UNAVAILABLE` / `INVALID_OUTPUT`. `FAILED`가 아니면 null | | `batchNo` | 현재 후보의 배치 번호 (0이면 후보 없음) | | `remainingSuggestions` | 남은 제안 횟수 (최대 3) | | `remainingValidations` | 남은 수정 검증 횟수 (최대 3) | diff --git a/docs/erd/class.puml b/docs/erd/class.puml index 9ed49c77..793963fd 100644 --- a/docs/erd/class.puml +++ b/docs/erd/class.puml @@ -190,6 +190,7 @@ entity "class_topic_drafts" as class_topic_drafts { suggest_request_count : INTEGER suggest_started_at : TIMESTAMP <> clarification_message : VARCHAR(200) <> + suggest_failure_reason : VARCHAR(30) <> validation_count : INTEGER created_at : TIMESTAMP updated_at : TIMESTAMP @@ -507,6 +508,7 @@ note bottom of class_topic_drafts 제안: 후보 정확히 3개 (S05 화면 기준, 기획 확정)(question ≤60, description ≤80, options 2개: label ≤20, rationale ≤40, 철학자 = PhilosopherType 코드) 서버 검사(개수·길이·조건 일치·철학자 허용 목록·두 철학자 다름·질문 중복) 실패 → 문제 목록으로 1회 보정 → 계속 실패면 REJECTED 실패·맥락 부족(NEEDS_CLARIFICATION)은 기존 후보 유지, batch_no 증가 없음 + FAILED 이유는 suggest_failure_reason (#608): AI_UNAVAILABLE = 제공자 장애, INVALID_OUTPUT = 보정 후에도 규칙 위반. 새 제안을 시작하면 비운다 분당 2회/계정 = ai_jobs(TOPIC_CANDIDATES, ticket_id, started_at) 1분 내 건수 (계정당 ACTIVE 이용권 1개) GENERATING 3분 경과는 멈춘 작업으로 보고 새 요청 허용 ai_jobs 멱등키: TOPIC_CANDIDATES:{draft_id}:{suggest_request_count}, TOPIC_VALIDATION:{draft_id}:{validation_count} diff --git a/src/main/java/com/swyp/picke/domain/classroom/converter/ClassTopicConverter.java b/src/main/java/com/swyp/picke/domain/classroom/converter/ClassTopicConverter.java index 3d18b701..d665fb93 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/converter/ClassTopicConverter.java +++ b/src/main/java/com/swyp/picke/domain/classroom/converter/ClassTopicConverter.java @@ -26,6 +26,7 @@ public static ClassTopicDraftResponse toDraft(ClassTopicDraft draft, ClassTopicV draft.getCategories().stream().map(BattleCategory::valueOf).toList(), draft.getSuggestStatus(), draft.getClarificationMessage(), + draft.getSuggestFailureReason(), draft.getBatchNo(), draft.remainingSuggestions(), draft.remainingValidations(), diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassTopicDraftResponse.java b/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassTopicDraftResponse.java index 9f3766b1..85e9acdf 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassTopicDraftResponse.java +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassTopicDraftResponse.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.battle.enums.BattleCategory; import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import com.swyp.picke.domain.classroom.enums.ClassTopicSuggestFailureReason; import com.swyp.picke.domain.classroom.enums.ClassTopicSuggestStatus; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; @@ -16,6 +17,9 @@ public record ClassTopicDraftResponse( + "SUCCEEDED = 성공, NEEDS_CLARIFICATION = 맥락 부족(clarificationMessage 표시), FAILED = 실패(기존 후보 유지, 횟수 차감 없음)") ClassTopicSuggestStatus suggestStatus, @Schema(description = "맥락 부족 시 보완 안내 문구", nullable = true) String clarificationMessage, + @Schema(description = "FAILED 일 때만 값이 있다. AI_UNAVAILABLE = AI 서비스 장애(잠시 후 다시 시도), " + + "INVALID_OUTPUT = AI 응답이 규칙을 지키지 못함(다시 시도하거나 조건 변경)", nullable = true) + ClassTopicSuggestFailureReason failureReason, @Schema(description = "현재 후보의 배치 번호 (성공 배치 수, 0 이면 후보 없음)") int batchNo, @Schema(description = "남은 제안 횟수 (최초 + 재추천 최대 3회)") int remainingSuggestions, @Schema(description = "남은 직접 수정 검증 횟수 (최대 3회)") int remainingValidations, diff --git a/src/main/java/com/swyp/picke/domain/classroom/entity/ClassTopicDraft.java b/src/main/java/com/swyp/picke/domain/classroom/entity/ClassTopicDraft.java index af4be131..eedc2b0a 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/entity/ClassTopicDraft.java +++ b/src/main/java/com/swyp/picke/domain/classroom/entity/ClassTopicDraft.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.classroom.entity; import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import com.swyp.picke.domain.classroom.enums.ClassTopicSuggestFailureReason; import com.swyp.picke.domain.classroom.enums.ClassTopicSuggestStatus; import com.swyp.picke.domain.ticket.entity.ClassTicket; import com.swyp.picke.domain.user.entity.User; @@ -30,6 +31,7 @@ *
  • candidates : 마지막 성공 배치의 후보 3개. candidate_id(1~3)·question·description·category·difficulty·options[2]
  • *
  • batch_no : 성공한 제안 배치 수 = 현재 후보의 배치 번호. 최대 3 (최초 + 재추천)
  • *
  • suggest_request_count : 제안 요청 수(실패 포함). ai_jobs 멱등키에 쓴다
  • + *
  • suggest_failure_reason : 마지막 제안이 FAILED 일 때만 값이 있다
  • *
  • validation_count : AI 의미 검증을 호출한 수정 검증 수. 최대 3
  • * */ @@ -84,6 +86,10 @@ public class ClassTopicDraft extends BaseEntity { @Column(name = "clarification_message", length = 200) private String clarificationMessage; + @Enumerated(EnumType.STRING) + @Column(name = "suggest_failure_reason", length = 30) + private ClassTopicSuggestFailureReason suggestFailureReason; + @Column(name = "validation_count", nullable = false) private int validationCount; @@ -122,6 +128,7 @@ public void startSuggest(LocalDateTime now) { this.suggestStatus = ClassTopicSuggestStatus.GENERATING; this.suggestRequestCount++; this.suggestStartedAt = now; + this.suggestFailureReason = null; } /** 성공 배치 반영. 조건 변경도 같은 초안의 새 배치로 세므로 이때 조건을 함께 바꾼다 (PRD §10) */ @@ -142,8 +149,9 @@ public void needClarification(String message) { this.clarificationMessage = message; } - public void failSuggest() { + public void failSuggest(ClassTopicSuggestFailureReason reason) { this.suggestStatus = ClassTopicSuggestStatus.FAILED; + this.suggestFailureReason = reason; } public void consumeValidation() { diff --git a/src/main/java/com/swyp/picke/domain/classroom/enums/ClassTopicSuggestFailureReason.java b/src/main/java/com/swyp/picke/domain/classroom/enums/ClassTopicSuggestFailureReason.java new file mode 100644 index 00000000..f8e15100 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/enums/ClassTopicSuggestFailureReason.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.classroom.enums; + +/** 주제 제안이 FAILED 로 끝난 이유. 화면 안내를 나누기 위한 값이며 제공자 오류 상세는 ai_jobs.error_code 에 남긴다 (#608) */ +public enum ClassTopicSuggestFailureReason { + AI_UNAVAILABLE, // 제공자 장애(연결 오류·시간 초과·5xx·크레딧 소진 등). 잠시 후 다시 시도 + INVALID_OUTPUT // AI 응답이 1회 보정 후에도 후보 규칙을 어김. 다시 시도하거나 조건을 바꿔 요청 +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/topic/ClassTopicDraftService.java b/src/main/java/com/swyp/picke/domain/classroom/service/topic/ClassTopicDraftService.java index 406baab6..f6f090a8 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/service/topic/ClassTopicDraftService.java +++ b/src/main/java/com/swyp/picke/domain/classroom/service/topic/ClassTopicDraftService.java @@ -15,6 +15,7 @@ import com.swyp.picke.domain.classroom.entity.ClassTopicVersion; import com.swyp.picke.domain.classroom.enums.AiJobStatus; import com.swyp.picke.domain.classroom.enums.AiJobType; +import com.swyp.picke.domain.classroom.enums.ClassTopicSuggestFailureReason; import com.swyp.picke.domain.classroom.enums.ClassTopicVersionSource; import com.swyp.picke.domain.classroom.repository.AiJobRepository; import com.swyp.picke.domain.classroom.repository.ClassTopicDraftRepository; @@ -156,7 +157,7 @@ public void failSuggest(PreparedSuggest prepared, String errorCode, boolean reje if (job.getStatus() != AiJobStatus.RUNNING) { return; } - draft.failSuggest(); + draft.failSuggest(rejected ? ClassTopicSuggestFailureReason.INVALID_OUTPUT : ClassTopicSuggestFailureReason.AI_UNAVAILABLE); finishJob(job, errorCode, rejected, usage, now); } diff --git a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassTopicApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassTopicApiIntegrationTest.java index ede1abf5..396ed84b 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassTopicApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassTopicApiIntegrationTest.java @@ -187,6 +187,7 @@ void suggestRejectedKeepsPreviousCandidates() throws Exception { suggest(draftId, null) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.suggestStatus").value("FAILED")) + .andExpect(jsonPath("$.data.failureReason").value("INVALID_OUTPUT")) .andExpect(jsonPath("$.data.batchNo").value(1)) .andExpect(jsonPath("$.data.remainingSuggestions").value(2)) .andExpect(jsonPath("$.data.candidates[0].question").value(firstQuestion)); @@ -223,6 +224,7 @@ void suggestProviderFailure() throws Exception { suggest(draftId, null) .andExpect(jsonPath("$.data.suggestStatus").value("FAILED")) + .andExpect(jsonPath("$.data.failureReason").value("AI_UNAVAILABLE")) .andExpect(jsonPath("$.data.remainingSuggestions").value(3)); verify(aiClient, times(3)).chatJson(eq(ClassTopicPrompts.SUGGEST_SYSTEM), anyString(), anyInt()); AiJob job = onlyJob(AiJobType.TOPIC_CANDIDATES); @@ -239,6 +241,7 @@ void suggestQuotaExhaustedFailsFast() throws Exception { suggest(draftId, null) .andExpect(jsonPath("$.data.suggestStatus").value("FAILED")) + .andExpect(jsonPath("$.data.failureReason").value("AI_UNAVAILABLE")) .andExpect(jsonPath("$.data.remainingSuggestions").value(3)); verify(aiClient, times(1)).chatJson(eq(ClassTopicPrompts.SUGGEST_SYSTEM), anyString(), anyInt()); AiJob job = onlyJob(AiJobType.TOPIC_CANDIDATES);