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
8 changes: 7 additions & 1 deletion docs/api-specs/class-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 2 additions & 0 deletions docs/erd/class.puml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ entity "class_topic_drafts" as class_topic_drafts {
suggest_request_count : INTEGER
suggest_started_at : TIMESTAMP <<nullable>>
clarification_message : VARCHAR(200) <<nullable>>
suggest_failure_reason : VARCHAR(30) <<nullable>>
validation_count : INTEGER
created_at : TIMESTAMP
updated_at : TIMESTAMP
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -30,6 +31,7 @@
* <li>candidates : 마지막 성공 배치의 후보 3개. candidate_id(1~3)·question·description·category·difficulty·options[2]</li>
* <li>batch_no : 성공한 제안 배치 수 = 현재 후보의 배치 번호. 최대 3 (최초 + 재추천)</li>
* <li>suggest_request_count : 제안 요청 수(실패 포함). ai_jobs 멱등키에 쓴다</li>
* <li>suggest_failure_reason : 마지막 제안이 FAILED 일 때만 값이 있다</li>
* <li>validation_count : AI 의미 검증을 호출한 수정 검증 수. 최대 3</li>
* </ul>
*/
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -122,6 +128,7 @@ public void startSuggest(LocalDateTime now) {
this.suggestStatus = ClassTopicSuggestStatus.GENERATING;
this.suggestRequestCount++;
this.suggestStartedAt = now;
this.suggestFailureReason = null;
}

/** 성공 배치 반영. 조건 변경도 같은 초안의 새 배치로 세므로 이때 조건을 함께 바꾼다 (PRD §10) */
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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회 보정 후에도 후보 규칙을 어김. 다시 시도하거나 조건을 바꿔 요청
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading