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 4ba7ced0..3e51732d 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 @@ -1,5 +1,6 @@ package com.swyp.picke.domain.classroom.service.debate; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; import com.swyp.picke.domain.classroom.enums.ClassDebateSpeaker; import com.swyp.picke.domain.user.enums.PhilosopherType; @@ -12,7 +13,7 @@ public final class ClassDebatePrompts { private ClassDebatePrompts() { } - public static final String PROMPT_VERSION = "debate-v1"; + public static final String PROMPT_VERSION = "debate-v2"; /** 과금 대상 출력 합계 1,000 토큰 상한 (PRD §11 길이와 전달 방식) */ public static final int MAX_OUTPUT_TOKENS = 1_000; @@ -31,8 +32,14 @@ private ClassDebatePrompts() { 주제에서 벗어났으면 관련 논점으로 돌아오도록 이끕니다. 3. 출처 없는 직접 인용·통계·연구 결과를 만들지 않습니다. 실존 인물의 말을 따옴표로 인용하지 않습니다. 4. 참가자를 비난하거나 조롱하지 않습니다. 이름을 부르지 않고 "~해요" 체로 씁니다. - 5. argument_text 는 50자 이상 500자 이하로 씁니다. + 5. argument_text 는 50자 이상 500자 이하로 씁니다. 그 안에서 [수준]의 권장 길이를 따릅니다. 6. focus 는 이번 주장의 초점 하나를 EVIDENCE(근거), ASSUMPTION(가정), COUNTEREXAMPLE(반례), CONDITION(조건), VALUE_CONFLICT(가치충돌) 중에서 고릅니다. + [수준]별 반론 방식 (모임이 정한 참가자 수준입니다. 논리의 엄밀함은 낮추지 않고 표현과 깊이를 맞춥니다): + - 초급: 일상 경험이나 친숙한 예시로 설명하고 쉬운 말을 씁니다. 개념어는 꼭 필요할 때만 풀어서 씁니다. + 한 번에 논점 하나만 다루고 문장을 짧게 씁니다. 반박을 이어 갈 수 있도록 생각을 묻는 질문 하나로 마칩니다. 권장 120~250자. + - 중급: 근거와 숨은 가정을 짚고 구체적인 예를 하나 듭니다. 개념어를 쓰면 한 번 짧게 풀어 줍니다. 권장 200~350자. + - 고급: 개념을 구분하고 조건·반례·가치 충돌을 정밀하게 다룹니다. 철학 개념어를 설명 없이 써도 되지만 + 출처 없는 인용은 쓰지 않습니다. 참가자 논증의 전제나 일반화 범위를 직접 문제 삼습니다. 권장 300~500자. 출력은 아래 형식의 JSON 객체 하나만 씁니다. 라운드 번호, 종료 여부, 점수 같은 다른 필드는 넣지 않습니다. {"argument_text": "50~500자", "focus": "EVIDENCE"} """; @@ -46,6 +53,7 @@ public static String user(DebatePromptInput input) { PhilosopherType p = input.philosopher(); sb.append("[철학자] ").append(p.getLabel()).append(" · ").append(p.getTypeName()).append(" · ") .append(p.getDescription()).append(" · ").append(String.join(" ", p.getKeywordTags())).append("\n\n"); + sb.append("[수준] ").append(difficultyLabel(input.difficulty())).append('\n'); sb.append("[라운드] ").append(input.round()).append(" / 3\n\n"); sb.append("[대화]\n"); if (input.history().isEmpty()) { @@ -68,6 +76,14 @@ public static String correctionUser(String originalUser, String previousResponse + "위 문제를 고친 JSON 을 다시 출력합니다.\n"; } + static String difficultyLabel(BattleDifficulty difficulty) { + return switch (difficulty) { + case BEGINNER -> "초급"; + case INTERMEDIATE -> "중급"; + case ADVANCED -> "고급"; + }; + } + private static String side(DebatePromptInput.Side side) { return side.position() + ". " + side.label() + " — " + side.rationale(); } 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 665b6bdc..35968ba0 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 @@ -2,6 +2,7 @@ import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; import com.swyp.picke.domain.classroom.dto.request.ClassDebateReplyRequest; import com.swyp.picke.domain.classroom.dto.response.ClassDebateResponse; import com.swyp.picke.domain.classroom.entity.AiJob; @@ -279,10 +280,17 @@ private DebatePromptInput promptInput(ClassDebateSession session) { promptSide(session.getParticipantOption()), promptSide(session.getOpponentOption()), session.getPhilosopher(), + difficultyOf(session.getClassroom()), session.getRound(), history); } + /** 커스텀 배틀의 대상 수준 = 주제 버전의 수준 (기획 Q&A: 커스텀은 AI 철학자 반론 수준). 없으면 중급으로 본다 */ + private static BattleDifficulty difficultyOf(Classroom classroom) { + ClassTopicVersion version = classroom.getTopicVersion(); + return version == null || version.getDifficulty() == null ? BattleDifficulty.INTERMEDIATE : version.getDifficulty(); + } + private static DebatePromptInput.Side promptSide(BattleOption option) { return new DebatePromptInput.Side(position(option), option.getTitle(), option.getStance()); } diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/debate/DebatePromptInput.java b/src/main/java/com/swyp/picke/domain/classroom/service/debate/DebatePromptInput.java index 66a0da95..3b3cabcf 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/service/debate/DebatePromptInput.java +++ b/src/main/java/com/swyp/picke/domain/classroom/service/debate/DebatePromptInput.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.classroom.service.debate; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; import com.swyp.picke.domain.classroom.enums.ClassDebateSpeaker; import com.swyp.picke.domain.user.enums.PhilosopherType; import java.util.List; @@ -13,6 +14,7 @@ public record DebatePromptInput( Side participant, Side opponent, PhilosopherType philosopher, + BattleDifficulty difficulty, int round, List history ) { 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 69e7737d..6501ec9d 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 @@ -198,7 +198,7 @@ void fullDebate() throws Exception { .andExpect(jsonPath("$.data.messages[0].focus").value("EVIDENCE")) .andExpect(jsonPath("$.data.messages[0].focusLabel").value("근거")); verify(aiClient).chatJson(eq(ClassDebatePrompts.SYSTEM), - argThat(p -> p.contains("[라운드] 1 / 3") && p.contains("(아직 없음)") + argThat(p -> p.contains("[라운드] 1 / 3") && p.contains("(아직 없음)") && p.contains("[수준] 중급") && p.contains("[상대 입장 - 당신이 대변] B. 반대 2") && p.contains("[철학자] 아리스토텔레스")), anyInt()); 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 new file mode 100644 index 00000000..75a6e6bd --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/classroom/service/debate/ClassDebatePromptsTest.java @@ -0,0 +1,44 @@ +package com.swyp.picke.domain.classroom.service.debate; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import com.swyp.picke.domain.user.enums.PhilosopherType; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ClassDebatePromptsTest { + + @ParameterizedTest + @DisplayName("토론 입력에 클래스 수준이 한글 이름으로 들어간다") + @CsvSource({"BEGINNER,초급", "INTERMEDIATE,중급", "ADVANCED,고급"}) + void userPromptCarriesDifficulty(BattleDifficulty difficulty, String label) { + String prompt = ClassDebatePrompts.user(input(difficulty)); + + assertThat(prompt).contains("[수준] " + label + "\n"); + } + + @Test + @DisplayName("시스템 프롬프트에 수준별 반론 방식이 모두 있고, 권장 길이는 출력 검증 범위(50~500자) 안이다") + void systemPromptDescribesEveryDifficulty() { + assertThat(ClassDebatePrompts.SYSTEM) + .contains("- 초급:", "- 중급:", "- 고급:") + .contains("권장 120~250자", "권장 200~350자", "권장 300~500자"); + assertThat(ClassDebatePrompts.PROMPT_VERSION).isEqualTo("debate-v2"); + } + + private static DebatePromptInput input(BattleDifficulty difficulty) { + return new DebatePromptInput( + "표현하지 않은 감정까지 타인이 판단해도 될까요?", + "감정 표현의 자유와 공동체의 기대를 비교해 봐요.", + new DebatePromptInput.Side("A", "판단할 수 있다", "표현은 관계에 대한 책임의 일부다"), + new DebatePromptInput.Side("B", "판단하기 어렵다", "내면과 표현은 다를 수 있다"), + PhilosopherType.values()[0], + difficulty, + 1, + List.of()); + } +}