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
14 changes: 9 additions & 5 deletions docs/api-specs/class-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접
### 6.3 TTS 청취 완료 (TTS 클래스만)
- `POST /api/v1/classes/{classId}/battle/votes/tts-complete`
- 사전투표 후 오디오 재생이 끝났을 때 호출한다.
- 단계가 `POST_VOTE`로 넘어가고, 이후 사전투표가 잠긴다.
- 단계가 `POST_VOTE`로 넘어가고, 이후 사전투표가 잠긴다. 이 호출이 있어야 사후투표를 할 수 있다.
- 이미 완료(`COMPLETED`)된 진행은 되돌리지 않는다.

| 상황 | 응답 |
Expand All @@ -700,6 +700,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접
|---|---|
| 사전투표 기록 없음 | 404 `VOTE_404` |
| 진행 단계가 `NONE` | 409 `VOTE_409_PRE` |
| TTS 클래스인데 청취 완료(6.3) 전 | 409 `CLASS_409_TTS_INCOMPLETE` |
| 이미 제출 | 409 `VOTE_409_SUB` |
| 커스텀 배틀인데 AI 토론을 마치지 않음 | 409 `CLASS_409_DEBATE_INCOMPLETE` |
| 이 배틀의 선택지가 아님 | 404 `BATTLE_OPT_404` |
Expand Down Expand Up @@ -752,6 +753,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접
```

- 사후투표한 선택지에 귀속된다.
- 의견 작성이 필수인 클래스(`commentRequired=true`)의 의견은 삭제할 수 없고 수정만 할 수 있다. 리포트 자료로 쓰이기 때문이다. 삭제하면 409 `CLASS_409_PERSPECTIVE_REQUIRED`다.
- 바로 `PUBLISHED`로 저장된다.
- 사후투표 전이면 404 `BATTLE_OPT_404`다.

Expand Down Expand Up @@ -819,8 +821,8 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접
- 조회: 클래스를 볼 수 있으면 된다. 마감 후에도 읽을 수 있다.
- 시작·반박·재시도: 0.3의 쓰기 규칙을 따른다.
- **라운드 진행:** 1라운드는 AI 주장으로 시작하고, 이어서 참가자가 반박한다. 3라운드 반박을 내면 토론이 끝나고 `COMPLETED`가 된다.
- **AI 주장 길이:** 50~500자
- **반박 길이:** 앞뒤 공백 제거 후 50~1,000자. 저장한 뒤에는 수정할 수 없다.
- **AI 주장 길이:** 200~500자
- **반박 길이:** 앞뒤 공백 제거 후 50~500자. 저장한 뒤에는 수정할 수 없다.

### 7.1 토론 조회 (재접속)
- `GET /api/v1/classes/{classId}/battle/debate`
Expand Down Expand Up @@ -897,7 +899,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접
],
"aiNotice": "철학자의 관점을 응용한 AI의 주장이에요. 실제 인물이 한 말이 아니에요.",
"replyMinLength": 50,
"replyMaxLength": 1000
"replyMaxLength": 500
}
}
```
Expand Down Expand Up @@ -1214,7 +1216,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접
| `CLASS_400_RECOMMEND_CONDITION` | 400 | 대상 수준과 카테고리 1~3개를 골라 주세요. 주제는 50자까지 입력할 수 있어요. |
| `CLASS_400_RECOMMEND_SETTINGS` | 400 | 클래스 추천을 켜려면 수준과 카테고리를 먼저 정해 주세요. |
| `CLASS_400_TOPIC_CONDITION` | 400 | 주제는 2~200자, 카테고리는 1~3개로 입력해 주세요. |
| `CLASS_400_DEBATE_REPLY` | 400 | 반박은 50자 이상 1,000자 이하로 적어 주세요. |
| `CLASS_400_DEBATE_REPLY` | 400 | 반박은 50자 이상 500자 이하로 적어 주세요. |
| `CLASS_400_DEBATE_PERSONAL_INFO` | 400 | 전화번호·이메일 같은 개인정보는 지우고 보내 주세요. |
| `CLASS_400_DEBATE_BLOCKED` | 400 | 다른 사람을 위협하거나 해칠 수 있는 표현은 보낼 수 없어요. 내용을 고쳐 주세요. |
| `CLASS_403_TICKET` | 403 | 사용 중인 이용권이 없어 클래스를 만들 수 없습니다. |
Expand All @@ -1238,6 +1240,8 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접
| `CLASS_409_OWNER` | 409 | 내가 운영하는 클래스에는 참여자로 들어갈 수 없습니다. |
| `CLASS_409_PRE_VOTE_LOCKED` | 409 | 배틀이 시작된 뒤에는 사전투표를 변경할 수 없습니다. |
| `CLASS_409_TTS` | 409 | 커스텀 배틀 클래스에는 음성(TTS) 단계가 없습니다. |
| `CLASS_409_TTS_INCOMPLETE` | 409 | 오디오를 끝까지 들은 뒤 사후투표를 할 수 있어요. |
| `CLASS_409_PERSPECTIVE_REQUIRED` | 409 | 의견 작성이 필수인 클래스의 의견은 삭제할 수 없어요. 내용을 수정해 주세요. |
| `CLASS_409_RESULT_EXPIRED` | 409 | 결과 열람 기간이 끝났습니다. |
| `CLASS_409_REPORT_RETRY` | 409 | 지금은 AI 요약을 다시 시도할 수 없습니다. |
| `CLASS_409_TOPIC_DRAFT_USED` | 409 | 이 주제 초안으로 이미 만든 클래스가 있습니다. |
Expand Down
6 changes: 3 additions & 3 deletions docs/erd/class.puml
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ note bottom of ai_jobs
status : QUEUED | RUNNING | SUCCEEDED | FAILED | REJECTED
idempotency_key 로 연속 탭 중복 생성 방지 (UX04)
불완전 JSON·거절·한도 종료는 SUCCEEDED 로 저장하지 않음 (A13)
DEBATE_ARGUMENT(AI 철학자 반박) 길이 : 50~500자 (기획 Q&A 확정). 위반 시 절삭하지 않고 1회 보정 호출 후 REJECTED
DEBATE_ARGUMENT(AI 철학자 반박) 길이 : 200~500자 (기획 확인 10/23). 위반 시 절삭하지 않고 1회 보정 호출 후 REJECTED
end note

note bottom of class_reports
Expand Down Expand Up @@ -499,9 +499,9 @@ end note

note bottom of class_debate_messages
(session_id, round_index, speaker) UK → 라운드·화자당 1개, 저장 후 수정 없음
USER: 앞뒤 공백 제거 후 50~1,000자 (상한은 호출당 입력 5,000 토큰 예산용), request_key 로 중복 전송 방지
USER: 앞뒤 공백 제거 후 50~500자 (기획 확인 10/23), request_key 로 중복 전송 방지
같은 키 재전송 = 기존 결과, 다른 키로 같은 라운드 = 409 CLASS_409_DEBATE_ROUND
AI: 검증 통과한 주장만 저장 (argument_text 50~500자, focus EVIDENCE/ASSUMPTION/COUNTEREXAMPLE/CONDITION/VALUE_CONFLICT),
AI: 검증 통과한 주장만 저장 (argument_text 200~500자, focus EVIDENCE/ASSUMPTION/COUNTEREXAMPLE/CONDITION/VALUE_CONFLICT),
reply_to_message_id = 직전 라운드 USER 메시지 (1라운드 null)
end note

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public ApiResponse<ClassDebateResponse> start(
}

@Operation(summary = "반박 제출",
description = "현재 라운드의 반박(50~1,000자)을 저장하고 다음 라운드 AI 주장을 생성합니다. 3라운드 반박이면 토론이 끝나고(COMPLETED) 사후투표를 할 수 있습니다. "
description = "현재 라운드의 반박(50~500자)을 저장하고 다음 라운드 AI 주장을 생성합니다. 3라운드 반박이면 토론이 끝나고(COMPLETED) 사후투표를 할 수 있습니다. "
+ "같은 requestKey 재전송은 저장된 결과를 그대로 돌려주고, 다른 키로 같은 라운드를 다시 내면 409 CLASS_409_DEBATE_ROUND. "
+ "반박을 받을 수 없는 상태면 409 CLASS_409_DEBATE_STATE, 길이 위반 400 CLASS_400_DEBATE_REPLY. "
+ "개인정보(전화번호·이메일·주민등록번호·카드번호)가 있으면 400 CLASS_400_DEBATE_PERSONAL_INFO, "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

/** 반박 길이(앞뒤 공백 제거 후 50~1,000자)는 서비스에서 검사한다. */
/** 반박 길이(앞뒤 공백 제거 후 50~500자)는 서비스에서 검사한다. */
@Schema(description = "토론 반박 제출 요청")
public record ClassDebateReplyRequest(

@Schema(description = "반박할 라운드 (1~3). 화면에 보인 현재 라운드를 보낸다", example = "1")
@NotNull
Integer roundIndex,

@Schema(description = "반박 내용 (앞뒤 공백 제거 후 50자 이상 1,000자 이하). 저장 후 수정할 수 없다",
@Schema(description = "반박 내용 (앞뒤 공백 제거 후 50자 이상 500자 이하). 저장 후 수정할 수 없다",
example = "속으로 슬퍼해도 밖으로 표현하지 않을 수 있어요. 표현 방식은 사람마다 달라서 겉모습만으로 감정을 판단하면 안 된다고 생각해요.")
String content,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
public enum AiJobType {
TOPIC_CANDIDATES,
TOPIC_VALIDATION,
DEBATE_ARGUMENT, // AI 철학자 반박 (50~500자)
DEBATE_ARGUMENT, // AI 철학자 반박 (200~500자)
MEMBER_REPORT,
CLASS_INSIGHT
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ public VoteResultResponse postVote(Long userId, Long classId, VoteRequest reques
&& !debateSessionService.isCompleted(classId, access.member().getId())) {
throw new CustomException(ErrorCode.CLASS_DEBATE_NOT_COMPLETED);
}
// 기존 콘텐츠는 오디오를 끝까지 들어야 사후투표 (PRD §7, 기획 확인 10/23). 사전투표가 없으면 아래에서 404
if (classroom.getBattleType() == ClassBattleType.TTS && !ttsListened(classroom, access.member().getUser())) {
throw new CustomException(ErrorCode.CLASS_TTS_INCOMPLETE);
}
return battleVoteService.postVote(battleOf(classroom).getId(), userId, classroom, request);
}

Expand Down Expand Up @@ -169,6 +173,12 @@ private boolean hasPublishedPerspective(Battle battle, Long userId, Classroom cl
battle.getId(), userId, classroom.getId(), PerspectiveStatus.PUBLISHED);
}

private boolean ttsListened(Classroom classroom, User user) {
return battleVoteRepository.findByBattleAndUserAndClassroom(battleOf(classroom), user, classroom)
.map(vote -> !Boolean.FALSE.equals(vote.getIsTtsListened()))
.orElse(true);
}

private static Battle battleOf(Classroom classroom) {
Battle battle = classroom.getBattle();
if (battle == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
public class ClassDebateContentValidator {

/** 기획 Q&A 확정 길이 (ERD ai_jobs 노트) */
public static final int ARGUMENT_MIN_LENGTH = 50;
public static final int ARGUMENT_MIN_LENGTH = 200;
public static final int ARGUMENT_MAX_LENGTH = 500;

private static final Set<String> FORBIDDEN_FIELDS = Set.of("round", "round_index", "done", "finished", "next_round", "winner", "score");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public final class ClassDebatePrompts {
private ClassDebatePrompts() {
}

public static final String PROMPT_VERSION = "debate-v2";
public static final String PROMPT_VERSION = "debate-v3";

/** 과금 대상 출력 합계 1,000 토큰 상한 (PRD §11 길이와 전달 방식) */
public static final int MAX_OUTPUT_TOKENS = 1_000;
Expand All @@ -32,16 +32,16 @@ private ClassDebatePrompts() {
주제에서 벗어났으면 관련 논점으로 돌아오도록 이끕니다.
3. 출처 없는 직접 인용·통계·연구 결과를 만들지 않습니다. 실존 인물의 말을 따옴표로 인용하지 않습니다.
4. 참가자를 비난하거나 조롱하지 않습니다. 이름을 부르지 않고 "~해요" 체로 씁니다.
5. argument_text 는 50자 이상 500자 이하로 씁니다. 그 안에서 [수준]의 권장 길이를 따릅니다.
5. argument_text 는 200자 이상 500자 이하로 씁니다. 그 안에서 [수준]의 권장 길이를 따릅니다.
6. focus 는 이번 주장의 초점 하나를 EVIDENCE(근거), ASSUMPTION(가정), COUNTEREXAMPLE(반례), CONDITION(조건), VALUE_CONFLICT(가치충돌) 중에서 고릅니다.
[수준]별 반론 방식 (모임이 정한 참가자 수준입니다. 논리의 엄밀함은 낮추지 않고 표현과 깊이를 맞춥니다):
- 초급: 일상 경험이나 친숙한 예시로 설명하고 쉬운 말을 씁니다. 개념어는 꼭 필요할 때만 풀어서 씁니다.
한 번에 논점 하나만 다루고 문장을 짧게 씁니다. 반박을 이어 갈 수 있도록 생각을 묻는 질문 하나로 마칩니다. 권장 120~250자.
- 중급: 근거와 숨은 가정을 짚고 구체적인 예를 하나 듭니다. 개념어를 쓰면 한 번 짧게 풀어 줍니다. 권장 200~350자.
한 번에 논점 하나만 다루고 문장을 짧게 씁니다. 반박을 이어 갈 수 있도록 생각을 묻는 질문 하나로 마칩니다. 권장 200~300자.
- 중급: 근거와 숨은 가정을 짚고 구체적인 예를 하나 듭니다. 개념어를 쓰면 한 번 짧게 풀어 줍니다. 권장 250~400자.
- 고급: 개념을 구분하고 조건·반례·가치 충돌을 정밀하게 다룹니다. 철학 개념어를 설명 없이 써도 되지만
출처 없는 인용은 쓰지 않습니다. 참가자 논증의 전제나 일반화 범위를 직접 문제 삼습니다. 권장 300~500자.
출처 없는 인용은 쓰지 않습니다. 참가자 논증의 전제나 일반화 범위를 직접 문제 삼습니다. 권장 350~500자.
출력은 아래 형식의 JSON 객체 하나만 씁니다. 라운드 번호, 종료 여부, 점수 같은 다른 필드는 넣지 않습니다.
{"argument_text": "50~500자", "focus": "EVIDENCE"}
{"argument_text": "200~500자", "focus": "EVIDENCE"}
""";

public static String user(DebatePromptInput input) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ public class ClassDebateSessionService {
/** 이 시간보다 오래 GENERATING 이면 멈춘 작업으로 보고 재시도를 허용한다 (호출 45초 × 최대 3회 + 여유) */
static final long GENERATING_STALE_MINUTES = 3;
public static final int REPLY_MIN_LENGTH = 50;
/** PRD 는 반박 길이 제한이 없지만, 호출당 입력 5,000 토큰 예산(§11) 안에 3라운드 이력이 들어가도록 상한을 둔다 */
public static final int REPLY_MAX_LENGTH = 1_000;
/** PRD §8 반박 50~500자 (기획 확인 10/23) */
public static final int REPLY_MAX_LENGTH = 500;
static final String AI_NOTICE = "철학자의 관점을 응용한 AI의 주장이에요. 실제 인물이 한 말이 아니에요.";

private final ClassAccessService classAccessService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ public void deletePerspective(Long perspectiveId, Long userId) {
Perspective perspective = findPerspectiveById(perspectiveId);
validateOwnership(perspective, userId);
requireClassWritable(perspective, userId);
// 의견 필수 클래스의 의견은 리포트 자료라 삭제 대신 수정만 허용 (PRD §7, 기획 확인 10/23)
if (perspective.getClassroom() != null && perspective.getClassroom().isCommentRequired()) {
throw new CustomException(ErrorCode.CLASS_REQUIRED_PERSPECTIVE_UNDELETABLE);
}

List<PerspectiveComment> comments = perspectiveCommentRepository.findByPerspective(perspective);
if (!comments.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ public enum ErrorCode {
CLASS_OWNER_CANNOT_LEAVE(HttpStatus.FORBIDDEN, "CLASS_403_OWNER_LEAVE", "운영자는 클래스를 나가거나 내보내질 수 없습니다. 클래스를 삭제해 주세요."),

// Classroom battle (클래스 내 배틀 진행)
CLASS_TTS_INCOMPLETE(HttpStatus.CONFLICT, "CLASS_409_TTS_INCOMPLETE", "오디오를 끝까지 들은 뒤 사후투표를 할 수 있어요."),
CLASS_REQUIRED_PERSPECTIVE_UNDELETABLE(HttpStatus.CONFLICT, "CLASS_409_PERSPECTIVE_REQUIRED", "의견 작성이 필수인 클래스의 의견은 삭제할 수 없어요. 내용을 수정해 주세요."),
CLASS_PRE_VOTE_LOCKED(HttpStatus.CONFLICT, "CLASS_409_PRE_VOTE_LOCKED", "배틀이 시작된 뒤에는 사전투표를 변경할 수 없습니다."),

// Classroom result / AI report (클래스 결과·리포트)
Expand All @@ -196,7 +198,7 @@ public enum ErrorCode {
CLASS_DEBATE_NOT_FOUND(HttpStatus.NOT_FOUND, "CLASS_404_DEBATE", "시작한 토론이 없습니다."),
CLASS_DEBATE_NOT_WAITING(HttpStatus.CONFLICT, "CLASS_409_DEBATE_STATE", "지금은 반박을 보낼 수 없어요."),
CLASS_DEBATE_ROUND_CONFLICT(HttpStatus.CONFLICT, "CLASS_409_DEBATE_ROUND", "이 라운드의 반박은 이미 제출되었습니다."),
CLASS_DEBATE_REPLY_INVALID(HttpStatus.BAD_REQUEST, "CLASS_400_DEBATE_REPLY", "반박은 50자 이상 1,000자 이하로 적어 주세요."),
CLASS_DEBATE_REPLY_INVALID(HttpStatus.BAD_REQUEST, "CLASS_400_DEBATE_REPLY", "반박은 50자 이상 500자 이하로 적어 주세요."),
CLASS_DEBATE_REPLY_PERSONAL_INFO(HttpStatus.BAD_REQUEST, "CLASS_400_DEBATE_PERSONAL_INFO", "전화번호·이메일 같은 개인정보는 지우고 보내 주세요."),
CLASS_DEBATE_REPLY_BLOCKED(HttpStatus.BAD_REQUEST, "CLASS_400_DEBATE_BLOCKED", "다른 사람을 위협하거나 해칠 수 있는 표현은 보낼 수 없어요. 내용을 고쳐 주세요."),
CLASS_DEBATE_NOT_RETRYABLE(HttpStatus.CONFLICT, "CLASS_409_DEBATE_RETRY", "지금은 다시 시도할 수 없어요."),
Expand Down
Loading
Loading