diff --git a/app/src/main/resources/db/migration/V18__add_page_numbers_and_language_to_problem_set.sql b/app/src/main/resources/db/migration/V18__add_page_numbers_and_language_to_problem_set.sql new file mode 100644 index 00000000..e5a53452 --- /dev/null +++ b/app/src/main/resources/db/migration/V18__add_page_numbers_and_language_to_problem_set.sql @@ -0,0 +1,10 @@ +-- 동일 재현(이어풀기)용 생성 조건 보강: 세트가 pageNumbers·language를 소유해 조회 시 되짚을 수 있게 한다. +-- 기존 legacy 행은 두 값 NULL → 조회 응답에서 폴백(US2)으로 유도된다. +ALTER TABLE problem_set + ADD COLUMN page_numbers TEXT NULL, + ADD COLUMN language VARCHAR(8) NULL; + +-- PII 분류(새 컬럼 커버리지 게이트) — 페이지 번호 목록·언어 코드는 개인정보 아님. +INSERT INTO pii_classification (table_name, column_name, strategy, note) +VALUES ('problem_set', 'page_numbers', 'SAFE', NULL), + ('problem_set', 'language', 'SAFE', NULL); diff --git a/build.gradle b/build.gradle index 61aec4f1..6f45bcbf 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { group = "com.icc.qasker" -version = "3.4.4" +version = "3.5.0" subprojects { tasks.configureEach { task -> diff --git a/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java b/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java index 4928439d..5d1a0a59 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java +++ b/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java @@ -9,6 +9,7 @@ public enum ExceptionMessage { // ## 공통 (global) DEFAULT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기가 제한을 초과했습니다."), // ## 파일 업로드/변환 (oci, quiz-make, util) @@ -26,6 +27,7 @@ public enum ExceptionMessage { PROBLEM_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 문제를 찾을 수 없습니다."), PROBLEM_SET_NOT_FOUND(HttpStatus.NOT_FOUND, "문제 세트를 찾을 수 없습니다."), QUALITY_REVIEW_NO_TARGET(HttpStatus.BAD_REQUEST, "품질 재검토할 문항이 없는 세트입니다."), + GRADE_UNSUPPORTED_QUIZ_TYPE(HttpStatus.BAD_REQUEST, "REAL_BLANK 유형만 채점할 수 있습니다."), FAIL_CONVERT(HttpStatus.INTERNAL_SERVER_ERROR, "컨버팅에 실패했습니다."), // ## 퀴즈 히스토리 (quiz-history) diff --git a/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java b/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java index fd846215..66b6483f 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java +++ b/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java @@ -1,10 +1,13 @@ package com.icc.qasker.global.error; import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import java.util.Objects; import lombok.extern.slf4j.Slf4j; import org.apache.catalina.connector.ClientAbortException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.async.AsyncRequestNotUsableException; @@ -57,6 +60,23 @@ public ResponseEntity handleCustomException( .body(new CustomErrorResponse(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR.getMessage())); } + /** + * 요청 바디 Bean Validation 실패(`@Valid @RequestBody`). 잘못된 요청이므로 400으로 응답한다(미처리 시 catch-all의 500으로 + * 샘). 첫 필드 오류 메시지를 그대로 노출하고, 없으면 기본 메시지로 대체한다. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e) { + String message = + e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .filter(Objects::nonNull) + .findFirst() + .orElse(ExceptionMessage.INVALID_REQUEST.getMessage()); + log.warn("[요청 검증 실패] {}", message); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new CustomErrorResponse(message)); + } + @ExceptionHandler(MaxUploadSizeExceededException.class) public ResponseEntity handleMaxUploadSizeExceededException( MaxUploadSizeExceededException e) { diff --git a/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java b/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java index b734a419..d30bd71d 100644 --- a/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java +++ b/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -12,6 +13,8 @@ import ch.qos.logback.core.read.ListAppender; import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import org.apache.catalina.connector.ClientAbortException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -21,6 +24,8 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.context.request.async.AsyncRequestTimeoutException; @@ -126,6 +131,15 @@ void unhandledException() throws Exception { .andExpect(jsonPath("$.message").value(ExceptionMessage.DEFAULT_ERROR.getMessage())); } + @Test + @DisplayName("요청 바디 검증 실패(@NotBlank) → 400 + 필드 오류 메시지 (500 아님)") + void bodyValidationFails() throws Exception { + mockMvc + .perform(post("/validate").contentType("application/json").content("{\"url\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("url이 존재하지 않습니다.")); + } + @RestController static class TestController { @@ -169,5 +183,10 @@ public void asyncTimeout() { public void boom() { throw new RuntimeException("unexpected"); } + + @PostMapping("/validate") + public void validate(@Valid @RequestBody ValidateRequest request) {} } + + record ValidateRequest(@NotBlank(message = "url이 존재하지 않습니다.") String url) {} } diff --git a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/AISelection.java b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/AISelection.java index f9f4e2a6..42e2a022 100644 --- a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/AISelection.java +++ b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/AISelection.java @@ -1,3 +1,12 @@ package com.icc.qasker.ai.dto; -public record AISelection(String content, String explanation, boolean correct) {} +import java.util.List; + +/** AI가 생성한 선택지. REAL_BLANK 정답 선택지는 {@code acceptedAnswers}에 빈칸별 인정 표현 집합을 담는다(없으면 null). */ +public record AISelection( + String content, String explanation, boolean correct, List> acceptedAnswers) { + + public AISelection(String content, String explanation, boolean correct) { + this(content, explanation, correct, null); + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/mapper/GeminiRealBlankQuestionMapper.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/mapper/GeminiRealBlankQuestionMapper.java new file mode 100644 index 00000000..d4872ca4 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/mapper/GeminiRealBlankQuestionMapper.java @@ -0,0 +1,99 @@ +package com.icc.qasker.ai.mapper; + +import com.icc.qasker.ai.dto.AIProblem; +import com.icc.qasker.ai.dto.AIProblemSet; +import com.icc.qasker.ai.dto.AISelection; +import com.icc.qasker.ai.structure.GeminiRealBlankQuestion; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * GeminiRealBlankQuestion → AIProblemSet 변환. 정답(answer)을 단일 Selection(content=answer, correct=true, + * acceptedAnswers=인정범위)로 매핑한다 — 오답 선택지 없음(FR-008). 해설은 selection.explanation에 담는다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class GeminiRealBlankQuestionMapper { + + private static final Pattern PAGE_PATTERN = Pattern.compile("\\[(\\d+)p\\]\\s*>"); + + public static AIProblemSet toDto(List questions) { + return toDto(questions, null); + } + + public static AIProblemSet toDto( + List questions, List sourcePages) { + List result = + questions.stream() + .map( + q -> { + List selections = + q.answer() != null + ? List.of( + new AISelection( + q.answer(), + remapText(q.explanation(), sourcePages), + true, + q.acceptedAnswers())) + : List.of(); + + return new AIProblem( + q.content(), + q.bloomsLevel(), + selections, + remapPages(q.referencedPages(), sourcePages), + remapText(q.appliedInstruction(), sourcePages)); + }) + .toList(); + + return new AIProblemSet(result); + } + + private static String remapText(String text, List sourcePages) { + if (text == null || sourcePages == null || sourcePages.isEmpty()) { + return text; + } + + StringBuilder sb = new StringBuilder(); + Matcher matcher = PAGE_PATTERN.matcher(text); + int lastEnd = 0; + + while (matcher.find()) { + sb.append(text, lastEnd, matcher.start()); + try { + int aiPage = Integer.parseInt(matcher.group(1)); + int index = aiPage - 1; + if (index >= 0 && index < sourcePages.size()) { + sb.append("[").append(sourcePages.get(index)).append("p] >"); + } else { + sb.append(matcher.group()); + } + } catch (NumberFormatException e) { + sb.append(matcher.group()); + } + lastEnd = matcher.end(); + } + sb.append(text.substring(lastEnd)); + return sb.toString(); + } + + private static List remapPages(List aiPages, List sourcePages) { + if (aiPages == null) return List.of(); + if (sourcePages == null || sourcePages.isEmpty()) return aiPages; + + return aiPages.stream() + .map( + page -> { + int index = page - 1; + if (index >= 0 && index < sourcePages.size()) { + return sourcePages.get(index); + } + return page; + }) + .distinct() + .sorted() + .toList(); + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/realblank/RealBlankQuizOrchestrator.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/realblank/RealBlankQuizOrchestrator.java new file mode 100644 index 00000000..d209d1b8 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/realblank/RealBlankQuizOrchestrator.java @@ -0,0 +1,80 @@ +package com.icc.qasker.ai.service.realblank; + +import com.icc.qasker.ai.GeminiFileService; +import com.icc.qasker.ai.dto.AIProblem; +import com.icc.qasker.ai.mapper.GeminiRealBlankQuestionMapper; +import com.icc.qasker.ai.properties.QAskerAiProperties; +import com.icc.qasker.ai.service.AbstractChunkedQuizOrchestrator; +import com.icc.qasker.ai.service.quality.QualityGate; +import com.icc.qasker.ai.service.realblank.prompt.RealBlankRequestPrompt; +import com.icc.qasker.ai.service.support.GeminiMetricsRecorder; +import com.icc.qasker.ai.structure.GeminiRealBlankQuestion; +import com.icc.qasker.ai.structure.GeminiRealBlankResponse; +import com.icc.qasker.ai.structure.GeminiRealBlankResponseSchema; +import java.util.List; +import java.util.Optional; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +/** + * REAL_BLANK(직접 입력 단답) 퀴즈 오케스트레이터. ESSAY와 동일하게 청크형 골격 {@link AbstractChunkedQuizOrchestrator}를 전용 + * 파싱 구조 {@code GeminiRealBlankQuestion}으로 고정한다. 오답 선택지를 만들지 않고(FR-008), 정답(answer)을 단일 선지로, 인정 + * 범위(acceptedAnswers)를 그 선지에 실어 보존한다. + */ +@Component +public class RealBlankQuizOrchestrator + extends AbstractChunkedQuizOrchestrator { + + public RealBlankQuizOrchestrator( + GeminiFileService geminiFileService, + ChatModel chatModel, + ObjectMapper objectMapper, + GeminiMetricsRecorder metricsRecorder, + QAskerAiProperties aiProperties, + QualityGate qualityGate) { + super(geminiFileService, chatModel, objectMapper, metricsRecorder, aiProperties, qualityGate); + } + + @Override + public String getSupportedType() { + return "REAL_BLANK"; + } + + @Override + protected Class elementType() { + return GeminiRealBlankQuestion.class; + } + + @Override + protected String responseSchema(String customInstruction) { + return GeminiRealBlankResponseSchema.forInstruction(customInstruction); + } + + @Override + protected boolean accept(GeminiRealBlankQuestion question) { + // 직접 입력 단답은 오답 선지 개념이 없으므로 drop 규칙 없음. + return true; + } + + @Override + protected AIProblem toProblem(GeminiRealBlankQuestion question, List sourcePages) { + return GeminiRealBlankQuestionMapper.toDto(List.of(question), sourcePages).quiz().getFirst(); + } + + @Override + protected Optional parseFirst(String text) { + GeminiRealBlankResponse parsed = + new BeanOutputConverter<>(GeminiRealBlankResponse.class).convert(text); + if (parsed == null || parsed.questions() == null || parsed.questions().isEmpty()) { + return Optional.empty(); + } + return Optional.of(parsed.questions().getFirst()); + } + + @Override + protected String dedupInstruction() { + return RealBlankRequestPrompt.DEDUP_INSTRUCTION; + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/realblank/prompt/RealBlankGuideLine.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/realblank/prompt/RealBlankGuideLine.java new file mode 100644 index 00000000..f73851da --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/realblank/prompt/RealBlankGuideLine.java @@ -0,0 +1,90 @@ +package com.icc.qasker.ai.service.realblank.prompt; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class RealBlankGuideLine { + + public static final String content = + """ + > **CRITICAL RULE**: 강의노트에 명시된 내용만 출제 근거로 사용한다. + > **CRITICAL RULE**: 빈칸 정답은 반드시 **명사**여야 한다. 같은 정답 용어가 문항 세트 내 2회 이상 등장하면 안 된다 (다중 빈칸은 각 정답어 단위로 적용). + > **CRITICAL RULE**: 이 유형은 사용자가 답을 **직접 타이핑**해 입력한다. **오답 선택지(distractor)를 절대 만들지 않는다.** 정답과 그 인정 범위(acceptedAnswers)의 품질에만 집중한다. + + # 역할 + 당신은 직접 입력 단답(빈칸채우기) 문항 설계 전문가다. + **Remember 수준**과 **Understand 수준** 문항을 혼합하여 출제한다. + + # Step 1 — 강의노트에서 출제 소재를 추출하고, 각 소재의 지식 유형을 판별한다 + | 지식 유형 | 판별 기준 | 소재 예시 | + |----------|----------|----------| + | 사실적 | 수치·날짜·명칭 등 검증 가능한 정보 | 프로토콜 이름, 역사적 인물, 영양소 명칭 | + | 개념적 | 원리·이론·분류 체계 간의 관계 | 경제 원리 비교, 세포 구조 대조, 프로토콜 계층 분류 | + + # Step 2 — 지식 유형과 인지 수준에 맞는 패턴을 선택한다 + | 인지 수준 | 패턴 | 적합한 지식 유형 | 권장 빈칸 수 | + |----------|------|----------------|------------| + | Remember | 정의형·명칭형·특성형 | 사실적, 개념적 | 1칸 | + | Understand | 비교형·원인형·분류형 | 개념적 | 2~4칸 | + - 서술문은 다중 단서로 정답을 특정하고, 정답어가 문맥에 직접 노출되지 않도록 우회 표현을 쓴다. + + # Step 2.5 — 빈칸 수와 정답 표기 (다중 빈칸 규칙) + - **빈칸 표시**: 본문에 `_______`을 등장 순서대로 N개(1~4개) 배치한다. 2칸 이상이면 각 빈칸 바로 앞에 `①②③④` 라벨을 붙인다. + - **정답 표기(answer)**: 빈칸 등장 순서대로 각 답을 **콤마(,)와 공백 한 칸**으로 구분한다. 예: 2칸 → `"감수분열, 체세포분열"`, 3칸 → `"UDP, IP, 80"`. + + # Step 2.6 — 정답 인정 범위(acceptedAnswers) 산출 ★이 유형의 핵심 + - 각 빈칸마다 **정답으로 인정할 표현 목록**을 만든다. `acceptedAnswers`는 2차원 배열이며, 바깥 배열은 빈칸 순서, 안쪽 배열은 그 빈칸의 인정 표현들이다. + - **각 빈칸 배열의 첫 번째 원소(index 0)는 반드시 그 빈칸의 모범답안(`answer`의 해당 빈칸) 자신**으로 둔다. 그다음에 통용 변형들을 나열한다. + - **목적**: 표현만 다를 뿐 사실상 맞는 답이 오답 처리되는 일을 줄이는 것이다. 아래 통용 변형은 **빠짐없이 적극적으로** 담아라. + - **완전 동의어**(뜻이 정확히 같은 다른 용어): 예 `["체세포분열", "유사분열"]`. + - **약어 ↔ 완전형(양방향 모두)**: 예 `["운영체제", "OS", "operating system"]`, `["티시피", "TCP", "Transmission Control Protocol"]`. + - **영어 ↔ 한국어 표기(양쪽 모두)**: 예 `["미토콘드리아", "mitochondria"]`, `["대한민국", "한국"]`. + - **도메인에서 통용되는 표준 이명·관용 명칭**: 그 분야에서 실제로 같은 대상을 가리키는 널리 쓰이는 다른 이름. + - **절대 포함 금지(품질 우선 — 이 경계를 넘지 마라)**: 오탈자(예: "가속돗"), 정답과 헷갈리는 **인접 개념**, 정답의 **상위/하위/부분 개념**, 뜻이 조금이라도 다른 표현. 인정 여부가 **조금이라도 애매하면 넣지 않는다**(보수적 기본). 넓히는 것은 "맞는데 오답"을 줄이기 위함이지, 틀린 개념을 정답 처리하기 위함이 절대 아니다. + - **표기 차이는 나열 불필요**: 공백·문장부호·대소문자·전각/반각 차이는 채점 시스템이 자동으로 정규화한다. 의미가 같은 **다른 표현**만 담는다. + - **개수 목표는 없다**: 통용 변형이 사실상 없는 고유 표기 하나뿐인 용어라면 모범답안 1개만 담는다(무리하게 부풀리지 않는다). + - 단일 빈칸이면 안쪽 배열 1개, N칸이면 빈칸 순서대로 N개. + + # Step 3 — 해설을 작성한다 + **bloomsLevel**: `"수준 — 유형: [설명]"` 형식으로 기입한다. + **explanation (정답 해설)**: + - `**정답 추론**`: 정답이 되는 이유를 단계적으로 서술. + - `**근거**`: 페이지 번호와 함께 인용. 구조: [Np] > "강의노트 원문 인용". + - `**스스로 점검**`: 놓치기 쉬운 사고 패턴을 질문 형태로 1개 제시. + + --- + + # 완성본 예시 (단일 빈칸) + ```json + { + "questions": [{ + "content": "**진핵세포**에서 이중막으로 둘러싸이고 자체 DNA를 보유한 채 산화적 인산화를 통해 ATP를 합성하는 소기관을 _______(이)라 한다.", + "bloomsLevel": "Remember — 명칭형: 세포 소기관의 고유 변별 단서로 정답을 특정", + "referencedPages": [5], + "answer": "미토콘드리아", + "acceptedAnswers": [["미토콘드리아", "mitochondria"]], + "explanation": "- **정답 추론**: 이중막·자체 DNA·산화적 인산화·ATP 합성은 미토콘드리아의 고유 단서입니다.\\n- **근거**: [5p] > \\"미토콘드리아는 자체 DNA를 가지며 산화적 인산화로 ATP를 생성한다\\"\\n- **스스로 점검**: 엽록체와의 변별 단서(광합성 vs 호흡)를 짚었나요?" + }] + } + ``` + + # 완성본 예시 (2-빈칸) + ```json + { + "questions": [{ + "content": "**세포 분열**에서 ①_______은(는) 교차와 독립적 분리로 유전적으로 다양한 생식세포 4개를 만들고, ②_______은(는) 유전적으로 동일한 딸세포 2개를 만든다.", + "bloomsLevel": "Understand — 비교형: 두 분열 방식의 산물 수·유전적 다양성 대비", + "referencedPages": [22, 23], + "answer": "감수분열, 체세포분열", + "acceptedAnswers": [["감수분열", "meiosis"], ["체세포분열", "유사분열", "mitosis"]], + "explanation": "- **정답 추론**: ①의 단서(교차·생식세포 4개)는 감수분열, ②의 단서(딸세포 2개·유전적 동일)는 체세포분열입니다.\\n- **근거**: [22p] > \\"감수분열은 ... 생식세포 4개를 만든다\\", [23p] > \\"체세포분열은 ... 딸세포 2개를 형성한다\\"\\n- **스스로 점검**: 두 빈칸의 산물 수 단서를 각각 짚었나요?" + }] + } + ``` + + > **CRITICAL RULE**: 강의노트에 명시된 내용만 출제 근거로 사용한다. + > **CRITICAL RULE**: 빈칸 정답은 반드시 **명사**여야 한다. + > **CRITICAL RULE**: **오답 선택지를 만들지 않는다.** acceptedAnswers에는 오탈자·인접/상하위 개념을 넣지 않는다(애매하면 제외). + """; +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/realblank/prompt/RealBlankRequestPrompt.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/realblank/prompt/RealBlankRequestPrompt.java new file mode 100644 index 00000000..e3ad67ae --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/realblank/prompt/RealBlankRequestPrompt.java @@ -0,0 +1,57 @@ +package com.icc.qasker.ai.service.realblank.prompt; + +import java.util.List; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** REAL_BLANK(직접 입력 단답) 전용 유저 프롬프트. 오답 선택지 없이 정답+인정범위를 산출한다. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class RealBlankRequestPrompt { + + /** 청크 K(K≥2) 유저 프롬프트 꼬리에 붙는 중복 회피 지침. */ + public static final String DEDUP_INSTRUCTION = + "\n\n> **CRITICAL RULE**: 위 직전 문항 목록과 빈칸 핵심 어휘·맥락·정답이 겹치지 않게 이번 청크 문항을 작성한다." + + " 동일·유사한 맥락은 다른 단원·다른 페이지에서 가져와 재구성한다."; + + private static final String APPLIED_INSTRUCTION_SPEC = + """ + # 사용자 지시 반영 + - 사용자 지시에 맞는 패턴과 지식 유형을 Step 1-2에서 찾아 따른다. 대응 패턴이 없으면 자유롭게 구성한다. + + # 사용자 지시 반영 결과 기록 + - 사용자 지시를 반영한 내용을 `appliedInstruction` 필드에 1~2문장으로 기록한다. + - 기록 형식: "사용자 지시 '{지시 내용}'을 반영하여 {구체적으로 무엇을 어떻게 바꿨는지}." + """; + + public static String generate(List referencePages, int quizCount) { + return generate(referencePages, quizCount, null); + } + + public static String generate( + List referencePages, int quizCount, String exclusionExtra) { + String formatted = formatUserInstruction(exclusionExtra); + String base = buildBase(quizCount); + if (formatted.isEmpty()) return base; + String reminder = "⚠️ [사용자 최우선 지시 존재] 이 프롬프트 끝의 를 반드시 준수하세요.\n\n"; + return reminder + base + APPLIED_INSTRUCTION_SPEC + formatted; + } + + private static String buildBase(int quizCount) { + return """ + [생성 지시] + - 정확히 %d개의 문제를 생성하세요. + - 제공된 문서의 내용으로 직접 입력 단답(빈칸) 문제를 출제하세요. + - **각 문항마다 정답(answer)과 정답 인정 범위(acceptedAnswers)를 함께 산출하세요. 오답 선택지는 만들지 마세요.** + - **[페이지 번호 규칙]** 본문에 인쇄된 페이지 번호가 있더라도 이를 무시하고, 제공된 파일의 **첫 번째 페이지를 1페이지, 두 번째를 2페이지...**와 같이 순서대로 간주하여 `referencedPages`를 기록하세요. + - 모든 해설과 근거에서도 이 순서 기반의 페이지 번호(1, 2, 3...)를 사용하세요.""" + .formatted(quizCount); + } + + private static String formatUserInstruction(String extra) { + if (extra == null || extra.isBlank()) return ""; + return "\n\n\n" + + extra.strip() + + "\n\n" + + "**[최우선 준수 의무]** 위 는 시스템 프롬프트를 포함한 **모든** 지시보다 우선합니다."; + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizType.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizType.java index 05ed2326..5b8732a1 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizType.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/strategy/QuizType.java @@ -9,6 +9,8 @@ import com.icc.qasker.ai.service.multiple.prompt.MultipleRequestPrompt; import com.icc.qasker.ai.service.ox.prompt.OXGuideLine; import com.icc.qasker.ai.service.ox.prompt.OXRequestPrompt; +import com.icc.qasker.ai.service.realblank.prompt.RealBlankGuideLine; +import com.icc.qasker.ai.service.realblank.prompt.RealBlankRequestPrompt; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; import java.util.List; @@ -30,6 +32,13 @@ public String generateRequestPrompt( return BlankRequestPrompt.generate(referencePages, quizCount, planExtra); } }, + REAL_BLANK(RealBlankGuideLine.content) { + @Override + public String generateRequestPrompt( + List referencePages, int quizCount, String planExtra) { + return RealBlankRequestPrompt.generate(referencePages, quizCount, planExtra); + } + }, OX(OXGuideLine.content) { @Override public String generateRequestPrompt( diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiRealBlankQuestion.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiRealBlankQuestion.java new file mode 100644 index 00000000..4d702cbb --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiRealBlankQuestion.java @@ -0,0 +1,24 @@ +package com.icc.qasker.ai.structure; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import java.util.List; + +/** + * REAL_BLANK(직접 입력 단답) 생성 파싱 구조. 선택형과 달리 오답 선택지가 없고, 정답과 그 인정 범위(acceptedAnswers)를 산출한다. + * BLANK/MULTIPLE/OX의 {@link GeminiQuestion}과 분리해 타 유형 스키마 오염을 막는다(FR-007). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GeminiRealBlankQuestion( + @JsonPropertyDescription("질문문 (빈칸은 _______ 로 표기)") String content, + @JsonPropertyDescription("이 문항에 적용된 Bloom's 수준") String bloomsLevel, + @JsonPropertyDescription("참조한 강의노트 페이지 번호") List referencedPages, + @JsonPropertyDescription("모범답안. 다중 빈칸이면 빈칸 등장 순서대로 콤마(,)와 공백으로 구분") String answer, + @JsonPropertyDescription( + "빈칸별 정답 인정 표현 목록. 바깥 배열=빈칸 순서, 안쪽 배열=그 빈칸에서 정답으로 인정할 표현들." + + " 각 안쪽 배열의 첫 원소(index 0)는 반드시 그 빈칸의 모범답안 자신. 이어서 통용 변형을 적극적으로 나열:" + + " 완전 동의어·약어↔완전형(양방향)·영↔한 표기(양쪽)·도메인 표준 이명." + + " 표기 차이(공백·대소문자·전각반각)는 자동 정규화되니 제외. 오탈자·인접 개념·상위/하위/부분 개념·뜻이 조금이라도 다른 표현은 절대 포함 금지(애매하면 제외).") + List> acceptedAnswers, + @JsonPropertyDescription("해설 (정답 추론·근거)") String explanation, + @JsonPropertyDescription("사용자 지시 반영 결과") String appliedInstruction) {} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiRealBlankResponse.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiRealBlankResponse.java new file mode 100644 index 00000000..33696345 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiRealBlankResponse.java @@ -0,0 +1,10 @@ +package com.icc.qasker.ai.structure; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record GeminiRealBlankResponse( + @JsonPropertyDescription("직접 입력 단답 문제 목록 — 문제+모범답안+인정범위+해설 포함") + List questions) {} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiRealBlankResponseSchema.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiRealBlankResponseSchema.java new file mode 100644 index 00000000..cde2d6de --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiRealBlankResponseSchema.java @@ -0,0 +1,67 @@ +package com.icc.qasker.ai.structure; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.ai.converter.BeanOutputConverter; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** REAL_BLANK 전용 JSON 스키마. customInstruction 유무에 따라 appliedInstruction 필드를 포함/제외한다. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class GeminiRealBlankResponseSchema { + + private static final String WITH_INSTRUCTION = + new BeanOutputConverter<>(GeminiRealBlankResponse.class).getJsonSchema(); + + private static final String WITHOUT_INSTRUCTION = stripAppliedInstruction(WITH_INSTRUCTION); + + public static String forInstruction(String customInstruction) { + if (customInstruction == null || customInstruction.isBlank()) { + return WITHOUT_INSTRUCTION; + } + return WITH_INSTRUCTION; + } + + private static String stripAppliedInstruction(String schema) { + try { + ObjectMapper om = new ObjectMapper(); + JsonNode root = om.readTree(schema); + stripFieldRecursive(root, "appliedInstruction"); + return om.writeValueAsString(root); + } catch (JacksonException e) { + return schema; + } + } + + private static void stripFieldRecursive(JsonNode node, String fieldName) { + if (!node.isObject()) return; + + ObjectNode obj = (ObjectNode) node; + + if (obj.has("properties") && obj.get("properties").has(fieldName)) { + ((ObjectNode) obj.get("properties")).remove(fieldName); + + if (obj.has("required") && obj.get("required").isArray()) { + ArrayNode required = (ArrayNode) obj.get("required"); + ArrayNode filtered = required.arrayNode(); + for (JsonNode item : required) { + if (!fieldName.equals(item.asText())) { + filtered.add(item); + } + } + obj.set("required", filtered); + } + } + + obj.properties() + .forEach( + entry -> { + if (entry.getValue().isObject()) { + stripFieldRecursive(entry.getValue(), fieldName); + } + }); + } +} diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/ProblemWithAnswer.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/ProblemWithAnswer.java index 55c2ff95..6c8b7718 100644 --- a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/ProblemWithAnswer.java +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/ProblemWithAnswer.java @@ -10,4 +10,5 @@ public record ProblemWithAnswer( boolean correct, boolean inReview, List selections, - String textAnswer) {} + String textAnswer, + String answer) {} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java index 57e38228..5711c4ab 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java @@ -10,10 +10,12 @@ import com.icc.qasker.quizhistory.entity.AnswerSnapshotView; import com.icc.qasker.quizhistory.entity.EssayGradeLog; import com.icc.qasker.quizhistory.entity.QuizHistory; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import com.icc.qasker.quizset.dto.feresponse.Selection; import com.icc.qasker.quizset.dto.readonly.ProblemDetail; import com.icc.qasker.quizset.dto.readonly.ProblemSetSummary; import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import com.icc.qasker.quizset.grading.RealBlankGrader; import java.util.List; import java.util.stream.IntStream; import lombok.AccessLevel; @@ -49,8 +51,15 @@ public HistorySummaryResponse toSummary( folderName); } - /** ProblemDetail + 답안 스냅샷 → ProblemWithAnswer(객관식 상세) 변환. 정답 인덱스와 사용자 답을 비교해 정오답을 판정한다. */ - public ProblemWithAnswer toProblemWithAnswer(ProblemDetail problem, AnswerSnapshotView answers) { + /** + * ProblemDetail + 답안 스냅샷 → ProblemWithAnswer 변환. REAL_BLANK는 텍스트 멤버십으로, 그 외 유형은 정답 인덱스와 사용자 답을 + * 비교해 정오답을 판정한다(FR-006 결과·해설·기록 판정 일치). + */ + public ProblemWithAnswer toProblemWithAnswer( + ProblemDetail problem, AnswerSnapshotView answers, QuizType quizType) { + if (quizType == QuizType.REAL_BLANK) { + return toRealBlankProblemWithAnswer(problem, answers); + } List rawSelections = problem.selections(); int correctIndex = findCorrectIndex(rawSelections); int userAnswer = answers.userAnswer(problem.number()); @@ -69,7 +78,24 @@ public ProblemWithAnswer toProblemWithAnswer(ProblemDetail problem, AnswerSnapsh correct, answers.inReview(problem.number()), selections, - answers.textAnswer(problem.number())); + answers.textAnswer(problem.number()), + null); + } + + /** REAL_BLANK 기록 상세: 서버 grader로 텍스트 멤버십 판정. 선택지는 노출하지 않고 대표정답만 answer로 준다. */ + private ProblemWithAnswer toRealBlankProblemWithAnswer( + ProblemDetail problem, AnswerSnapshotView answers) { + String textAnswer = answers.textAnswer(problem.number()); + RealBlankGrader.GradeOutcome outcome = RealBlankGrader.grade(problem.selections(), textAnswer); + return new ProblemWithAnswer( + problem.number(), + problem.title(), + answers.userAnswer(problem.number()), + outcome.isCorrect(), + answers.inReview(problem.number()), + List.of(), + textAnswer, + outcome.answer()); } /** ProblemDetail + 답안 스냅샷 + 최신 채점 로그 → EssayProblemWithGrade(서술형 상세) 변환. */ diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryCommandServiceImpl.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryCommandServiceImpl.java index 9dff2ff9..d2f3a2af 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryCommandServiceImpl.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryCommandServiceImpl.java @@ -10,7 +10,13 @@ import com.icc.qasker.quizhistory.entity.QuizHistory; import com.icc.qasker.quizhistory.repository.QuizFolderRepository; import com.icc.qasker.quizhistory.repository.QuizHistoryRepository; +import com.icc.qasker.quizset.ProblemSetReadService; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.readonly.ProblemSetSummary; +import com.icc.qasker.quizset.grading.RealBlankGrader; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -22,6 +28,7 @@ public class QuizHistoryCommandServiceImpl implements QuizHistoryCommandService private final QuizHistoryRepository quizHistoryRepository; private final QuizFolderRepository quizFolderRepository; + private final ProblemSetReadService problemSetReadService; private final HashUtil hashUtil; @Override @@ -47,28 +54,75 @@ public String initHistory(String userId, InitHistoryRequest request) { } @Override - @Transactional public String saveHistory(String userId, SaveHistoryRequest request) { + Long problemSetId = hashUtil.decode(request.problemSetId()); List snapshots = request.userAnswers().stream() .map(a -> new AnswerSnapshot(a.number(), a.userAnswer(), a.inReview(), a.textAnswer())) .toList(); - QuizHistory history = - quizHistoryRepository - .findByUserIdAndProblemSetId(userId, hashUtil.decode(request.problemSetId())) - .orElseGet( - () -> - quizHistoryRepository.save( - QuizHistory.builder() - .userId(userId) - .problemSetId(hashUtil.decode(request.problemSetId())) - .title(request.title()) - .build())); - history.completeQuiz(snapshots, request.score(), request.totalTime()); + int score = resolveScore(problemSetId, request); + + QuizHistory history = findOrCreateHistory(userId, problemSetId, request.title()); + history.completeQuiz(snapshots, score, request.totalTime()); + quizHistoryRepository.save(history); return hashUtil.encode(history.getId()); } + /** + * (user, problemSet) 이력 행을 찾거나 없으면 생성한다. 같은 세트의 결과 화면이 병렬로 열려 이력 저장이 동시에 일어나면 둘 다 INSERT를 시도해 유니크 + * 제약 {@code (user_id, problem_set_id)}에 걸릴 수 있는데, 충돌을 재조회로 흡수해 500 대신 먼저 만들어진 행을 재사용한다({@link + * #initHistory}와 동일 패턴). + * + *

메서드-레벨 {@code @Transactional}을 두지 않는 것이 핵심이다 — 각 리포지토리 호출이 독립 트랜잭션이라 한 요청의 INSERT 충돌이 이 흐름을 + * 오염시키지 않아 이어지는 재조회가 정상 동작한다(같은 트랜잭션 안에서 잡으면 rollback-only로 오염돼 재조회가 실패한다). 이후 {@code + * completeQuiz} 변경은 호출부의 명시적 {@code save}(merge)로 영속한다. + */ + private QuizHistory findOrCreateHistory(String userId, Long problemSetId, String title) { + return quizHistoryRepository + .findByUserIdAndProblemSetId(userId, problemSetId) + .orElseGet( + () -> { + try { + return quizHistoryRepository.save( + QuizHistory.builder() + .userId(userId) + .problemSetId(problemSetId) + .title(title) + .build()); + } catch (DataIntegrityViolationException e) { + return quizHistoryRepository + .findByUserIdAndProblemSetId(userId, problemSetId) + .orElseThrow(() -> new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND)); + } + }); + } + + /** + * REAL_BLANK 세트는 서버 grader로 맞힌 문항 수를 재계산해 저장한다(클라 score 맹신 폐기, FR-006). 타 유형은 클라가 보낸 score를 그대로 + * 유지한다(채점 동작 불변, FR-007). + */ + private int resolveScore(Long problemSetId, SaveHistoryRequest request) { + ProblemSetSummary summary = problemSetReadService.findProblemSetById(problemSetId).orElse(null); + if (summary == null || summary.quizType() != QuizType.REAL_BLANK) { + return request.score(); + } + Map inputByNumber = + request.userAnswers().stream() + .collect( + Collectors.toMap( + a -> a.number(), + a -> a.textAnswer() == null ? "" : a.textAnswer(), + (a, b) -> a)); + return (int) + problemSetReadService.findProblemsByProblemSetId(problemSetId).stream() + .filter( + p -> + RealBlankGrader.grade(p.selections(), inputByNumber.get(p.number())) + .isCorrect()) + .count(); + } + @Override @Transactional public void updateHistoryTitle(String userId, String historyId, String title) { diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImpl.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImpl.java index d32ca4df..45f11cfc 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImpl.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/QuizHistoryQueryServiceImpl.java @@ -136,7 +136,9 @@ public HistoryDetailResponse getHistoryDetail(String userId, String historyId) { AnswerSnapshotView answers = AnswerSnapshotView.from(history.getAnswers()); List problemWithAnswers = - problems.stream().map(p -> quizHistoryMapper.toProblemWithAnswer(p, answers)).toList(); + problems.stream() + .map(p -> quizHistoryMapper.toProblemWithAnswer(p, answers, problemSet.quizType())) + .toList(); return new HistoryDetailResponse( hashUtil.encode(history.getId()), diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/mock/MockQuizFolderCommandService.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/mock/MockQuizFolderCommandService.java index 5d8438ae..fd16a231 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/mock/MockQuizFolderCommandService.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/mock/MockQuizFolderCommandService.java @@ -13,13 +13,14 @@ import org.springframework.transaction.annotation.Transactional; /** - * 부하 트레이스용 quiz-folder 커맨드 mock(@Profile("mock")). 다른 도메인 mock(MockQuizHistoryCommandService 등)과 동일 - * 패턴 — findByIdAndUserId·소유검증 없이 모든 write를 자기정리(save→delete) throwaway로 순증 0으로 태운다. folder만 이 mock이 - * 없어 실 서비스(조건부 delete)를 타 스케일마다 종단 write가 들쭉날쭉했다. + * 부하 트레이스용 quiz-folder 커맨드 mock(@Profile("mock & !mockai")). 다른 도메인 + * mock(MockQuizHistoryCommandService 등)과 동일 패턴 — findByIdAndUserId·소유검증 없이 모든 write를 + * 자기정리(save→delete) throwaway로 순증 0으로 태운다. folder만 이 mock이 없어 실 서비스(조건부 delete)를 타 스케일마다 종단 write가 + * 들쭉날쭉했다. */ @Service @Primary -@Profile("mock") +@Profile("mock & !mockai") @RequiredArgsConstructor public class MockQuizFolderCommandService implements QuizFolderCommandService { diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/mock/MockQuizHistoryCommandService.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/mock/MockQuizHistoryCommandService.java index 7353b6a5..9be9bc15 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/mock/MockQuizHistoryCommandService.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/mock/MockQuizHistoryCommandService.java @@ -13,12 +13,13 @@ import org.springframework.transaction.annotation.Transactional; /** - * 부하 트레이스용 quiz-history 커맨드 mock(@Profile("mock")). 모든 write를 자기정리(save→delete) throwaway로 순증 0으로 - * 태운다. problemSetId는 FK가 없는 일반 컬럼이라 실 데이터와 겹치지 않는 sentinel({@code 0})을 써 unique 충돌을 피한다. + * 부하 트레이스용 quiz-history 커맨드 mock(@Profile("mock & !mockai")). 모든 write를 자기정리(save→delete) + * throwaway로 순증 0으로 태운다. problemSetId는 FK가 없는 일반 컬럼이라 실 데이터와 겹치지 않는 sentinel({@code 0})을 써 unique + * 충돌을 피한다. */ @Service @Primary -@Profile("mock") +@Profile("mock & !mockai") @RequiredArgsConstructor public class MockQuizHistoryCommandService implements QuizHistoryCommandService { diff --git a/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/QuizHistoryCommandServiceImplTest.java b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/QuizHistoryCommandServiceImplTest.java index 83ccdbe2..ded8255a 100644 --- a/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/QuizHistoryCommandServiceImplTest.java +++ b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/QuizHistoryCommandServiceImplTest.java @@ -2,15 +2,20 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import com.icc.qasker.global.component.HashUtil; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; +import com.icc.qasker.quizhistory.dto.ferequest.SaveHistoryRequest; import com.icc.qasker.quizhistory.entity.QuizFolder; import com.icc.qasker.quizhistory.entity.QuizHistory; +import com.icc.qasker.quizhistory.entity.QuizHistory.QuizHistoryStatus; import com.icc.qasker.quizhistory.repository.QuizFolderRepository; import com.icc.qasker.quizhistory.repository.QuizHistoryRepository; +import com.icc.qasker.quizset.ProblemSetReadService; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -18,12 +23,14 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; @ExtendWith(MockitoExtension.class) class QuizHistoryCommandServiceImplTest { @Mock private QuizHistoryRepository quizHistoryRepository; @Mock private QuizFolderRepository quizFolderRepository; + @Mock private ProblemSetReadService problemSetReadService; @Mock private HashUtil hashUtil; @InjectMocks private QuizHistoryCommandServiceImpl service; @@ -83,4 +90,50 @@ void assignFolder_folderNotFound() { .extracting(e -> ((CustomException) e).getMessage()) .isEqualTo(ExceptionMessage.FOLDER_NOT_FOUND.getMessage()); } + + @Test + @DisplayName("saveHistory: 같은 (user,set) 동시 저장 INSERT 충돌을 재조회로 흡수한다 (500 없이 기존 행 완료)") + void saveHistory_absorbs_concurrent_insert_race() { + QuizHistory existing = + QuizHistory.builder().id(7L).userId("u1").problemSetId(10L).title("t").build(); + when(hashUtil.decode("enc")).thenReturn(10L); + when(problemSetReadService.findProblemSetById(10L)).thenReturn(Optional.empty()); + // 최초 조회는 없음 → save가 유니크 충돌(다른 트랜잭션이 먼저 INSERT) → 재조회는 기존 행 반환 + when(quizHistoryRepository.findByUserIdAndProblemSetId("u1", 10L)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(existing)); + when(quizHistoryRepository.save(any())) + .thenThrow(new DataIntegrityViolationException("dup (user_id, problem_set_id)")) + .thenReturn(existing); + when(hashUtil.encode(7L)).thenReturn("encHist"); + + SaveHistoryRequest request = new SaveHistoryRequest("enc", "t", List.of(), 3, "00:01:00"); + + // 충돌을 던지지 않고(=500 없음) 기존 행으로 흡수해 완료 처리한다. + String result = service.saveHistory("u1", request); + + assertThat(result).isEqualTo("encHist"); + assertThat(existing.getStatus()).isEqualTo(QuizHistoryStatus.COMPLETED); + assertThat(existing.getScore()).isEqualTo(3); + } + + @Test + @DisplayName("saveHistory: 기존 이력이 있으면 새로 만들지 않고 완료 처리한다") + void saveHistory_updates_existing() { + QuizHistory existing = + QuizHistory.builder().id(7L).userId("u1").problemSetId(10L).title("t").build(); + when(hashUtil.decode("enc")).thenReturn(10L); + when(problemSetReadService.findProblemSetById(10L)).thenReturn(Optional.empty()); + when(quizHistoryRepository.findByUserIdAndProblemSetId("u1", 10L)) + .thenReturn(Optional.of(existing)); + when(quizHistoryRepository.save(existing)).thenReturn(existing); + when(hashUtil.encode(7L)).thenReturn("encHist"); + + String result = + service.saveHistory("u1", new SaveHistoryRequest("enc", "t", List.of(), 5, "00:02:00")); + + assertThat(result).isEqualTo("encHist"); + assertThat(existing.getStatus()).isEqualTo(QuizHistoryStatus.COMPLETED); + assertThat(existing.getScore()).isEqualTo(5); + } } diff --git a/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/mock/MockWriteServiceProfileTest.java b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/mock/MockWriteServiceProfileTest.java new file mode 100644 index 00000000..589dad4b --- /dev/null +++ b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/mock/MockWriteServiceProfileTest.java @@ -0,0 +1,28 @@ +package com.icc.qasker.quizhistory.service.mock; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Profile; + +/** + * 자기정리 write mock은 {@code mock & !mockai} 가드를 가져야 한다 — 부하 트레이스(mock)에선 활성이되, 기능 E2E(mockai)에선 비활성돼 + * 실제 서비스가 이력·폴더를 영속하게 한다(생성 mock과 동일 패턴). 가드가 {@code mock} 으로 되돌아가면 mockai E2E에서 sentinel 충돌로 500이 + * 재발하므로 잠근다. + */ +class MockWriteServiceProfileTest { + + @Test + @DisplayName("이력·폴더 write mock은 mockai에서 비활성되는 & !mockai 가드를 유지한다") + void self_cleaning_write_mocks_carry_not_mockai_guard() { + assertThat(profileOf(MockQuizHistoryCommandService.class)).containsExactly("mock & !mockai"); + assertThat(profileOf(MockQuizFolderCommandService.class)).containsExactly("mock & !mockai"); + } + + private static String[] profileOf(Class type) { + Profile profile = type.getAnnotation(Profile.class); + assertThat(profile).as("%s 에 @Profile 이 있어야 한다", type.getSimpleName()).isNotNull(); + return profile.value(); + } +} diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/adapter/MockAIServerAdapter.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/adapter/MockAIServerAdapter.java index 6f700168..0d9b49ba 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/adapter/MockAIServerAdapter.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/adapter/MockAIServerAdapter.java @@ -26,6 +26,13 @@ public void streamRequest(GenerationRequestToAI request) { List pages = CollectionUtils.isEmpty(request.referencePages()) ? List.of(1) : request.referencePages(); + // REAL_BLANK는 선택형과 산출물 형태가 달라(정답 1선지 + acceptedAnswers 2차원, 오답 선지 없음) 전용 목업을 낸다. + // 단일 빈칸 + 다중 빈칸(≥2)을 모두 포함해 빈칸별 인정 목록 노출(FR-008) E2E를 실 Gemini 없이 검증한다. + if ("REAL_BLANK".equals(request.strategyValue())) { + realBlankMocks(quizCount, pages).forEach(request.sink()::saveProblem); + return; + } + // 3개 청크로 나누어 전송 int[][] ranges = { {1, quizCount / 3}, @@ -36,19 +43,104 @@ public void streamRequest(GenerationRequestToAI request) { for (int[] range : ranges) { List problems = new ArrayList<>(); for (int i = range[0]; i <= range[1]; i++) { - problems.add( - new AIProblem( - "Mock question " + i, - "Mock explanation for question " + i, - List.of( - new AISelection("Option A", "Mock explanation A", true), - new AISelection("Option B", "Mock explanation B", false), - new AISelection("Option C", "Mock explanation C", false), - new AISelection("Option D", "Mock explanation D", false)), - pages, - null)); + // 1번 문항은 마크다운 서식(표·인용·코드·수식)을 담은 대표 픽스처로 낸다 — 기능 005 E2E가 전 요소 렌더를 검증한다. + problems.add(i == 1 ? markdownFixture(pages) : plainMock(i, pages)); } problems.forEach(request.sink()::saveProblem); } } + + /** REAL_BLANK 목업 세트: 2번 문항은 다중 빈칸(≥2), 나머지는 단일 빈칸. 정답 1선지 + acceptedAnswers 2차원(index 0=모범답). */ + private static List realBlankMocks(int quizCount, List pages) { + List problems = new ArrayList<>(); + for (int i = 1; i <= quizCount; i++) { + problems.add(i == 2 ? realBlankMultiBlank(pages) : realBlankSingle(i, pages)); + } + return problems; + } + + /** 단일 빈칸 REAL_BLANK 목업. acceptedAnswers=[[모범답, 영문 이표기]]. */ + private static AIProblem realBlankSingle(int i, List pages) { + return new AIProblem( + "진핵세포에서 이중막으로 둘러싸이고 산화적 인산화로 ATP를 합성하는 소기관을 _______(이)라 한다. (mock " + i + ")", + "Remember — 명칭형", + List.of( + new AISelection( + "미토콘드리아", + "- **정답 추론**: 이중막·산화적 인산화·ATP 합성은 미토콘드리아의 고유 단서입니다. (mock " + i + ")", + true, + List.of(List.of("미토콘드리아", "mitochondria")))), + pages, + null); + } + + /** + * 다중 빈칸(2칸) REAL_BLANK 목업. acceptedAnswers=[[모범답1,변형...],[모범답2,변형...]] — 빈칸별 구분 렌더(FR-008) 검증용. + */ + private static AIProblem realBlankMultiBlank(List pages) { + return new AIProblem( + "세포 분열에서 ①_______은(는) 생식세포 4개를, ②_______은(는) 유전적으로 동일한 딸세포 2개를 만든다.", + "Understand — 비교형", + List.of( + new AISelection( + "감수분열, 체세포분열", + "- **정답 추론**: ①은 감수분열, ②는 체세포분열입니다. (mock 다중 빈칸)", + true, + List.of(List.of("감수분열", "meiosis"), List.of("체세포분열", "유사분열", "mitosis")))), + pages, + null); + } + + private static AIProblem plainMock(int i, List pages) { + return new AIProblem( + "Mock question " + i, + "Mock explanation for question " + i, + List.of( + new AISelection("Option A", "Mock explanation A", true), + new AISelection("Option B", "Mock explanation B", false), + new AISelection("Option C", "Mock explanation C", false), + new AISelection("Option D", "Mock explanation D", false)), + pages, + null); + } + + /** 표·인용·코드 펜스·인라인/블록 수식을 담은 마크다운 대표 문항(기능 005 렌더 E2E 픽스처). 이미 개행 분리된 유효 GFM이다. */ + private static AIProblem markdownFixture(List pages) { + String stem = + """ + 다음 표는 세포 소기관의 특성을 비교한 것이다. + + | 소기관 | 위치 | 주요 기능 | + | :--- | :--- | :--- | + | 미토콘드리아 | 동물·식물 | 세포 호흡 | + | 엽록체 | 식물 | 광합성 | + + > **참고**: 광합성 반응식은 $6CO_2 + 6H_2O \\rightarrow C_6H_{12}O_6 + 6O_2$ 이다. + + ```python + def energy(): + return "ATP" + ``` + + 블록 수식으로 나타내면 다음과 같다. + + $$ + E = mc^2 + $$ + + 위 자료에서 **틀린 항목**을 고르면?"""; + return new AIProblem( + stem, + "Analyze", + List.of( + new AISelection( + "미토콘드리아 — 세포 호흡", + "- **정답 추론**: 미토콘드리아는 세포 호흡으로 ATP를 만든다.\n- **근거**: 표의 *주요 기능* 열 참조.", + true), + new AISelection("엽록체 — 광합성", "표와 일치하므로 오답이다.", false), + new AISelection("리보솜 — 단백질 합성", "표에 없는 항목이다.", false), + new AISelection("액포 — 저장", "표에 없는 항목이다.", false)), + pages, + null); + } } diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/AIProblemSetMapper.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/AIProblemSetMapper.java index 32225745..6bf13531 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/AIProblemSetMapper.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/AIProblemSetMapper.java @@ -3,6 +3,7 @@ import com.icc.qasker.ai.dto.AIProblem; import com.icc.qasker.ai.dto.AIProblemSet; import com.icc.qasker.ai.dto.AISelection; +import com.icc.qasker.quizmake.text.MarkdownNormalizer; import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent; import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI; import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI.SelectionsOfAI; @@ -29,7 +30,8 @@ public static QuizGeneratedFromAI toQuiz(AIProblem problem) { private static QuizGeneratedFromAI toQuizGeneratedFromAI(AIProblem problem) { QuizGeneratedFromAI quiz = new QuizGeneratedFromAI(); - quiz.setTitle(problem.content()); + // 저장 보편 초크포인트: 실제 생성·mock·fast-serve·전 타입이 모두 여기로 수렴한다. 글루된 표 개행을 결정론적으로 복원한다(계약 §7.3-2). + quiz.setTitle(MarkdownNormalizer.normalize(problem.content())); quiz.setBloomsLevel(problem.bloomsLevel()); quiz.setReferencedPages(problem.referencedPages()); quiz.setAppliedInstruction(problem.appliedInstruction()); @@ -42,9 +44,10 @@ private static QuizGeneratedFromAI toQuizGeneratedFromAI(AIProblem problem) { private static SelectionsOfAI toSelectionsOfAI(AISelection selection) { SelectionsOfAI sel = new SelectionsOfAI(); - sel.setContent(selection.content()); - sel.setExplanation(selection.explanation()); + sel.setContent(MarkdownNormalizer.normalize(selection.content())); + sel.setExplanation(MarkdownNormalizer.normalize(selection.explanation())); sel.setCorrect(selection.correct()); + sel.setAcceptedAnswers(selection.acceptedAnswers()); return sel; } } diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/properties/QAskerSseProperties.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/properties/QAskerSseProperties.java index 96d468f9..51672d4c 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/properties/QAskerSseProperties.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/properties/QAskerSseProperties.java @@ -11,4 +11,10 @@ public class QAskerSseProperties { /** SSE 연결 타임아웃 (ms) */ private long timeoutMs = 300_000; + + /** + * SSE heartbeat(keep-alive comment) 전송 주기 (ms). 긴 TTFQ 무음 구간에 중간 프록시(Cloudflare 등)가 idle 스트림을 절단해 + * 클라이언트가 재연결하는 것을 예방한다. EventSource는 comment 라인을 무시하므로 클라이언트 코드 변경은 없다. + */ + private long heartbeatIntervalMs = 15_000; } diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImpl.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImpl.java index c0bca360..c4df35c5 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImpl.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImpl.java @@ -5,7 +5,6 @@ import com.icc.qasker.ai.dto.GenerationRequestToAI; import com.icc.qasker.global.component.HashUtil; -import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; import com.icc.qasker.quizmake.GenerationCommandService; import com.icc.qasker.quizmake.SseNotificationService; @@ -62,9 +61,15 @@ public void triggerGeneration(String userId, GenerationRequest request) { request.quizCount(), request.quizType(), request.uploadedUrl(), - request.customInstruction()); + request.customInstruction(), + request.pageNumbers(), + request.language().name()); } catch (DataIntegrityViolationException e) { - throw new CustomException(ExceptionMessage.AI_DUPLICATED_GENERATION); + // 같은 sessionId 재-POST는 멱등 no-op으로 흡수한다(sessionId unique 위반). SSE 재연결 시 클라이언트가 + // onopen에서 생성을 재발사할 수 있는데, 최초 요청의 생성이 이미 진행/완료 중이므로 새 생성을 시작하지 않는다. + // 재연결된 스트림은 subscribe의 재부착 + Last-Event-ID 리플레이로 남은 이벤트를 이어받아 정상 완료된다. + log.info("[생성 멱등] 같은 sessionId 재요청 무시 — 진행 중 생성 유지 sessionId={}", request.sessionId()); + return; } Map contextMap = MDC.getCopyOfContextMap(); diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImpl.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImpl.java index bd498632..25a2a07b 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImpl.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/SseNotificationServiceImpl.java @@ -11,9 +11,13 @@ import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.PreDestroy; import java.io.IOException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; import org.springframework.stereotype.Service; @@ -27,6 +31,7 @@ public class SseNotificationServiceImpl implements SseNotificationService { private final Map emitterMap = new ConcurrentHashMap<>(); private final CircuitBreakerRegistry circuitBreakerRegistry; private final Counter sseTimeoutCounter; + private final ScheduledExecutorService heartbeatScheduler; public SseNotificationServiceImpl( QAskerSseProperties sseProperties, @@ -42,6 +47,35 @@ public SseNotificationServiceImpl( Counter.builder("sse.connections.timeout") .description("SSE 연결 타임아웃 발생 횟수") .register(registry); + + // heartbeat: 무음 구간에 keep-alive comment를 주기적으로 흘려 중간 프록시의 idle 절단(→클라이언트 재연결→중복 생성 트리거)을 예방한다. + this.heartbeatScheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread t = new Thread(r, "sse-heartbeat"); + t.setDaemon(true); + return t; + }); + long intervalMs = sseProperties.getHeartbeatIntervalMs(); + heartbeatScheduler.scheduleAtFixedRate( + this::sendHeartbeats, intervalMs, intervalMs, TimeUnit.MILLISECONDS); + } + + /** 활성 emitter에 keep-alive comment를 보낸다. 전송 실패(끊긴 연결)면 맵에서 정리한다 — 다음 재연결 시 새 emitter가 등록된다. */ + private void sendHeartbeats() { + emitterMap.forEach( + (sessionId, emitter) -> { + try { + emitter.send(SseEmitter.event().comment("keep-alive")); + } catch (Exception e) { + emitterMap.remove(sessionId, emitter); + } + }); + } + + @PreDestroy + void shutdownHeartbeat() { + heartbeatScheduler.shutdownNow(); } @Override diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/mock/MockGenerationCommandService.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/mock/MockGenerationCommandService.java index e1e9d339..6f5c7801 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/mock/MockGenerationCommandService.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/mock/MockGenerationCommandService.java @@ -11,13 +11,17 @@ import org.springframework.transaction.interceptor.TransactionAspectSupport; /** - * 부하 트레이스용 생성 mock(@Profile("mock")). AI 스트리밍·가상스레드·SSE는 전부 건너뛰고 동기로 {@code initProblemSet}만 태운다. - * problem_set 삭제 메서드가 다른 모듈에 없어(cross-module) 트랜잭션 롤백으로 순증 0을 달성한다 — INSERT는 performance_schema에 남아 - * 실 URI로 트레이스되고, 커밋은 되지 않아 DB 상태는 불변이다. + * 부하 트레이스용 생성 mock(@Profile("mock & !mockai")). AI 스트리밍·가상스레드·SSE는 전부 건너뛰고 동기로 {@code + * initProblemSet}만 태운다. problem_set 삭제 메서드가 다른 모듈에 없어(cross-module) 트랜잭션 롤백으로 순증 0을 달성한다 — INSERT는 + * performance_schema에 남아 실 URI로 트레이스되고, 커밋은 되지 않아 DB 상태는 불변이다. + * + *

{@code mockai}가 함께 활성이면 이 스텁은 비활성화되어 실제 생성 흐름({@code GenerationCommandServiceImpl})이 살아난다 — + * {@code MockAIServerAdapter}(Gemini 없이 마크다운 픽스처 생성)와 결합해 결정론적 E2E 시드를 만든다(기능 005). 부하 트레이스는 여전히 + * {@code mockai} 없는 {@code mock}으로 동작한다. */ @Service @Primary -@Profile("mock") +@Profile("mock & !mockai") @RequiredArgsConstructor public class MockGenerationCommandService implements GenerationCommandService { @@ -33,7 +37,9 @@ public void triggerGeneration(String userId, GenerationRequest request) { request.quizCount(), request.quizType(), request.uploadedUrl(), - request.customInstruction()); + request.customInstruction(), + request.pageNumbers(), + request.language().name()); // 자기정리: 롤백으로 problem_set INSERT를 되돌린다(트레이스엔 남고 DB엔 안 남음). TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/text/MarkdownNormalizer.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/text/MarkdownNormalizer.java new file mode 100644 index 00000000..bb879125 --- /dev/null +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/text/MarkdownNormalizer.java @@ -0,0 +1,166 @@ +package com.icc.qasker.quizmake.text; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 생성된 마크다운 본문의 결정론적 정규화. 유일한 복원 대상은 "GFM 표 구분행(`|---|`)이 인접한 헤더·본문 행과 한 줄로 글루된" 실패 모드로, 행 사이에 개행을 + * 재삽입해 렌더러가 표로 파싱하게 한다(재현 버그의 직접 원인). + * + *

설계 원칙: + * + *

    + *
  • 보수적: 구분행 시그니처가 없으면 완전 no-op → 서식 없는 본문 회귀 0(FR-006). 산문 속 리터럴 파이프(예: {@code P(A|B)}, + * {@code |x|})는 구분행이 없으므로 건드리지 않는다. + *
  • 멱등: 이미 개행으로 분리된 정상 표는 변형하지 않는다. 복원 결과를 다시 정규화해도 동일하다. + *
  • 무손실: 어떤 경우에도 내용을 버리지 않는다. 복원이 모호하면(헤더 셀 수 불일치 등) 원문을 그대로 반환한다. + *
+ * + *

구분행 자체가 소실된 표(구분행 없는 "파이프 수프")는 열·행 분할이 결정론적으로 모호해 복원 대상이 아니다 — 생성 프롬프트 서식 규약으로 예방한다(계약 §7.3-2 + * 경계). + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MarkdownNormalizer { + + /** + * GFM 표 구분행 셀: 선택적 정렬 콜론 + 하이픈 3개 이상. 예: {@code ---}, {@code :---}, {@code ---:}, {@code :---:} + */ + private static final Pattern SEPARATOR_CELL = Pattern.compile("^:?-{3,}:?$"); + + /** 표 열로 인정할 최소 구분행 셀 수. 단일 하이픈 셀(수평선 등)의 오탐을 피하기 위해 2 이상. */ + private static final int MIN_COLUMNS = 2; + + public static String normalize(String text) { + if (text == null || text.isBlank() || text.indexOf('|') < 0) { + return text; + } + String[] lines = text.split("\n", -1); + StringBuilder out = new StringBuilder(text.length() + 32); + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + out.append('\n'); + } + out.append(repairLine(lines[i])); + } + return out.toString(); + } + + /** + * 한 줄 안에 헤더·구분행·본문이 글루된 표를 개행 분리된 표로 복원한다. 복원 대상이 아니면 원문을 그대로 반환한다. + * + *

개행이 유실되면 원래 행 경계({@code |\n|})는 빈 셀({@code | |})로 남는다. 이 빈 셀을 행 경계 구분자로 삼아 셀을 행 세그먼트로 나눈 뒤, + * 구분행 세그먼트를 앵커로 표 구조를 재구성한다. + */ + private static String repairLine(String line) { + if (line.indexOf('|') < 0) { + return line; + } + + // 파이프 사이의 셀을 추출한다. parts[0]=첫 파이프 이전(표 밖 접두 텍스트), parts[last]=마지막 파이프 이후(표 밖 접미 텍스트). + String[] parts = line.split("\\|", -1); + if (parts.length < 3) { + return line; // 파이프가 2개 미만이면 표가 아니다. + } + String prefix = parts[0]; + String suffix = parts[parts.length - 1]; + + boolean hasSeparatorCell = false; + for (int i = 1; i < parts.length - 1; i++) { + if (SEPARATOR_CELL.matcher(parts[i].trim()).matches()) { + hasSeparatorCell = true; + break; + } + } + if (!hasSeparatorCell) { + return line; // 구분행 시그니처 없음 → 보수적으로 미변형(산문 속 리터럴 파이프 포함). + } + + // 빈 셀(행 경계)을 기준으로 셀을 행 세그먼트로 나눈다. + List> segments = new ArrayList<>(); + List current = new ArrayList<>(); + for (int i = 1; i < parts.length - 1; i++) { + String cell = parts[i].trim(); + if (cell.isEmpty()) { + if (!current.isEmpty()) { + segments.add(current); + current = new ArrayList<>(); + } + } else { + current.add(cell); + } + } + if (!current.isEmpty()) { + segments.add(current); + } + + // 구분행 세그먼트(모든 셀이 구분행 시그니처)를 찾는다 — 정확히 1개여야 한다. + int separatorIndex = -1; + int separatorCount = 0; + for (int i = 0; i < segments.size(); i++) { + if (isSeparatorSegment(segments.get(i))) { + separatorIndex = i; + separatorCount++; + } + } + if (separatorCount != 1) { + return line; // 구분행이 없거나 모호(복수) → 미변형. + } + if (segments.size() == 1) { + return line; // 구분행 세그먼트뿐(정상 단독 구분행 줄) → 멱등 no-op. + } + if (separatorIndex != 1) { + return line; // 헤더는 정확히 세그먼트 0이어야 한다(구분행은 세그먼트 1). + } + + int columns = segments.get(1).size(); + if (columns < MIN_COLUMNS) { + return line; // 1열 표는 오탐 위험 → 미변형. + } + if (segments.get(0).size() != columns) { + return line; // 헤더 셀 수 불일치 → 분할 모호 → 미변형(무손실). + } + // 본문 세그먼트: 마지막을 제외하고 모두 열 수와 일치해야 한다(마지막 잔여 행은 무손실 허용). + for (int i = 2; i < segments.size(); i++) { + int size = segments.get(i).size(); + boolean isLast = i == segments.size() - 1; + if (size != columns && !(isLast && size < columns)) { + return line; // 중간 행 셀 수 불일치 → 미변형(무손실). + } + } + + StringBuilder sb = new StringBuilder(); + if (!prefix.isBlank()) { + sb.append(prefix.strip()).append("\n\n"); + } + for (int i = 0; i < segments.size(); i++) { + if (i > 0) { + sb.append('\n'); + } + sb.append(renderRow(segments.get(i))); + } + if (!suffix.isBlank()) { + sb.append("\n\n").append(suffix.strip()); + } + return sb.toString(); + } + + private static boolean isSeparatorSegment(List segment) { + for (String cell : segment) { + if (!SEPARATOR_CELL.matcher(cell).matches()) { + return false; + } + } + return !segment.isEmpty(); + } + + private static String renderRow(List cells) { + StringBuilder sb = new StringBuilder("|"); + for (String cell : cells) { + sb.append(' ').append(cell).append(" |"); + } + return sb.toString(); + } +} diff --git a/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/adapter/MockAIServerAdapterTest.java b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/adapter/MockAIServerAdapterTest.java new file mode 100644 index 00000000..719c6f78 --- /dev/null +++ b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/adapter/MockAIServerAdapterTest.java @@ -0,0 +1,77 @@ +package com.icc.qasker.quizmake.adapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.icc.qasker.ai.QuizBatchSink; +import com.icc.qasker.ai.QuizOrchestrationService; +import com.icc.qasker.ai.dto.AIProblem; +import com.icc.qasker.ai.dto.GenerationRequestToAI; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MockAIServerAdapterTest { + + private final MockAIServerAdapter adapter = + new MockAIServerAdapter(mock(QuizOrchestrationService.class)); + + private List generate(String strategyValue, int quizCount) { + List collected = new ArrayList<>(); + QuizBatchSink sink = + problem -> { + collected.add(problem); + return collected.size(); + }; + adapter.streamRequest( + GenerationRequestToAI.builder() + .strategyValue(strategyValue) + .quizCount(quizCount) + .referencePages(List.of(1, 2)) + .sink(sink) + .build()); + return collected; + } + + @Test + @DisplayName("REAL_BLANK 전략은 정답 1선지(오답 없음) + 빈칸별 acceptedAnswers를 가진 목업을 낸다") + void real_blank_strategy_emits_real_blank_mocks() { + List problems = generate("REAL_BLANK", 5); + + assertThat(problems).hasSize(5); + assertThat(problems) + .allSatisfy( + p -> { + assertThat(p.selections()).hasSize(1); + assertThat(p.selections().get(0).correct()).isTrue(); + assertThat(p.selections().get(0).acceptedAnswers()).isNotEmpty(); + // index 0 = 모범답: 각 빈칸 인정 목록의 첫 원소가 대표정답의 해당 조각과 대응 + assertThat(p.selections().get(0).acceptedAnswers().get(0)).isNotEmpty(); + }); + } + + @Test + @DisplayName("REAL_BLANK 목업은 단일 빈칸과 다중 빈칸(≥2)을 모두 포함한다 (FR-008 렌더 검증용)") + void real_blank_mocks_include_single_and_multi_blank() { + List problems = generate("REAL_BLANK", 5); + + // 다중 빈칸: acceptedAnswers 바깥 배열 크기 ≥ 2 + assertThat(problems) + .anySatisfy( + p -> + assertThat(p.selections().get(0).acceptedAnswers()).hasSizeGreaterThanOrEqualTo(2)); + // 단일 빈칸: acceptedAnswers 바깥 배열 크기 == 1 + assertThat(problems) + .anySatisfy(p -> assertThat(p.selections().get(0).acceptedAnswers()).hasSize(1)); + } + + @Test + @DisplayName("선택형(MULTIPLE) 전략은 기존 4선지 목업을 그대로 낸다 (회귀 방지)") + void choice_strategy_keeps_four_selection_mocks() { + List problems = generate("MULTIPLE", 3); + + assertThat(problems).hasSize(3); + assertThat(problems).allSatisfy(p -> assertThat(p.selections()).hasSize(4)); + } +} diff --git a/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/mapper/AIProblemSetMapperTest.java b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/mapper/AIProblemSetMapperTest.java new file mode 100644 index 00000000..dc8c2a72 --- /dev/null +++ b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/mapper/AIProblemSetMapperTest.java @@ -0,0 +1,56 @@ +package com.icc.qasker.quizmake.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.icc.qasker.ai.dto.AIProblem; +import com.icc.qasker.ai.dto.AISelection; +import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** 저장 초크포인트에서 글루된 표 마크다운이 지문·선지·해설 전부에 대해 정규화되는지 검증한다(계약 §7.3-2). */ +class AIProblemSetMapperTest { + + @Test + @DisplayName("toQuiz: 지문·선지·해설의 글루된 표가 개행 분리된 GFM 표로 정규화된다") + void normalizes_glued_tables_across_fields() { + AIProblem problem = + new AIProblem( + "본문 | A | B | | --- | --- | | 1 | 2 |", + "Apply", + List.of( + new AISelection( + "선지 | X | Y | | --- | --- | | 9 | 8 |", + "해설 | P | Q | | --- | --- | | 5 | 6 |", + true)), + List.of(1), + null); + + QuizGeneratedFromAI quiz = AIProblemSetMapper.toQuiz(problem); + + assertThat(quiz.getTitle()).isEqualTo("본문\n\n| A | B |\n| --- | --- |\n| 1 | 2 |"); + assertThat(quiz.getSelections().get(0).getContent()) + .isEqualTo("선지\n\n| X | Y |\n| --- | --- |\n| 9 | 8 |"); + assertThat(quiz.getSelections().get(0).getExplanation()) + .isEqualTo("해설\n\n| P | Q |\n| --- | --- |\n| 5 | 6 |"); + } + + @Test + @DisplayName("toQuiz: 서식 없는 일반 지문은 그대로 유지된다(회귀 0)") + void keeps_plain_content_unchanged() { + AIProblem problem = + new AIProblem( + "다음 중 옳은 것은 무엇인가?", + "Apply", + List.of(new AISelection("보기 1", "해설 1", true)), + List.of(1), + null); + + QuizGeneratedFromAI quiz = AIProblemSetMapper.toQuiz(problem); + + assertThat(quiz.getTitle()).isEqualTo("다음 중 옳은 것은 무엇인가?"); + assertThat(quiz.getSelections().get(0).getContent()).isEqualTo("보기 1"); + assertThat(quiz.getSelections().get(0).getExplanation()).isEqualTo("해설 1"); + } +} diff --git a/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImplTest.java b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImplTest.java index 26d895f9..f35f3e01 100644 --- a/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImplTest.java +++ b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/service/generation/GenerationCommandServiceImplTest.java @@ -1,14 +1,17 @@ package com.icc.qasker.quizmake.service.generation; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.after; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -53,7 +56,8 @@ void setUp() { hashUtil = mock(HashUtil.class); resultRecorder = mock(GenerationResultRecorder.class); - when(quizCommandService.initProblemSet(any(), any(), any(), anyInt(), any(), any(), any())) + when(quizCommandService.initProblemSet( + any(), any(), any(), anyInt(), any(), any(), any(), any(), any())) .thenReturn(1L); service = @@ -76,12 +80,20 @@ void real_blank_request_persists_real_blank_to_db() { verify(quizCommandService, timeout(2000)) .initProblemSet( - eq("user-1"), any(), any(), anyInt(), eq(QuizType.REAL_BLANK), any(), any()); + eq("user-1"), + any(), + any(), + anyInt(), + eq(QuizType.REAL_BLANK), + any(), + any(), + any(), + any()); } @Test - @DisplayName("REAL_BLANK 요청은 AI 서버에 strategyValue=BLANK로 전달한다") - void real_blank_request_calls_ai_with_blank_strategy() { + @DisplayName("REAL_BLANK 요청은 AI 서버에 strategyValue=REAL_BLANK로 전달한다 (전용 전략)") + void real_blank_request_calls_ai_with_real_blank_strategy() { GenerationRequest request = request(QuizType.REAL_BLANK); service.triggerGeneration("user-1", request); @@ -89,7 +101,7 @@ void real_blank_request_calls_ai_with_blank_strategy() { ArgumentCaptor captor = ArgumentCaptor.forClass(GenerationRequestToAI.class); verify(aiServerAdapter, timeout(2000)).streamRequest(captor.capture()); - assertThat(captor.getValue().strategyValue()).isEqualTo("BLANK"); + assertThat(captor.getValue().strategyValue()).isEqualTo("REAL_BLANK"); } @Test @@ -217,6 +229,22 @@ void finalize_marks_failed_when_stream_throws() { verify(resultRecorder, timeout(2000)).recordError(eq(1L), eq(QuizType.MULTIPLE), any()); } + @Test + @DisplayName("멱등: 같은 sessionId 재-POST(중복 제약 위반)는 예외 없이 무시하고 새 생성을 시작하지 않는다") + void duplicate_session_repost_is_idempotent_noop() { + GenerationRequest request = request(QuizType.REAL_BLANK); + when(quizCommandService.initProblemSet( + any(), any(), any(), anyInt(), any(), any(), any(), any(), any())) + .thenThrow(new org.springframework.dao.DataIntegrityViolationException("dup sessionId")); + + // 예외를 밖으로 던지지 않는다(컨트롤러는 기존과 동일 202 유지). + assertThatCode(() -> service.triggerGeneration("user-1", request)).doesNotThrowAnyException(); + + // 진행 중 생성을 재기동하지 않는다: AI 스트림·상태 변경이 일어나지 않는다. + verify(aiServerAdapter, after(500).never()).streamRequest(any()); + verify(quizCommandService, never()).updateStatus(anyLong(), any()); + } + // ── helpers ──────────────────────────────────────────────── /** streamRequest 호출 시 sink에 deliverCount건의 문제를 순차 저장하도록 스텁한다(배치 인터리빙 Phase 1). */ diff --git a/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/text/MarkdownNormalizerTest.java b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/text/MarkdownNormalizerTest.java new file mode 100644 index 00000000..706bf9f4 --- /dev/null +++ b/modules/quiz-make/impl/src/test/java/com/icc/qasker/quizmake/text/MarkdownNormalizerTest.java @@ -0,0 +1,116 @@ +package com.icc.qasker.quizmake.text; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** 마크다운 정규화기 단위 테스트 — 계약 §7.3-2 경계(글루된 표 복원 / no-op 회귀 / 무손실)를 고정한다. */ +class MarkdownNormalizerTest { + + @Test + @DisplayName("글루된 표: 헤더/구분행/본문이 한 줄에 몰린 표를 개행 분리된 GFM 표로 복원한다") + void repairs_glued_table() { + String glued = "| 소기관 | 위치 | | :--- | :--- | | 미토콘드리아 | 동물 | | 엽록체 | 식물 |"; + + String result = MarkdownNormalizer.normalize(glued); + + assertThat(result) + .isEqualTo("| 소기관 | 위치 |\n" + "| :--- | :--- |\n" + "| 미토콘드리아 | 동물 |\n" + "| 엽록체 | 식물 |"); + } + + @Test + @DisplayName("글루된 표 앞의 산문 접두 텍스트는 표 위 문단으로 분리된다") + void repairs_glued_table_with_prose_prefix() { + String glued = "다음 표를 보자. | A | B | | --- | --- | | 1 | 2 |"; + + String result = MarkdownNormalizer.normalize(glued); + + assertThat(result).isEqualTo("다음 표를 보자.\n\n| A | B |\n| --- | --- |\n| 1 | 2 |"); + } + + @Test + @DisplayName("정상(개행 분리) 표는 변형하지 않는다") + void keeps_wellformed_table_unchanged() { + String table = "| A | B |\n| :--- | :--- |\n| 1 | 2 |\n| 3 | 4 |"; + + assertThat(MarkdownNormalizer.normalize(table)).isEqualTo(table); + } + + @Test + @DisplayName("멱등: 복원 결과를 다시 정규화해도 동일하다") + void is_idempotent() { + String glued = "| A | B | | --- | --- | | 1 | 2 |"; + + String once = MarkdownNormalizer.normalize(glued); + String twice = MarkdownNormalizer.normalize(once); + + assertThat(twice).isEqualTo(once); + } + + @Test + @DisplayName("산문 속 리터럴 파이프(P(A|B), |x|)는 구분행이 없으므로 건드리지 않는다") + void leaves_literal_pipes_in_prose_untouched() { + String prose = "조건부확률 P(A|B)와 절댓값 |x|는 표가 아니다."; + + assertThat(MarkdownNormalizer.normalize(prose)).isEqualTo(prose); + } + + @Test + @DisplayName("구분행이 소실된 파이프 나열은 복원 대상이 아니다(원문 유지, 무손실)") + void does_not_touch_pipe_soup_without_separator() { + String soup = "| a | b | | c | d | | e | f |"; + + assertThat(MarkdownNormalizer.normalize(soup)).isEqualTo(soup); + } + + @Test + @DisplayName("서식 없는 일반 텍스트는 완전 no-op이다(FR-006 회귀 0)") + void plain_text_is_noop() { + String plain = "이 문제는 특별한 서식이 없는 일반 지문이다. 파이프도 없다."; + + assertThat(MarkdownNormalizer.normalize(plain)).isEqualTo(plain); + } + + @Test + @DisplayName("null·빈 문자열은 그대로 반환한다") + void null_and_blank_passthrough() { + assertThat(MarkdownNormalizer.normalize(null)).isNull(); + assertThat(MarkdownNormalizer.normalize("")).isEqualTo(""); + assertThat(MarkdownNormalizer.normalize(" ")).isEqualTo(" "); + } + + @Test + @DisplayName("헤더 셀 수가 열 수와 불일치하면 모호하므로 복원하지 않는다(무손실)") + void bails_when_header_count_mismatches_columns() { + // 구분행은 2열인데 앞에 셀이 3개 → 헤더 경계 모호 → 원문 유지 + String ambiguous = "| x | y | z | | --- | --- | | 1 | 2 |"; + + assertThat(MarkdownNormalizer.normalize(ambiguous)).isEqualTo(ambiguous); + } + + @Test + @DisplayName("본문이 여러 줄 개행으로 이어진 표에서 구분행만 정상이면 전체가 no-op이다") + void multiline_table_all_lines_noop() { + String table = + "설명 문단입니다.\n\n" + + "| 항목 | 값 |\n" + + "| --- | --- |\n" + + "| 가 | 1 |\n" + + "| 나 | 2 |\n\n" + + "이어지는 설명."; + + assertThat(MarkdownNormalizer.normalize(table)).isEqualTo(table); + } + + @Test + @DisplayName("불완전한 마지막 본문 행(셀 부족)도 내용을 버리지 않고 복원한다") + void preserves_incomplete_trailing_row() { + String glued = "| A | B | | --- | --- | | 1 | 2 | | 3 |"; + + String result = MarkdownNormalizer.normalize(glued); + + assertThat(result).isEqualTo("| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 |"); + assertThat(result).contains("3"); // 마지막 잔여 셀 유실 없음 + } +} diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetService.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetService.java index 5eb115d6..553078dd 100644 --- a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetService.java +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetService.java @@ -3,11 +3,14 @@ import com.icc.qasker.quizset.dto.ferequest.ChangeTitleRequest; import com.icc.qasker.quizset.dto.feresponse.ChangeTitleResponse; import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse; +import com.icc.qasker.quizset.dto.feresponse.RegenerationConditionResponse; public interface ProblemSetService { ProblemSetResponse getProblemSet(String problemSetId); + RegenerationConditionResponse getRegenerationCondition(String problemSetId); + ChangeTitleResponse changeProblemSetTitle( String userId, String problemSetId, ChangeTitleRequest request); } diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/QuizCommandService.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/QuizCommandService.java index c270f5f8..99b2e44f 100644 --- a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/QuizCommandService.java +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/QuizCommandService.java @@ -13,7 +13,9 @@ Long initProblemSet( Integer totalQuizCount, QuizType quizType, String uploadUrl, - String customInstruction); + String customInstruction, + List pageNumbers, + String language); void updateStatus(Long problemSetId, GenerationStatus status); diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/airesponse/ProblemSetGeneratedEvent.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/airesponse/ProblemSetGeneratedEvent.java index 9ea5ed8a..88a85040 100644 --- a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/airesponse/ProblemSetGeneratedEvent.java +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/airesponse/ProblemSetGeneratedEvent.java @@ -29,6 +29,8 @@ public static class SelectionsOfAI { private String content; private String explanation; private boolean correct; + // REAL_BLANK 정답 선택지의 빈칸별 인정 표현 집합(서버 채점용). 타 유형·정보 없으면 null. + private List> acceptedAnswers; } } } diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/ferequest/GradeRequest.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/ferequest/GradeRequest.java new file mode 100644 index 00000000..65b1507a --- /dev/null +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/ferequest/GradeRequest.java @@ -0,0 +1,17 @@ +package com.icc.qasker.quizset.dto.ferequest; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * REAL_BLANK 무상태 채점 요청. 사용자가 제출한 raw 입력만 담고, 판정은 서버가 수행한다(채점 SSOT). 결과·해설 화면이 로그인 여부와 무관하게 이 엔드포인트를 + * 호출한다. + */ +public record GradeRequest( + @NotBlank(message = "problemSetId가 null입니다.") String problemSetId, + @NotNull(message = "answers가 null입니다.") List answers) { + + /** {@code textAnswer}는 다중 빈칸이면 U+001F로 결합된 직렬화 문자열, 단일 빈칸이면 raw 입력이다. */ + public record GradeAnswer(int number, String textAnswer) {} +} diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/ferequest/enums/QuizType.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/ferequest/enums/QuizType.java index a347db7c..16f09d82 100644 --- a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/ferequest/enums/QuizType.java +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/ferequest/enums/QuizType.java @@ -8,6 +8,6 @@ public enum QuizType { ESSAY; public String toAiStrategyName() { - return this == REAL_BLANK ? BLANK.name() : name(); + return name(); } } diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/feresponse/GradeResponse.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/feresponse/GradeResponse.java new file mode 100644 index 00000000..29be9238 --- /dev/null +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/feresponse/GradeResponse.java @@ -0,0 +1,18 @@ +package com.icc.qasker.quizset.dto.feresponse; + +import java.util.List; + +/** + * REAL_BLANK 무상태 채점 결과. 문항별 정오답·표시용 대표정답과 함께, 채점 후 각 빈칸의 허용 정답 목록을 노출한다(FR-006). 풀이 화면 응답엔 담기지 않으며 + * 이 채점 응답에만 실린다(FR-007). + */ +public record GradeResponse(List results) { + + /** + * {@code answer}는 표시용 대표정답(다중 빈칸은 저장 규약대로 ", " 결합된 content). {@code acceptedAnswers}는 빈칸별 허용 정답 + * 목록(바깥 배열=빈칸 등장 순서, index 0=canonical 모범답, 이하 통용 변형)이며, 인정 집합이 없는 과거 세트는 대표정답 content 기반 폴백이 + * 담긴다(FR-008·FR-009). + */ + public record GradeResult( + int number, boolean isCorrect, String answer, List> acceptedAnswers) {} +} diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/feresponse/RegenerationConditionResponse.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/feresponse/RegenerationConditionResponse.java new file mode 100644 index 00000000..47325ad0 --- /dev/null +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/feresponse/RegenerationConditionResponse.java @@ -0,0 +1,21 @@ +package com.icc.qasker.quizset.dto.feresponse; + +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import java.util.List; + +/** + * 세트의 생성 조건을 되돌려주는 응답(동일 재현·이어풀기용). 즉시생성 vs 옵션 화면 폴백의 판정은 프론트가 {@code documentAvailable && + * pageNumbers?.length && language}로 수행한다(서버에 reproducible 집계 플래그를 두지 않아 계약이 얇다). + * + *

{@code documentAvailable}은 원본 자료가 지금 유효한지의 서버 소유 판정 자리다. 자료 능동 만료검사는 이번 스코프에서 미도입(후속 과제)이라 현 + * 단계 항상 {@code true}. legacy 세트(pageNumbers·language 미저장)는 두 값이 null로 내려가 프론트 폴백으로 유도된다. + */ +public record RegenerationConditionResponse( + QuizType quizType, + Integer quizCount, + List pageNumbers, + String language, + String customInstruction, + String uploadedUrl, + String title, + boolean documentAvailable) {} diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/SelectionDetail.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/SelectionDetail.java index 3b4b4bb4..75d52911 100644 --- a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/SelectionDetail.java +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/SelectionDetail.java @@ -1,4 +1,14 @@ package com.icc.qasker.quizset.dto.readonly; -/** Selection Entity의 read-only DTO. 모듈 경계를 넘어 Selection 데이터를 전달할 때 사용. */ -public record SelectionDetail(String content, boolean correct) {} +import java.util.List; + +/** + * Selection Entity의 read-only DTO. 모듈 경계를 넘어 Selection 데이터를 전달할 때 사용. {@code acceptedAnswers}는 + * REAL_BLANK 서버 채점용 인정 집합(빈칸별)이며, 정보가 없으면 {@code null}이다. + */ +public record SelectionDetail(String content, boolean correct, List> acceptedAnswers) { + + public SelectionDetail(String content, boolean correct) { + this(content, correct, null); + } +} diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/grading/RealBlankGrader.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/grading/RealBlankGrader.java new file mode 100644 index 00000000..add7fc0e --- /dev/null +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/grading/RealBlankGrader.java @@ -0,0 +1,180 @@ +package com.icc.qasker.quizset.grading; + +import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * REAL_BLANK 채점의 단일 진실원천(SSOT). 정규화 + 폐쇄 집합 멤버십으로 결정적으로 판정한다(AI 호출 없음). + * + *

결과·해설·기록 세 화면과 로그인/비로그인이 모두 이 클래스를 경유해 동일 판정을 얻는다(FR-006). 애매한 답은 인정 집합 밖이므로 자동으로 오답이 + * 된다(FR-005, 보수적 기본). + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RealBlankGrader { + + /** 다중 빈칸 사용자 입력 직렬화 구분자(U+001F, Unit Separator). 프론트는 다중일 때만 이 구분자로 결합한다. */ + public static final String BLANK_DELIMITER = "\u001F"; + + /** 저장된 정답 content의 빈칸 구분자(생성 규약: "콤마(,)와 공백 한 칸"). 방어적으로 콤마 기준 분해 후 trim. */ + private static final String CONTENT_BLANK_DELIMITER = ","; + + /** + * 정규화 규칙(SSOT): NFKC(전각/반각·호환문자) → 소문자 → 공백·문장부호 제거. 표기 차이(FR-001)만 있는 답을 같은 문자열로 접는다. + * 동의어(FR-002)는 정규화가 아니라 인정 집합(acceptedAnswers)이 담당한다. + */ + public static String normalize(String s) { + if (s == null) { + return ""; + } + String normalized = Normalizer.normalize(s, Normalizer.Form.NFKC).toLowerCase(Locale.ROOT); + StringBuilder sb = new StringBuilder(normalized.length()); + normalized + .codePoints() + .forEach( + cp -> { + if (Character.isWhitespace(cp) || isPunctuation(cp)) { + return; + } + sb.appendCodePoint(cp); + }); + return sb.toString(); + } + + private static boolean isPunctuation(int codePoint) { + return switch (Character.getType(codePoint)) { + case Character.CONNECTOR_PUNCTUATION, + Character.DASH_PUNCTUATION, + Character.START_PUNCTUATION, + Character.END_PUNCTUATION, + Character.INITIAL_QUOTE_PUNCTUATION, + Character.FINAL_QUOTE_PUNCTUATION, + Character.OTHER_PUNCTUATION -> + true; + default -> false; + }; + } + + /** + * 빈칸별 독립 판정 후 전 빈칸 AND. 조각 수가 인정 집합의 빈칸 수와 다르면(빈 답 포함) 오답으로 처리한다(FR-005 보수적). + * + * @param inputsPerBlank 빈칸 등장 순서대로의 사용자 입력 조각 + * @param acceptedPerBlank 빈칸별 인정 표현 집합(바깥=빈칸 순서, 안=그 빈칸의 인정 표현들) + */ + public static boolean isCorrect( + List inputsPerBlank, List> acceptedPerBlank) { + if (inputsPerBlank == null || acceptedPerBlank == null) { + return false; + } + if (inputsPerBlank.size() != acceptedPerBlank.size() || acceptedPerBlank.isEmpty()) { + return false; + } + for (int i = 0; i < acceptedPerBlank.size(); i++) { + if (!matchesBlank(inputsPerBlank.get(i), acceptedPerBlank.get(i))) { + return false; + } + } + return true; + } + + private static boolean matchesBlank(String input, List accepted) { + if (accepted == null || accepted.isEmpty()) { + return false; + } + String normalizedInput = normalize(input); + if (normalizedInput.isEmpty()) { + return false; + } + for (String candidate : accepted) { + if (normalizedInput.equals(normalize(candidate))) { + return true; + } + } + return false; + } + + /** 직렬화된 사용자 입력(textAnswer)을 빈칸별 조각으로 분해한다. 단일 빈칸(구분자 없음)은 1조각. trailing 빈 조각 유지. */ + public static List splitInputs(String textAnswer) { + if (textAnswer == null) { + return List.of(); + } + return Arrays.asList(textAnswer.split(BLANK_DELIMITER, -1)); + } + + /** + * 인정 집합 정보가 없는 문항(구 문항 등)의 폴백: 정답 content를 빈칸별 단일 인정값으로 쓴다(현행 완전 일치 수준 보장, FR-009). content가 콤마 + * 결합이면 각 빈칸으로 분해한다. + */ + public static List> fallbackAccepted(String correctContent) { + if (correctContent == null || correctContent.isBlank()) { + return List.of(); + } + return Arrays.stream(correctContent.split(CONTENT_BLANK_DELIMITER, -1)) + .map(String::trim) + .>map(List::of) + .toList(); + } + + /** + * 문항 선택지 목록과 사용자 입력으로 판정한다. 정답 선택지(correct=true)의 인정 집합(없으면 content 기반 폴백)으로 채점하고, 표시용 대표정답(정답 + * content)을 함께 돌려준다. 결과·기록 양쪽이 이 한 메서드를 경유한다(SSOT). + */ + public static GradeOutcome grade(List selections, String textAnswer) { + SelectionDetail correct = + selections == null + ? null + : selections.stream().filter(SelectionDetail::correct).findFirst().orElse(null); + String answer = correct == null ? "" : correct.content(); + List> accepted = + canonicalize( + answer, + correct != null && correct.acceptedAnswers() != null + ? correct.acceptedAnswers() + : fallbackAccepted(answer)); + boolean isCorrect = isCorrect(splitInputs(textAnswer), accepted); + return new GradeOutcome(isCorrect, answer, accepted); + } + + /** + * 각 빈칸 인정 집합의 index 0을 canonical 모범답(대표정답의 그 빈칸)으로 고정한다(FR-006 노출 관례). 모범답이 집합에 없으면 앞에 넣고, 이미 + * 있으면(정규화 동일) 맨 앞으로 끌어올린다 — 모범답은 언제나 정답이어야 하므로 채점 멤버십도 함께 보장된다. 대표정답 빈칸 수보다 인정 집합이 길면 초과분은 원본 순서를 + * 유지한다. + */ + private static List> canonicalize(String answer, List> accepted) { + if (accepted == null || accepted.isEmpty()) { + return accepted; + } + String[] answerBlanks = (answer == null ? "" : answer).split(CONTENT_BLANK_DELIMITER, -1); + List> result = new ArrayList<>(accepted.size()); + for (int i = 0; i < accepted.size(); i++) { + List blank = accepted.get(i); + String canonical = i < answerBlanks.length ? answerBlanks[i].trim() : ""; + if (blank == null || canonical.isEmpty()) { + result.add(blank); + continue; + } + String normalizedCanonical = normalize(canonical); + List reordered = new ArrayList<>(blank.size() + 1); + reordered.add(canonical); + for (String variant : blank) { + if (variant != null && !normalize(variant).equals(normalizedCanonical)) { + reordered.add(variant); + } + } + result.add(List.copyOf(reordered)); + } + return List.copyOf(result); + } + + /** + * 채점 결과: 정오답 + 표시용 대표정답 + 빈칸별 인정 집합. {@code acceptedAnswers}는 채점에 실제로 쓰인 집합(없으면 content 기반 폴백)이며, + * 각 빈칸 배열의 index 0은 canonical 모범답이다(FR-006 노출용). + */ + public record GradeOutcome( + boolean isCorrect, String answer, List> acceptedAnswers) {} +} diff --git a/modules/quiz-set/api/src/test/java/com/icc/qasker/quizset/dto/ferequest/enums/QuizTypeTest.java b/modules/quiz-set/api/src/test/java/com/icc/qasker/quizset/dto/ferequest/enums/QuizTypeTest.java index a4a89b9f..89af8a3e 100644 --- a/modules/quiz-set/api/src/test/java/com/icc/qasker/quizset/dto/ferequest/enums/QuizTypeTest.java +++ b/modules/quiz-set/api/src/test/java/com/icc/qasker/quizset/dto/ferequest/enums/QuizTypeTest.java @@ -8,9 +8,9 @@ class QuizTypeTest { @Test - @DisplayName("REAL_BLANK는 AI 전략 이름을 BLANK로 매핑한다") - void real_blank_maps_to_blank() { - assertThat(QuizType.REAL_BLANK.toAiStrategyName()).isEqualTo("BLANK"); + @DisplayName("REAL_BLANK는 전용 전략 이름 REAL_BLANK를 반환한다") + void real_blank_uses_own_strategy() { + assertThat(QuizType.REAL_BLANK.toAiStrategyName()).isEqualTo("REAL_BLANK"); } @Test diff --git a/modules/quiz-set/api/src/test/java/com/icc/qasker/quizset/grading/RealBlankGraderTest.java b/modules/quiz-set/api/src/test/java/com/icc/qasker/quizset/grading/RealBlankGraderTest.java new file mode 100644 index 00000000..94d41ce6 --- /dev/null +++ b/modules/quiz-set/api/src/test/java/com/icc/qasker/quizset/grading/RealBlankGraderTest.java @@ -0,0 +1,227 @@ +package com.icc.qasker.quizset.grading; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class RealBlankGraderTest { + + @Nested + @DisplayName("normalize — 표기 차이만 있는 답을 같은 문자열로 접는다 (FR-001)") + class Normalize { + + @Test + @DisplayName("공백·문장부호·대소문자·전각/반각 차이를 제거한다") + void folds_notation_differences() { + String base = RealBlankGrader.normalize("운동량"); + assertThat(RealBlankGrader.normalize(" 운동량 ")).isEqualTo(base); + assertThat(RealBlankGrader.normalize("운동 량")).isEqualTo(base); + assertThat(RealBlankGrader.normalize("운동량!")).isEqualTo(base); + } + + @Test + @DisplayName("영문 대소문자와 전각 영숫자를 접는다") + void folds_case_and_fullwidth() { + assertThat(RealBlankGrader.normalize("TCP")).isEqualTo(RealBlankGrader.normalize("tcp")); + // 전각 영문 "TCP" → NFKC → "TCP" → 소문자 + assertThat(RealBlankGrader.normalize("TCP")).isEqualTo(RealBlankGrader.normalize("tcp")); + } + + @Test + @DisplayName("null·빈 입력은 빈 문자열로 정규화된다") + void null_becomes_empty() { + assertThat(RealBlankGrader.normalize(null)).isEmpty(); + assertThat(RealBlankGrader.normalize(" ")).isEmpty(); + } + } + + @Nested + @DisplayName("isCorrect — 단일 빈칸") + class SingleBlank { + + private final List> accepted = List.of(List.of("미토콘드리아", "mitochondria")); + + @Test + @DisplayName("표기 차이가 있어도 인정 집합에 있으면 정답 (FR-001)") + void notation_variant_is_correct() { + assertThat(RealBlankGrader.isCorrect(List.of(" 미토콘드리아 "), accepted)).isTrue(); + } + + @Test + @DisplayName("동의어(영↔한 등)가 인정 집합에 있으면 정답 (FR-002)") + void synonym_is_correct() { + assertThat(RealBlankGrader.isCorrect(List.of("Mitochondria"), accepted)).isTrue(); + } + + @Test + @DisplayName("인정 집합 밖(오탈자·인접 개념)은 오답 (FR-003·FR-005)") + void typo_and_adjacent_are_wrong() { + assertThat(RealBlankGrader.isCorrect(List.of("미토콘트리아"), accepted)).isFalse(); + assertThat(RealBlankGrader.isCorrect(List.of("엽록체"), accepted)).isFalse(); + } + + @Test + @DisplayName("빈 답·공백만은 오답") + void empty_is_wrong() { + assertThat(RealBlankGrader.isCorrect(List.of(""), accepted)).isFalse(); + assertThat(RealBlankGrader.isCorrect(List.of(" "), accepted)).isFalse(); + } + } + + @Nested + @DisplayName("isCorrect — 다중 빈칸 (전 빈칸 AND)") + class MultiBlank { + + private final List> accepted = + List.of(List.of("감수분열", "meiosis"), List.of("체세포분열", "mitosis")); + + @Test + @DisplayName("모든 빈칸이 인정 집합에 있으면 정답") + void all_blanks_correct() { + assertThat(RealBlankGrader.isCorrect(List.of("감수분열", "체세포분열"), accepted)).isTrue(); + assertThat(RealBlankGrader.isCorrect(List.of("meiosis", "mitosis"), accepted)).isTrue(); + } + + @Test + @DisplayName("한 빈칸이라도 틀리면 오답") + void one_wrong_blank_fails() { + assertThat(RealBlankGrader.isCorrect(List.of("감수분열", "무사분열"), accepted)).isFalse(); + } + + @Test + @DisplayName("빈칸 순서가 바뀌면 오답 (위치 역전)") + void swapped_order_fails() { + assertThat(RealBlankGrader.isCorrect(List.of("체세포분열", "감수분열"), accepted)).isFalse(); + } + + @Test + @DisplayName("조각 수가 빈칸 수와 다르면 오답") + void arity_mismatch_fails() { + assertThat(RealBlankGrader.isCorrect(List.of("감수분열"), accepted)).isFalse(); + assertThat(RealBlankGrader.isCorrect(List.of("감수분열", "체세포분열", "이분법"), accepted)).isFalse(); + } + } + + @Nested + @DisplayName("splitInputs — 직렬화된 입력 분해") + class SplitInputs { + + @Test + @DisplayName("단일 빈칸(구분자 없음)은 1조각") + void single_blank() { + assertThat(RealBlankGrader.splitInputs("운동량")).containsExactly("운동량"); + } + + @Test + @DisplayName("U+001F 구분자로 다중 빈칸을 분해하고 trailing 빈 조각을 유지한다") + void multi_blank_keeps_trailing_empty() { + String d = RealBlankGrader.BLANK_DELIMITER; + assertThat(RealBlankGrader.splitInputs("감수분열" + d + "체세포분열")) + .containsExactly("감수분열", "체세포분열"); + assertThat(RealBlankGrader.splitInputs("감수분열" + d)).containsExactly("감수분열", ""); + } + } + + @Nested + @DisplayName("fallbackAccepted — 인정 집합 없는 구 문항 폴백 (FR-009)") + class Fallback { + + @Test + @DisplayName("단일 정답 content는 1빈칸 단일 인정값으로") + void single() { + List> accepted = RealBlankGrader.fallbackAccepted("운동량"); + assertThat(RealBlankGrader.isCorrect(List.of("운동량"), accepted)).isTrue(); + assertThat(RealBlankGrader.isCorrect(List.of("운동에너지"), accepted)).isFalse(); + } + + @Test + @DisplayName("콤마 결합 content는 빈칸별로 분해된다") + void multi() { + List> accepted = RealBlankGrader.fallbackAccepted("감수분열, 체세포분열"); + assertThat(RealBlankGrader.isCorrect(List.of("감수분열", "체세포분열"), accepted)).isTrue(); + } + + @Test + @DisplayName("표기 차이는 폴백에서도 정규화로 인정된다(현행 완전 일치의 상위 호환)") + void fallback_still_normalizes() { + List> accepted = RealBlankGrader.fallbackAccepted("운동량"); + assertThat(RealBlankGrader.isCorrect(List.of(" 운동량 "), accepted)).isTrue(); + } + } + + @Nested + @DisplayName("grade — 인정 집합 노출 + index 0 = canonical 모범답 (FR-006·US3)") + class GradeExposure { + + private static SelectionDetail correct(String content, List> accepted) { + return new SelectionDetail(content, true, accepted); + } + + @Test + @DisplayName("정답 판정과 함께 채점에 쓰인 인정 집합을 그대로 돌려준다") + void grade_exposes_accepted_answers() { + RealBlankGrader.GradeOutcome outcome = + RealBlankGrader.grade( + List.of(correct("미토콘드리아", List.of(List.of("미토콘드리아", "mitochondria")))), + "mitochondria"); + + assertThat(outcome.isCorrect()).isTrue(); + assertThat(outcome.answer()).isEqualTo("미토콘드리아"); + assertThat(outcome.acceptedAnswers()).containsExactly(List.of("미토콘드리아", "mitochondria")); + } + + @Test + @DisplayName("모범답안이 인정 집합 맨 앞이 아니면 index 0으로 끌어올린다") + void grade_hoists_canonical_to_index0() { + RealBlankGrader.GradeOutcome outcome = + RealBlankGrader.grade( + List.of(correct("운영체제", List.of(List.of("OS", "operating system", "운영체제")))), "OS"); + + assertThat(outcome.acceptedAnswers().get(0).get(0)).isEqualTo("운영체제"); + assertThat(outcome.acceptedAnswers().get(0)) + .containsExactly("운영체제", "OS", "operating system"); + } + + @Test + @DisplayName("모범답안이 인정 집합에 빠져 있으면 index 0에 보강하고 채점 멤버십도 보장된다") + void grade_inserts_missing_canonical() { + RealBlankGrader.GradeOutcome outcome = + RealBlankGrader.grade( + List.of(correct("미토콘드리아", List.of(List.of("mitochondria")))), "미토콘드리아"); + + assertThat(outcome.acceptedAnswers().get(0)).containsExactly("미토콘드리아", "mitochondria"); + assertThat(outcome.isCorrect()).isTrue(); // 모범답 타이핑이 정답으로 인정됨 + } + + @Test + @DisplayName("다중 빈칸: 각 빈칸 index 0이 그 빈칸의 모범답으로 고정된다") + void grade_multi_blank_canonical_per_blank() { + RealBlankGrader.GradeOutcome outcome = + RealBlankGrader.grade( + List.of( + correct( + "감수분열, 체세포분열", + List.of(List.of("meiosis", "감수분열"), List.of("유사분열", "mitosis", "체세포분열")))), + "감수분열" + RealBlankGrader.BLANK_DELIMITER + "체세포분열"); + + assertThat(outcome.acceptedAnswers()).hasSize(2); + assertThat(outcome.acceptedAnswers().get(0).get(0)).isEqualTo("감수분열"); + assertThat(outcome.acceptedAnswers().get(1).get(0)).isEqualTo("체세포분열"); + assertThat(outcome.isCorrect()).isTrue(); + } + + @Test + @DisplayName("legacy(acceptedAnswers null): content 기반 폴백을 index 0으로 노출한다 (FR-009)") + void grade_legacy_fallback_exposed() { + RealBlankGrader.GradeOutcome outcome = + RealBlankGrader.grade(List.of(new SelectionDetail("광합성", true)), "광합성"); + + assertThat(outcome.acceptedAnswers()).containsExactly(List.of("광합성")); + assertThat(outcome.isCorrect()).isTrue(); + } + } +} diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/controller/GradeController.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/controller/GradeController.java new file mode 100644 index 00000000..bb15a8a9 --- /dev/null +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/controller/GradeController.java @@ -0,0 +1,34 @@ +package com.icc.qasker.quizset.controller; + +import com.icc.qasker.global.annotation.RateLimit; +import com.icc.qasker.global.ratelimit.RateLimitTier; +import com.icc.qasker.quizset.dto.ferequest.GradeRequest; +import com.icc.qasker.quizset.dto.feresponse.GradeResponse; +import com.icc.qasker.quizset.service.grade.GradeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * REAL_BLANK 무상태 채점 API. 공개(인증 불필요)이며 저장하지 않는다 — 결과·해설 화면이 로그인 여부와 무관하게 동일 판정을 얻는 단일 채점 + * 경로다(FR-006). + */ +@Tag(name = "Grade", description = "REAL_BLANK 무상태 채점 API") +@RestController +@RequiredArgsConstructor +public class GradeController { + + private final GradeService gradeService; + + @Operation(summary = "REAL_BLANK 답안을 채점한다 (무상태·공개)") + @RateLimit(RateLimitTier.READ) + @PostMapping("/grade") + public ResponseEntity grade(@Valid @RequestBody GradeRequest request) { + return ResponseEntity.ok(gradeService.grade(request)); + } +} diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/controller/ProblemSetQueryController.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/controller/ProblemSetQueryController.java index 2fe3ec3e..ee1908dc 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/controller/ProblemSetQueryController.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/controller/ProblemSetQueryController.java @@ -2,6 +2,7 @@ import com.icc.qasker.quizset.ProblemSetService; import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse; +import com.icc.qasker.quizset.dto.feresponse.RegenerationConditionResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -24,4 +25,11 @@ public class ProblemSetQueryController { public ResponseEntity getProblemSet(@PathVariable("id") String problemSetId) { return ResponseEntity.ok(problemSetService.getProblemSet(problemSetId)); } + + @Operation(summary = "세트의 생성 조건을 되돌려준다(동일 재현·이어풀기용)") + @GetMapping("/{id}/regeneration-condition") + public ResponseEntity getRegenerationCondition( + @PathVariable("id") String problemSetId) { + return ResponseEntity.ok(problemSetService.getRegenerationCondition(problemSetId)); + } } diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/ProblemSet.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/ProblemSet.java index e8ee5947..e419cd1b 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/ProblemSet.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/ProblemSet.java @@ -2,8 +2,10 @@ import com.icc.qasker.global.entity.CreatedAt; import com.icc.qasker.quizset.GenerationStatus; +import com.icc.qasker.quizset.converter.IntegerListConverter; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -62,6 +64,16 @@ public class ProblemSet extends CreatedAt { @Column(columnDefinition = "TEXT") private String customInstruction; + // 동일 재현(이어풀기)용 생성 조건 — 생성 시점에만 알 수 있어 세트에 함께 저장한다(일반 조회 응답엔 없어 복원 불가). + // V18 이전 legacy 세트는 컬럼 NULL. IntegerListConverter는 NULL을 빈 리스트로 읽으므로 조회 매핑에서 빈 리스트를 null로 정규화한다. + @Convert(converter = IntegerListConverter.class) + @Column(columnDefinition = "TEXT") + private List pageNumbers; + + // 요청 Language enum(quiz-make 모듈)을 이 모듈이 의존하지 않아 문자열("KO"/"EN")로 저장한다. + @Column(length = 8) + private String language; + // 이하 헬퍼 함수 public void updateStatus(GenerationStatus status) { if (status == null) { diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/Selection.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/Selection.java index fe4e0609..14ee7456 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/Selection.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/Selection.java @@ -1,3 +1,15 @@ package com.icc.qasker.quizset.entity; -public record Selection(String content, String explanation, boolean correct) {} +import java.util.List; + +/** + * 선택지. REAL_BLANK 정답 선택지는 {@code acceptedAnswers}에 빈칸별 인정 표현 집합을 담는다(서버 채점용). 이 정보가 없는 문항(구 문항·타 + * 유형)은 {@code null}이며, 채점 시 정답 content 기반 폴백으로 동작한다. + */ +public record Selection( + String content, String explanation, boolean correct, List> acceptedAnswers) { + + public Selection(String content, String explanation, boolean correct) { + this(content, explanation, correct, null); + } +} diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemMapper.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemMapper.java index debf1c46..023479af 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemMapper.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemMapper.java @@ -30,7 +30,13 @@ public Problem fromResponse(QuizGeneratedFromAI quizDto, ProblemSet problemSet) quizDto.getSelections() == null ? new ArrayList<>() : quizDto.getSelections().stream() - .map(s -> new Selection(s.getContent(), s.getExplanation(), s.isCorrect())) + .map( + s -> + new Selection( + s.getContent(), + s.getExplanation(), + s.isCorrect(), + s.getAcceptedAnswers())) .collect(toList()); List referencedPages = diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemSetResponseMapper.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemSetResponseMapper.java index f55d4395..89da26a5 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemSetResponseMapper.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemSetResponseMapper.java @@ -1,6 +1,7 @@ package com.icc.qasker.quizset.mapper; import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse; import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse.QuizForFe; import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse.QuizForFe.SelectionForFE; @@ -18,12 +19,22 @@ public final class ProblemSetResponseMapper { private final HashUtil hashUtil; public QuizForFe fromEntity(Problem problem) { + return toQuiz(problem, false); + } + + /** + * REAL_BLANK 풀이 화면은 입력창만 필요하므로 선택지를 노출하지 않는다(정답·인정집합 유출 방지, 채점은 서버 SSOT). {@code hideSelections}면 + * selections를 빈 배열로 투영한다. + */ + private QuizForFe toQuiz(Problem problem, boolean hideSelections) { // 풀이 응답 경량화: 문항 해설·선지별 해설은 FE 미사용(실증)이므로 값을 비운다. // getExplanationContent()를 호출하지 않아 lazy 컬럼 초기화(N+1)를 유발하지 않는다 — Q6 lazy의 성립 조건. List selections = - QuizMappingSupport.mapSelections( - problem.getSelections(), - (id, sel) -> new SelectionForFE(id, sel.content(), null, sel.correct())); + hideSelections + ? List.of() + : QuizMappingSupport.mapSelections( + problem.getSelections(), + (id, sel) -> new SelectionForFE(id, sel.content(), null, sel.correct())); return new QuizForFe( problem.getId().getNumber(), @@ -41,7 +52,8 @@ public ProblemSetResponse fromEntity(ProblemSet problemSet) { /** 세트 메타데이터는 problemSet에서, 문항 목록은 전달받은 problems에서 조립한다. */ public ProblemSetResponse toResponse(ProblemSet problemSet, List problems) { - List quizzes = problems.stream().map(this::fromEntity).toList(); + boolean hideSelections = problemSet.getQuizType() == QuizType.REAL_BLANK; + List quizzes = problems.stream().map(p -> toQuiz(p, hideSelections)).toList(); return new ProblemSetResponse( problemSet.getSessionId(), diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/generation/support/QuizCommandServiceImpl.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/generation/support/QuizCommandServiceImpl.java index 139f6480..f32fcec0 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/generation/support/QuizCommandServiceImpl.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/generation/support/QuizCommandServiceImpl.java @@ -33,7 +33,9 @@ public Long initProblemSet( Integer totalQuizCount, QuizType quizType, String uploadUrl, - String customInstruction) { + String customInstruction, + List pageNumbers, + String language) { ProblemSet problemSet = ProblemSet.builder() .sessionId(sessionId) @@ -43,6 +45,8 @@ public Long initProblemSet( .quizType(quizType) .fileUrl(uploadUrl) .customInstruction(customInstruction) + .pageNumbers(pageNumbers) + .language(language) .build(); ProblemSet saved = problemSetRepository.save(problemSet); return saved.getId(); diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/grade/GradeService.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/grade/GradeService.java new file mode 100644 index 00000000..b2580ff9 --- /dev/null +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/grade/GradeService.java @@ -0,0 +1,60 @@ +package com.icc.qasker.quizset.service.grade; + +import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; +import com.icc.qasker.quizset.ProblemSetReadService; +import com.icc.qasker.quizset.dto.ferequest.GradeRequest; +import com.icc.qasker.quizset.dto.ferequest.GradeRequest.GradeAnswer; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.feresponse.GradeResponse; +import com.icc.qasker.quizset.dto.feresponse.GradeResponse.GradeResult; +import com.icc.qasker.quizset.dto.readonly.ProblemDetail; +import com.icc.qasker.quizset.dto.readonly.ProblemSetSummary; +import com.icc.qasker.quizset.grading.RealBlankGrader; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * REAL_BLANK 무상태 채점 서비스(SSOT). {@link RealBlankGrader}로 결정적으로 판정한다. 저장하지 않으며 로그인 여부와 무관하게 동작한다. + * REAL_BLANK 세트만 처리해 타 유형 채점을 건드리지 않는다(FR-007). + */ +@Service +@RequiredArgsConstructor +public class GradeService { + + private final ProblemSetReadService problemSetReadService; + private final HashUtil hashUtil; + + public GradeResponse grade(GradeRequest request) { + Long problemSetId = hashUtil.decode(request.problemSetId()); + ProblemSetSummary summary = + problemSetReadService + .findProblemSetById(problemSetId) + .orElseThrow(() -> new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND)); + if (summary.quizType() != QuizType.REAL_BLANK) { + throw new CustomException(ExceptionMessage.GRADE_UNSUPPORTED_QUIZ_TYPE); + } + + Map inputByNumber = + request.answers().stream() + .collect( + Collectors.toMap( + GradeAnswer::number, a -> a.textAnswer() == null ? "" : a.textAnswer())); + + List results = + problemSetReadService.findProblemsByProblemSetId(problemSetId).stream() + .map(problem -> gradeProblem(problem, inputByNumber.get(problem.number()))) + .toList(); + return new GradeResponse(results); + } + + private GradeResult gradeProblem(ProblemDetail problem, String textAnswer) { + RealBlankGrader.GradeOutcome outcome = RealBlankGrader.grade(problem.selections(), textAnswer); + return new GradeResult( + problem.number(), outcome.isCorrect(), outcome.answer(), outcome.acceptedAnswers()); + } +} diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/mock/MockProblemSetService.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/mock/MockProblemSetService.java index 9a32657e..66ebb7f6 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/mock/MockProblemSetService.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/mock/MockProblemSetService.java @@ -4,6 +4,7 @@ import com.icc.qasker.quizset.dto.ferequest.ChangeTitleRequest; import com.icc.qasker.quizset.dto.feresponse.ChangeTitleResponse; import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse; +import com.icc.qasker.quizset.dto.feresponse.RegenerationConditionResponse; import com.icc.qasker.quizset.entity.ProblemSet; import com.icc.qasker.quizset.repository.ProblemSetRepository; import com.icc.qasker.quizset.service.query.ProblemSetServiceImpl; @@ -34,6 +35,11 @@ public ProblemSetResponse getProblemSet(String problemSetId) { return real.getProblemSet(problemSetId); } + @Override + public RegenerationConditionResponse getRegenerationCondition(String problemSetId) { + return real.getRegenerationCondition(problemSetId); + } + @Override @Transactional public ChangeTitleResponse changeProblemSetTitle( diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetReadServiceImpl.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetReadServiceImpl.java index 9a93c987..16c3fc8e 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetReadServiceImpl.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetReadServiceImpl.java @@ -48,7 +48,9 @@ private ProblemSetSummary toSummary(ProblemSet ps) { private ProblemDetail toDetail(Problem p) { List selections = - p.getSelections().stream().map(s -> new SelectionDetail(s.content(), s.correct())).toList(); + p.getSelections().stream() + .map(s -> new SelectionDetail(s.content(), s.correct(), s.acceptedAnswers())) + .toList(); return new ProblemDetail( p.getId().getNumber(), p.getTitle(), selections, p.getExplanationContent()); } diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImpl.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImpl.java index 7f8ddbb1..ebf31e4c 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImpl.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImpl.java @@ -7,9 +7,11 @@ import com.icc.qasker.quizset.dto.ferequest.ChangeTitleRequest; import com.icc.qasker.quizset.dto.feresponse.ChangeTitleResponse; import com.icc.qasker.quizset.dto.feresponse.ProblemSetResponse; +import com.icc.qasker.quizset.dto.feresponse.RegenerationConditionResponse; import com.icc.qasker.quizset.entity.ProblemSet; import com.icc.qasker.quizset.mapper.ProblemSetResponseMapper; import com.icc.qasker.quizset.repository.ProblemSetRepository; +import java.util.List; import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +33,27 @@ public ProblemSetResponse getProblemSet(String problemSetId) { return problemSetResponseMapper.fromEntity(problemSet); } + @Override + public RegenerationConditionResponse getRegenerationCondition(String problemSetId) { + Assert.hasText(problemSetId, "problemSetId must not be blank"); + ProblemSet ps = getProblemSetEntityByEncoded(problemSetId); + // 자료 능동 만료검사는 이번 스코프 미도입 → documentAvailable 항상 true(후속에 실 판정으로 대체). + return new RegenerationConditionResponse( + ps.getQuizType(), + ps.getTotalQuizCount(), + emptyToNull(ps.getPageNumbers()), + ps.getLanguage(), + ps.getCustomInstruction(), + ps.getFileUrl(), + ps.getTitle(), + true); + } + + // legacy 세트는 컬럼 NULL이 IntegerListConverter를 거쳐 빈 리스트로 읽힌다. 계약대로 null로 정규화해 프론트 폴백 판정을 명확히 한다. + private static List emptyToNull(List pageNumbers) { + return (pageNumbers == null || pageNumbers.isEmpty()) ? null : pageNumbers; + } + @Override @Transactional public ChangeTitleResponse changeProblemSetTitle( diff --git a/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/service/grade/GradeServiceTest.java b/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/service/grade/GradeServiceTest.java new file mode 100644 index 00000000..dcd796f2 --- /dev/null +++ b/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/service/grade/GradeServiceTest.java @@ -0,0 +1,131 @@ +package com.icc.qasker.quizset.service.grade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.quizset.ProblemSetReadService; +import com.icc.qasker.quizset.dto.ferequest.GradeRequest; +import com.icc.qasker.quizset.dto.ferequest.GradeRequest.GradeAnswer; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.feresponse.GradeResponse; +import com.icc.qasker.quizset.dto.feresponse.GradeResponse.GradeResult; +import com.icc.qasker.quizset.dto.readonly.ProblemDetail; +import com.icc.qasker.quizset.dto.readonly.ProblemSetSummary; +import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GradeServiceTest { + + @Mock private ProblemSetReadService problemSetReadService; + @Mock private HashUtil hashUtil; + @InjectMocks private GradeService gradeService; + + private static final long SET_ID = 42L; + + @BeforeEach + void setUp() { + lenient().when(hashUtil.decode("enc")).thenReturn(SET_ID); + } + + private void givenSet(QuizType type, List problems) { + when(problemSetReadService.findProblemSetById(SET_ID)) + .thenReturn( + java.util.Optional.of( + new ProblemSetSummary(SET_ID, type, problems.size(), "t", Instant.EPOCH))); + lenient().when(problemSetReadService.findProblemsByProblemSetId(SET_ID)).thenReturn(problems); + } + + private ProblemDetail realBlankProblem(int number, String answer, List> accepted) { + return new ProblemDetail( + number, "q" + number, List.of(new SelectionDetail(answer, true, accepted)), null); + } + + private GradeResponse grade(int number, String textAnswer) { + return gradeService.grade( + new GradeRequest("enc", List.of(new GradeAnswer(number, textAnswer)))); + } + + @Test + @DisplayName("동의어(영↔한)를 정답으로 인정하고 대표정답을 함께 준다 (FR-002)") + void synonym_accepted() { + givenSet( + QuizType.REAL_BLANK, + List.of(realBlankProblem(1, "미토콘드리아", List.of(List.of("미토콘드리아", "mitochondria"))))); + + GradeResult result = grade(1, "mitochondria").results().getFirst(); + + assertThat(result.isCorrect()).isTrue(); + assertThat(result.answer()).isEqualTo("미토콘드리아"); + } + + @Test + @DisplayName("표기 차이만 있는 답을 정답으로 인정한다 (FR-001)") + void notation_variant_accepted() { + givenSet(QuizType.REAL_BLANK, List.of(realBlankProblem(1, "운동량", List.of(List.of("운동량"))))); + + assertThat(grade(1, " 운동량!").results().getFirst().isCorrect()).isTrue(); + } + + @Test + @DisplayName("인정 범위 밖(인접 개념)은 오답 (FR-005)") + void adjacent_concept_wrong() { + givenSet( + QuizType.REAL_BLANK, + List.of(realBlankProblem(1, "미토콘드리아", List.of(List.of("미토콘드리아", "mitochondria"))))); + + assertThat(grade(1, "엽록체").results().getFirst().isCorrect()).isFalse(); + } + + @Test + @DisplayName("인정 범위 정보가 없는 문항은 정답 content 완전 일치(정규화)로 폴백한다 (FR-009)") + void fallback_to_exact_match() { + givenSet(QuizType.REAL_BLANK, List.of(realBlankProblem(1, "운동량", null))); + + assertThat(grade(1, " 운동량 ").results().getFirst().isCorrect()).isTrue(); + assertThat(grade(1, "운동에너지").results().getFirst().isCorrect()).isFalse(); + } + + @Test + @DisplayName("REAL_BLANK가 아닌 세트는 채점을 거부한다 (FR-007 격리)") + void non_real_blank_rejected() { + givenSet(QuizType.MULTIPLE, List.of()); + + assertThatThrownBy(() -> grade(1, "x")).isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("응답에 빈칸별 허용 정답 목록을 실어 준다 — index 0 = 모범답 (FR-006·FR-008·US3)") + void response_carries_accepted_answers_with_canonical_first() { + givenSet( + QuizType.REAL_BLANK, + List.of(realBlankProblem(1, "운영체제", List.of(List.of("OS", "operating system", "운영체제"))))); + + GradeResult result = grade(1, "OS").results().getFirst(); + + assertThat(result.acceptedAnswers()).hasSize(1); + assertThat(result.acceptedAnswers().get(0)).containsExactly("운영체제", "OS", "operating system"); + } + + @Test + @DisplayName("legacy 문항(인정범위 null)도 응답에 대표정답 폴백 목록을 실어 준다 (FR-009)") + void response_carries_fallback_for_legacy() { + givenSet(QuizType.REAL_BLANK, List.of(realBlankProblem(1, "운동량", null))); + + GradeResult result = grade(1, "운동량").results().getFirst(); + + assertThat(result.acceptedAnswers()).containsExactly(List.of("운동량")); + } +} diff --git a/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImplTest.java b/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImplTest.java index ccab7a82..2f7fdeca 100644 --- a/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImplTest.java +++ b/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImplTest.java @@ -12,6 +12,7 @@ import com.icc.qasker.quizset.dto.ferequest.ChangeTitleRequest; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import com.icc.qasker.quizset.dto.feresponse.ChangeTitleResponse; +import com.icc.qasker.quizset.dto.feresponse.RegenerationConditionResponse; import com.icc.qasker.quizset.entity.ProblemSet; import com.icc.qasker.quizset.mapper.ProblemSetResponseMapper; import com.icc.qasker.quizset.repository.ProblemSetRepository; @@ -78,4 +79,73 @@ void throws_when_set_user_null() { .isInstanceOf(CustomException.class) .hasMessage(ExceptionMessage.NOT_ENOUGH_ACCESS.getMessage()); } + + @Test + @DisplayName("조건이 온전한 세트는 저장 조건을 그대로 되돌려주고 documentAvailable=true (effective quizType 유지)") + void returns_regeneration_condition_for_complete_set() { + ProblemSet set = + ProblemSet.builder() + .id(1L) + .sessionId("sess") + .title("이산수학 3장") + .generationStatus(GenerationStatus.COMPLETED) + .quizType(QuizType.REAL_BLANK) + .totalQuizCount(10) + .userId("user-1") + .fileUrl("https://cdn.example/doc.pdf") + .customInstruction("난이도 높게") + .pageNumbers(List.of(1, 2, 3)) + .language("KO") + .build(); + when(hashUtil.decode("enc")).thenReturn(1L); + when(problemSetRepository.findById(1L)).thenReturn(Optional.of(set)); + + RegenerationConditionResponse response = service.getRegenerationCondition("enc"); + + assertThat(response.quizType()).isEqualTo(QuizType.REAL_BLANK); + assertThat(response.quizCount()).isEqualTo(10); + assertThat(response.pageNumbers()).containsExactly(1, 2, 3); + assertThat(response.language()).isEqualTo("KO"); + assertThat(response.customInstruction()).isEqualTo("난이도 높게"); + assertThat(response.uploadedUrl()).isEqualTo("https://cdn.example/doc.pdf"); + assertThat(response.title()).isEqualTo("이산수학 3장"); + assertThat(response.documentAvailable()).isTrue(); + } + + @Test + @DisplayName("legacy 세트(pageNumbers 빈 리스트·language null)는 두 값을 null로 정규화해 폴백을 유도한다") + void normalizes_legacy_set_conditions_to_null() { + ProblemSet legacy = + ProblemSet.builder() + .id(1L) + .sessionId("sess") + .title("옛 세트") + .generationStatus(GenerationStatus.COMPLETED) + .quizType(QuizType.MULTIPLE) + .totalQuizCount(5) + .userId("user-1") + .fileUrl("https://cdn.example/old.pdf") + .pageNumbers(List.of()) // 컬럼 NULL → IntegerListConverter가 빈 리스트로 읽는다 + .language(null) + .build(); + when(hashUtil.decode("enc")).thenReturn(1L); + when(problemSetRepository.findById(1L)).thenReturn(Optional.of(legacy)); + + RegenerationConditionResponse response = service.getRegenerationCondition("enc"); + + assertThat(response.pageNumbers()).isNull(); + assertThat(response.language()).isNull(); + assertThat(response.documentAvailable()).isTrue(); + } + + @Test + @DisplayName("세트가 없으면 PROBLEM_SET_NOT_FOUND") + void throws_when_set_not_found_for_regeneration_condition() { + when(hashUtil.decode("enc")).thenReturn(1L); + when(problemSetRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getRegenerationCondition("enc")) + .isInstanceOf(CustomException.class) + .hasMessage(ExceptionMessage.PROBLEM_SET_NOT_FOUND.getMessage()); + } }