Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ plugins {

group = "com.icc.qasker"

version = "3.4.4"
version = "3.5.0"

subprojects {
tasks.configureEach { task ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -57,6 +60,23 @@ public ResponseEntity<CustomErrorResponse> handleCustomException(
.body(new CustomErrorResponse(ExceptionMessage.AI_SERVER_COMMUNICATION_ERROR.getMessage()));
}

/**
* 요청 바디 Bean Validation 실패(`@Valid @RequestBody`). 잘못된 요청이므로 400으로 응답한다(미처리 시 catch-all의 500으로
* 샘). 첫 필드 오류 메시지를 그대로 노출하고, 없으면 기본 메시지로 대체한다.
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<CustomErrorResponse> 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<CustomErrorResponse> handleMaxUploadSizeExceededException(
MaxUploadSizeExceededException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {

Expand Down Expand Up @@ -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) {}
}
Original file line number Diff line number Diff line change
@@ -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<List<String>> acceptedAnswers) {

public AISelection(String content, String explanation, boolean correct) {
this(content, explanation, correct, null);
}
}
Original file line number Diff line number Diff line change
@@ -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<GeminiRealBlankQuestion> questions) {
return toDto(questions, null);
}

public static AIProblemSet toDto(
List<GeminiRealBlankQuestion> questions, List<Integer> sourcePages) {
List<AIProblem> result =
questions.stream()
.map(
q -> {
List<AISelection> 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<Integer> 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<Integer> remapPages(List<Integer> aiPages, List<Integer> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<GeminiRealBlankQuestion> {

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<GeminiRealBlankQuestion> 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<Integer> sourcePages) {
return GeminiRealBlankQuestionMapper.toDto(List.of(question), sourcePages).quiz().getFirst();
}

@Override
protected Optional<GeminiRealBlankQuestion> 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;
}
}
Loading
Loading