From aebe9672678fc1a5139e5a8a535d0f4c818eec05 Mon Sep 17 00:00:00 2001 From: si-zero Date: Fri, 23 Oct 2026 23:16:55 +0900 Subject: [PATCH 1/2] =?UTF-8?q?#569=20[Feat]=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EA=B8=B0=ED=9A=8D=20=ED=99=95=EC=9D=B8=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=20(AI=20=EC=A3=BC=EC=9E=A5=20=EA=B8=B8=EC=9D=B4=C2=B7TTS=20?= =?UTF-8?q?=EC=B2=AD=EC=B7=A8=20=ED=95=84=EC=88=98=C2=B7=ED=95=84=EC=88=98?= =?UTF-8?q?=20=EC=9D=98=EA=B2=AC=20=EC=82=AD=EC=A0=9C=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/class-api.md | 8 +++-- docs/erd/class.puml | 4 +-- .../domain/classroom/enums/AiJobType.java | 2 +- .../service/ClassBattleServiceImpl.java | 10 +++++++ .../debate/ClassDebateContentValidator.java | 2 +- .../service/debate/ClassDebatePrompts.java | 12 ++++---- .../service/PerspectiveService.java | 4 +++ .../global/common/exception/ErrorCode.java | 2 ++ .../ClassBattleApiIntegrationTest.java | 30 +++++++++++++++++-- .../ClassDebateApiIntegrationTest.java | 5 +++- .../debate/ClassDebatePromptsTest.java | 6 ++-- 11 files changed, 67 insertions(+), 18 deletions(-) diff --git a/docs/api-specs/class-api.md b/docs/api-specs/class-api.md index 2a29b2a1..90115574 100644 --- a/docs/api-specs/class-api.md +++ b/docs/api-specs/class-api.md @@ -683,7 +683,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 ### 6.3 TTS 청취 완료 (TTS 클래스만) - `POST /api/v1/classes/{classId}/battle/votes/tts-complete` - 사전투표 후 오디오 재생이 끝났을 때 호출한다. -- 단계가 `POST_VOTE`로 넘어가고, 이후 사전투표가 잠긴다. +- 단계가 `POST_VOTE`로 넘어가고, 이후 사전투표가 잠긴다. 이 호출이 있어야 사후투표를 할 수 있다. - 이미 완료(`COMPLETED`)된 진행은 되돌리지 않는다. | 상황 | 응답 | @@ -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` | @@ -752,6 +753,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 ``` - 사후투표한 선택지에 귀속된다. +- 의견 작성이 필수인 클래스(`commentRequired=true`)의 의견은 삭제할 수 없고 수정만 할 수 있다. 리포트 자료로 쓰이기 때문이다. 삭제하면 409 `CLASS_409_PERSPECTIVE_REQUIRED`다. - 바로 `PUBLISHED`로 저장된다. - 사후투표 전이면 404 `BATTLE_OPT_404`다. @@ -819,7 +821,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 - 조회: 클래스를 볼 수 있으면 된다. 마감 후에도 읽을 수 있다. - 시작·반박·재시도: 0.3의 쓰기 규칙을 따른다. - **라운드 진행:** 1라운드는 AI 주장으로 시작하고, 이어서 참가자가 반박한다. 3라운드 반박을 내면 토론이 끝나고 `COMPLETED`가 된다. -- **AI 주장 길이:** 50~500자 +- **AI 주장 길이:** 200~500자 - **반박 길이:** 앞뒤 공백 제거 후 50~1,000자. 저장한 뒤에는 수정할 수 없다. ### 7.1 토론 조회 (재접속) @@ -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 | 이 주제 초안으로 이미 만든 클래스가 있습니다. | diff --git a/docs/erd/class.puml b/docs/erd/class.puml index 4dea31be..b4eb5847 100644 --- a/docs/erd/class.puml +++ b/docs/erd/class.puml @@ -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 @@ -501,7 +501,7 @@ note bottom of class_debate_messages (session_id, round_index, speaker) UK → 라운드·화자당 1개, 저장 후 수정 없음 USER: 앞뒤 공백 제거 후 50~1,000자 (상한은 호출당 입력 5,000 토큰 예산용), 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 diff --git a/src/main/java/com/swyp/picke/domain/classroom/enums/AiJobType.java b/src/main/java/com/swyp/picke/domain/classroom/enums/AiJobType.java index 1ab169d1..d3f6aaea 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/enums/AiJobType.java +++ b/src/main/java/com/swyp/picke/domain/classroom/enums/AiJobType.java @@ -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 } diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/ClassBattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/classroom/service/ClassBattleServiceImpl.java index 4a836afc..325d4d86 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/service/ClassBattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/classroom/service/ClassBattleServiceImpl.java @@ -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); } @@ -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) { diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateContentValidator.java b/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateContentValidator.java index f18d3663..b4a11fe8 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateContentValidator.java +++ b/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateContentValidator.java @@ -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 FORBIDDEN_FIELDS = Set.of("round", "round_index", "done", "finished", "next_round", "winner", "score"); diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebatePrompts.java b/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebatePrompts.java index 3e51732d..c6c72314 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebatePrompts.java +++ b/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebatePrompts.java @@ -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; @@ -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) { diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java index 58789e14..9fc4055d 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java @@ -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 comments = perspectiveCommentRepository.findByPerspective(perspective); if (!comments.isEmpty()) { diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 28789b63..7a81f727 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -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 (클래스 결과·리포트) diff --git a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java index 3a5fb966..2501ab94 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java @@ -67,6 +67,7 @@ import static org.hamcrest.Matchers.hasSize; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -203,6 +204,10 @@ void fullFlow_inClass() throws Exception { .andExpect(jsonPath("$.data.preVote.optionId").value(optionB.getId())) .andExpect(jsonPath("$.data.status").value("PRE_VOTE")); + // TTS 를 끝까지 듣기 전에는 사후투표 불가 + vote(token, cls, "/votes/post", optionA).andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_TTS_INCOMPLETE")); + // TTS 청취 완료 → 이후 사전투표 변경은 잠김 mockMvc.perform(post(classBattle(cls) + "/votes/tts-complete").header("Authorization", "Bearer " + token)) .andExpect(status().isOk()); @@ -223,12 +228,23 @@ void fullFlow_inClass() throws Exception { .andExpect(jsonPath("$.data.participationCompleted").value(false)); // 의견 등록 → 참여 완료 - mockMvc.perform(post(classBattle(cls) + "/perspectives") + long perspectiveId = dataOf(mockMvc.perform(post(classBattle(cls) + "/perspectives") .header("Authorization", "Bearer " + token) .contentType(MediaType.APPLICATION_JSON) .content(json(Map.of("content", "클래스 안에서 쓴 의견입니다. 충분히 긴 내용으로 작성합니다.")))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("PUBLISHED")); + .andExpect(jsonPath("$.data.status").value("PUBLISHED")) + .andReturn()).get("perspectiveId").asLong(); + + // 의견 필수 클래스의 의견은 삭제 대신 수정만 + mockMvc.perform(delete("/api/v1/perspectives/" + perspectiveId).header("Authorization", "Bearer " + token)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_PERSPECTIVE_REQUIRED")); + mockMvc.perform(patch("/api/v1/perspectives/" + perspectiveId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of("content", "수정한 클래스 의견입니다. 충분히 긴 내용으로 다시 작성합니다.")))) + .andExpect(status().isOk()); mockMvc.perform(get(classBattle(cls)).header("Authorization", "Bearer " + token)) .andExpect(status().isOk()) @@ -258,6 +274,7 @@ void classAndGeneral_areIsolated() throws Exception { join(token, cls.inviteCode, "학생A").andExpect(status().isOk()); vote(token, cls, "/votes/pre", optionA).andExpect(status().isOk()); + listen(token, cls); vote(token, cls, "/votes/post", optionA).andExpect(status().isOk()); mockMvc.perform(post(classBattle(cls) + "/perspectives") .header("Authorization", "Bearer " + token) @@ -323,6 +340,7 @@ void closedClass_blocksWritesOnly() throws Exception { // 운영자(참여)가 먼저 의견까지 남겨 둔다 vote(ownerToken, cls, "/votes/pre", optionA).andExpect(status().isOk()); + listen(ownerToken, cls); vote(ownerToken, cls, "/votes/post", optionA).andExpect(status().isOk()); MvcResult created = mockMvc.perform(post(classBattle(cls) + "/perspectives") .header("Authorization", "Bearer " + ownerToken) @@ -370,6 +388,7 @@ void likes_followClassAccess() throws Exception { join(readerToken, cls.inviteCode, "학생B").andExpect(status().isOk()); vote(writerToken, cls, "/votes/pre", optionA).andExpect(status().isOk()); + listen(writerToken, cls); vote(writerToken, cls, "/votes/post", optionA).andExpect(status().isOk()); long perspectiveId = dataOf(mockMvc.perform(post(classBattle(cls) + "/perspectives") .header("Authorization", "Bearer " + writerToken) @@ -443,6 +462,7 @@ void reports_requireClassReadAccess() throws Exception { join(readerToken, cls.inviteCode, "학생B").andExpect(status().isOk()); vote(writerToken, cls, "/votes/pre", optionA).andExpect(status().isOk()); + listen(writerToken, cls); vote(writerToken, cls, "/votes/post", optionA).andExpect(status().isOk()); long perspectiveId = dataOf(mockMvc.perform(post(classBattle(cls) + "/perspectives") .header("Authorization", "Bearer " + writerToken) @@ -500,6 +520,7 @@ void purge_afterReadGrace() throws Exception { join(readerToken, cls.inviteCode, "학생B").andExpect(status().isOk()); vote(writerToken, cls, "/votes/pre", optionA).andExpect(status().isOk()); + listen(writerToken, cls); vote(writerToken, cls, "/votes/post", optionA).andExpect(status().isOk()); long perspectiveId = dataOf(mockMvc.perform(post(classBattle(cls) + "/perspectives") .header("Authorization", "Bearer " + writerToken) @@ -592,6 +613,11 @@ private ResultActions vote(String token, ClassInfo cls, String path, BattleOptio .content(json(Map.of("optionId", option.getId())))); } + private void listen(String token, ClassInfo cls) throws Exception { + mockMvc.perform(post(classBattle(cls) + "/votes/tts-complete").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } + private void closeClass(long classId) { Classroom classroom = classroomRepository.findById(classId).orElseThrow(); ReflectionTestUtils.setField(classroom, "deadline", LocalDateTime.now().minusMinutes(1)); diff --git a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java index 6501ec9d..2b33aea0 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java @@ -368,7 +368,10 @@ private void stubDebate() { private static Map argument(int round) { return Map.of( - "argument_text", round + "라운드 주장이에요. 감정과 표현이 다를 수 있다는 점은 인정하지만, 관계 속에서는 상대가 확인할 수 있는 표현도 중요하지 않을까요?", + "argument_text", round + "라운드 주장이에요. 감정과 표현이 다를 수 있다는 점은 인정해요. 다만 관계 속에서는 상대가 확인할 수 있는 표현도 중요하지 않을까요? " + + "내면의 슬픔을 의심하지 않더라도, 아무 표현이 없어서 다른 사람이 느끼는 상처는 남을 수 있어요. " + + "표현의 자유와 관계에 대한 책임이 부딪칠 때 우리는 무엇을 기준으로 판단해야 할까요? 슬픔을 드러내는 방식이 문화와 사람마다 다르다면, " + + "겉으로 드러난 모습만으로 누군가를 비난하는 일은 어디까지 정당할 수 있을지 함께 따져 보고 싶어요.", "focus", "EVIDENCE"); } diff --git a/src/test/java/com/swyp/picke/domain/classroom/service/debate/ClassDebatePromptsTest.java b/src/test/java/com/swyp/picke/domain/classroom/service/debate/ClassDebatePromptsTest.java index 75a6e6bd..18890eb9 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/service/debate/ClassDebatePromptsTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/service/debate/ClassDebatePromptsTest.java @@ -22,12 +22,12 @@ void userPromptCarriesDifficulty(BattleDifficulty difficulty, String label) { } @Test - @DisplayName("시스템 프롬프트에 수준별 반론 방식이 모두 있고, 권장 길이는 출력 검증 범위(50~500자) 안이다") + @DisplayName("시스템 프롬프트에 수준별 반론 방식이 모두 있고, 권장 길이는 출력 검증 범위(200~500자) 안이다") void systemPromptDescribesEveryDifficulty() { assertThat(ClassDebatePrompts.SYSTEM) .contains("- 초급:", "- 중급:", "- 고급:") - .contains("권장 120~250자", "권장 200~350자", "권장 300~500자"); - assertThat(ClassDebatePrompts.PROMPT_VERSION).isEqualTo("debate-v2"); + .contains("권장 200~300자", "권장 250~400자", "권장 350~500자"); + assertThat(ClassDebatePrompts.PROMPT_VERSION).isEqualTo("debate-v3"); } private static DebatePromptInput input(BattleDifficulty difficulty) { From 739bdab134bb83fd032c724e7bfb580a48c4ba3a Mon Sep 17 00:00:00 2001 From: si-zero Date: Fri, 23 Oct 2026 23:20:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?#569=20[Feat]=20=EC=B0=B8=EC=97=AC=EC=9E=90?= =?UTF-8?q?=20=EB=B0=98=EB=B0=95=20=EC=B5=9C=EB=8C=80=20=EA=B8=B8=EC=9D=B4?= =?UTF-8?q?=20500=EC=9E=90=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/class-api.md | 6 +++--- docs/erd/class.puml | 2 +- .../domain/classroom/controller/ClassDebateController.java | 2 +- .../classroom/dto/request/ClassDebateReplyRequest.java | 4 ++-- .../classroom/service/debate/ClassDebateSessionService.java | 4 ++-- .../com/swyp/picke/global/common/exception/ErrorCode.java | 2 +- .../classroom/controller/ClassDebateApiIntegrationTest.java | 5 ++++- 7 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/api-specs/class-api.md b/docs/api-specs/class-api.md index 90115574..1c40c623 100644 --- a/docs/api-specs/class-api.md +++ b/docs/api-specs/class-api.md @@ -822,7 +822,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 - 시작·반박·재시도: 0.3의 쓰기 규칙을 따른다. - **라운드 진행:** 1라운드는 AI 주장으로 시작하고, 이어서 참가자가 반박한다. 3라운드 반박을 내면 토론이 끝나고 `COMPLETED`가 된다. - **AI 주장 길이:** 200~500자 -- **반박 길이:** 앞뒤 공백 제거 후 50~1,000자. 저장한 뒤에는 수정할 수 없다. +- **반박 길이:** 앞뒤 공백 제거 후 50~500자. 저장한 뒤에는 수정할 수 없다. ### 7.1 토론 조회 (재접속) - `GET /api/v1/classes/{classId}/battle/debate` @@ -899,7 +899,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 ], "aiNotice": "철학자의 관점을 응용한 AI의 주장이에요. 실제 인물이 한 말이 아니에요.", "replyMinLength": 50, - "replyMaxLength": 1000 + "replyMaxLength": 500 } } ``` @@ -1216,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 | 사용 중인 이용권이 없어 클래스를 만들 수 없습니다. | diff --git a/docs/erd/class.puml b/docs/erd/class.puml index b4eb5847..c5ebfc5e 100644 --- a/docs/erd/class.puml +++ b/docs/erd/class.puml @@ -499,7 +499,7 @@ 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 200~500자, focus EVIDENCE/ASSUMPTION/COUNTEREXAMPLE/CONDITION/VALUE_CONFLICT), reply_to_message_id = 직전 라운드 USER 메시지 (1라운드 null) diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java index 94f2d255..fe50d5c0 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java @@ -48,7 +48,7 @@ public ApiResponse 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, " diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassDebateReplyRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassDebateReplyRequest.java index 7bedea09..4526b470 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassDebateReplyRequest.java +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassDebateReplyRequest.java @@ -5,7 +5,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -/** 반박 길이(앞뒤 공백 제거 후 50~1,000자)는 서비스에서 검사한다. */ +/** 반박 길이(앞뒤 공백 제거 후 50~500자)는 서비스에서 검사한다. */ @Schema(description = "토론 반박 제출 요청") public record ClassDebateReplyRequest( @@ -13,7 +13,7 @@ public record ClassDebateReplyRequest( @NotNull Integer roundIndex, - @Schema(description = "반박 내용 (앞뒤 공백 제거 후 50자 이상 1,000자 이하). 저장 후 수정할 수 없다", + @Schema(description = "반박 내용 (앞뒤 공백 제거 후 50자 이상 500자 이하). 저장 후 수정할 수 없다", example = "속으로 슬퍼해도 밖으로 표현하지 않을 수 있어요. 표현 방식은 사람마다 달라서 겉모습만으로 감정을 판단하면 안 된다고 생각해요.") String content, diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateSessionService.java b/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateSessionService.java index 35968ba0..76476d39 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateSessionService.java +++ b/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateSessionService.java @@ -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; diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 7a81f727..a465b36d 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -198,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", "지금은 다시 시도할 수 없어요."), diff --git a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java index 2b33aea0..a4eae01e 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java @@ -236,7 +236,7 @@ void fullDebate() throws Exception { } @Test - @DisplayName("반박 중복·검사: 같은 키 재전송은 그대로, 다른 키 409, 다음 라운드 미리 제출 409, 50자 미만 400") + @DisplayName("반박 중복·검사: 같은 키 재전송은 그대로, 다른 키 409, 다음 라운드 미리 제출 409, 50자 미만·500자 초과 400") void replyIdempotency() throws Exception { preVote(optionB); start().andExpect(jsonPath("$.data.philosopher.code").value("PLATO")); @@ -244,6 +244,9 @@ void replyIdempotency() throws Exception { reply(1, "너무 짧아요", "key-short") .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error.code").value("CLASS_400_DEBATE_REPLY")); + reply(1, "가".repeat(501), "key-long") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("CLASS_400_DEBATE_REPLY")); reply(2, longText("미리 쓴 반박"), "key-x") .andExpect(status().isConflict()) .andExpect(jsonPath("$.error.code").value("CLASS_409_DEBATE_STATE"));