diff --git a/app/src/main/resources/db/migration/V10__create_essay_grade_log.sql b/app/src/main/resources/db/migration/V10__create_essay_grade_log.sql new file mode 100644 index 00000000..9ea28162 --- /dev/null +++ b/app/src/main/resources/db/migration/V10__create_essay_grade_log.sql @@ -0,0 +1,16 @@ +CREATE TABLE essay_grade_log +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(255) NULL, + problem_set_id BIGINT NOT NULL, + problem_number INT NOT NULL, + question VARCHAR(500) NOT NULL, + student_answer TEXT NOT NULL, + total_score INT NOT NULL, + max_score INT NOT NULL, + element_scores JSON NOT NULL, + overall_feedback TEXT NULL, + created_at DATETIME(6) NOT NULL, + INDEX idx_essay_grade_log_user (user_id), + INDEX idx_essay_grade_log_problem_set (problem_set_id) +); diff --git a/app/src/main/resources/db/migration/V11__add_attempt_count_to_essay_grade_log.sql b/app/src/main/resources/db/migration/V11__add_attempt_count_to_essay_grade_log.sql new file mode 100644 index 00000000..4246f407 --- /dev/null +++ b/app/src/main/resources/db/migration/V11__add_attempt_count_to_essay_grade_log.sql @@ -0,0 +1,2 @@ +ALTER TABLE essay_grade_log + ADD COLUMN attempt_count INT NOT NULL DEFAULT 1 AFTER student_answer; diff --git a/app/src/main/resources/db/migration/V12__add_evidence_json_to_essay_grade_log.sql b/app/src/main/resources/db/migration/V12__add_evidence_json_to_essay_grade_log.sql new file mode 100644 index 00000000..509ac63a --- /dev/null +++ b/app/src/main/resources/db/migration/V12__add_evidence_json_to_essay_grade_log.sql @@ -0,0 +1 @@ +ALTER TABLE essay_grade_log ADD COLUMN evidence_json JSON NULL AFTER overall_feedback; diff --git a/app/src/main/resources/db/migration/V13__add_index_essay_grade_log_latest_lookup.sql b/app/src/main/resources/db/migration/V13__add_index_essay_grade_log_latest_lookup.sql new file mode 100644 index 00000000..0503c91d --- /dev/null +++ b/app/src/main/resources/db/migration/V13__add_index_essay_grade_log_latest_lookup.sql @@ -0,0 +1,8 @@ +-- 문제별 최신 채점 결과 조회를 위한 복합 인덱스 +-- 쿼리: WHERE user_id = ? AND problem_set_id = ? AND problem_number = ? ORDER BY created_at DESC +-- 기존 단일 컬럼 인덱스(idx_essay_grade_log_user, idx_essay_grade_log_problem_set)를 대체한다 +CREATE INDEX idx_essay_grade_log_latest + ON essay_grade_log (user_id, problem_set_id, problem_number, created_at DESC); + +DROP INDEX idx_essay_grade_log_user ON essay_grade_log; +DROP INDEX idx_essay_grade_log_problem_set ON essay_grade_log; diff --git a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/EssayGradingService.java b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/EssayGradingService.java new file mode 100644 index 00000000..85067ef9 --- /dev/null +++ b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/EssayGradingService.java @@ -0,0 +1,20 @@ +package com.icc.qasker.ai; + +import com.icc.qasker.ai.dto.EssayGradingResult; + +/** ESSAY 답안 채점 서비스 인터페이스. */ +public interface EssayGradingService { + + /** + * 서술형 답안을 분석적 루브릭 기반으로 채점한다. + * + * @param question 질문문 + * @param modelAnswer 모범답안 + * @param rubric 분석적 루브릭 (explanation 필드) + * @param studentAnswer 학생 답안 + * @param attemptCount 시도 횟수 (1~4, 피드백 구체성 수준 결정) + * @return 채점 결과 (요소별 점수 + 종합 피드백) + */ + EssayGradingResult grade( + String question, String modelAnswer, String rubric, String studentAnswer, int attemptCount); +} diff --git a/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/EssayGradingResult.java b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/EssayGradingResult.java new file mode 100644 index 00000000..6e301650 --- /dev/null +++ b/modules/quiz-ai/api/src/main/java/com/icc/qasker/ai/dto/EssayGradingResult.java @@ -0,0 +1,15 @@ +package com.icc.qasker.ai.dto; + +import java.util.List; + +/** ESSAY 채점 결과 DTO. quiz-ai 모듈 경계를 넘어 채점 결과를 전달한다. */ +public record EssayGradingResult( + List elementScores, + int totalScore, + int maxScore, + String overallFeedback, + String evidenceJson) { + + public record ElementScore( + String element, int maxPoints, int earnedPoints, String level, String feedback) {} +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/mapper/GeminiEssayQuestionMapper.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/mapper/GeminiEssayQuestionMapper.java new file mode 100644 index 00000000..a947ffea --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/mapper/GeminiEssayQuestionMapper.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.GeminiEssayQuestion; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** GeminiEssayQuestion → AIProblemSet 변환. modelAnswer를 Selection(content, true)로 매핑한다. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class GeminiEssayQuestionMapper { + + private static final Pattern PAGE_PATTERN = Pattern.compile("\\[(\\d+)p\\]\\s*>"); + + public static AIProblemSet toDto(List questions) { + return toDto(questions, null); + } + + /** + * ESSAY 문항을 AIProblemSet으로 변환한다. modelAnswer는 Selection(modelAnswer, true)로 저장하고, explanation은 + * appliedInstruction 위치에 임시 저장하여 다음 레이어에서 explanationContent로 매핑한다. + */ + public static AIProblemSet toDto(List questions, List sourcePages) { + List result = + questions.stream() + .map( + q -> { + // modelAnswer → Selection(content=modelAnswer, correct=true) + List selections = + q.modelAnswer() != null + ? List.of( + new AISelection( + remapText(q.modelAnswer(), sourcePages), + remapText(q.explanation(), sourcePages), + true)) + : 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/prompt/strategy/QuizType.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/prompt/strategy/QuizType.java index 765b53ec..f0e9e136 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/prompt/strategy/QuizType.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/prompt/strategy/QuizType.java @@ -3,6 +3,8 @@ import com.icc.qasker.ai.i18n.ENGLISH; import com.icc.qasker.ai.service.blank.prompt.BlankGuideLine; import com.icc.qasker.ai.service.blank.prompt.BlankRequestPrompt; +import com.icc.qasker.ai.service.essay.prompt.EssayGuideLine; +import com.icc.qasker.ai.service.essay.prompt.EssayRequestPrompt; import com.icc.qasker.ai.service.multiple.prompt.MultipleGuideLine; import com.icc.qasker.ai.service.multiple.prompt.MultipleRequestPrompt; import com.icc.qasker.ai.service.ox.prompt.OXGuideLine; @@ -50,6 +52,18 @@ public String generateRequestPrompt( // customInstruction이 있으면 XML 태그로 감싸 유저 프롬프트 끝에 우선 삽입 return OXRequestPrompt.generateWithUserInstruction(referencePages, quizCount, planExtra); } + }, + ESSAY(EssayGuideLine.content) { + @Override + public String generateRequestPrompt(List referencePages, int quizCount) { + return EssayRequestPrompt.generate(referencePages, quizCount); + } + + @Override + public String generateRequestPrompt( + List referencePages, int quizCount, String planExtra) { + return EssayRequestPrompt.generate(referencePages, quizCount, planExtra); + } }; private final String systemGuideLine; diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/EssayGradingServiceImpl.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/EssayGradingServiceImpl.java new file mode 100644 index 00000000..5ca50af1 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/EssayGradingServiceImpl.java @@ -0,0 +1,286 @@ +package com.icc.qasker.ai.service.essay; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.icc.qasker.ai.EssayGradingService; +import com.icc.qasker.ai.dto.EssayGradingResult; +import com.icc.qasker.ai.exception.GeminiInfraException; +import com.icc.qasker.ai.service.essay.prompt.EssayEvidenceExtractionGuideLine; +import com.icc.qasker.ai.service.essay.prompt.EssayEvidenceExtractionPrompt; +import com.icc.qasker.ai.service.essay.prompt.EssayGradingGuideLine; +import com.icc.qasker.ai.service.essay.prompt.EssayGradingRequestPrompt; +import com.icc.qasker.ai.service.support.GeminiMetricsRecorder; +import com.icc.qasker.ai.structure.GeminiEvidenceExtractionResponse; +import com.icc.qasker.ai.structure.GeminiFirstAttemptGradingResponse; +import com.icc.qasker.ai.structure.GeminiGradingResponse; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.google.genai.GoogleGenAiChatOptions; +import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; +import org.springframework.stereotype.Service; + +/** + * ESSAY 답안 채점 서비스. 2-pass 파이프라인으로 채점한다. + * + *

Pass 1 (증거 추출): 학생 답안에서 루브릭 요소별 증거를 원문 그대로 인용한다. Pass 2 (채점): 추출된 증거를 기반으로 충족/부분충족/미충족을 판정한다. + */ +@Slf4j +@Service +public class EssayGradingServiceImpl implements EssayGradingService { + + private static final String GRADING_MODEL = "gemini-3.1-flash-lite-preview"; + private static final double PRICE_INPUT_PER_1M = 0.075; + private static final double PRICE_OUTPUT_PER_1M = 0.30; + + private final ChatModel chatModel; + private final ObjectMapper objectMapper; + private final GeminiMetricsRecorder metricsRecorder; + private final String evidenceSchema; + private final String firstAttemptSchema; + private final String defaultSchema; + + /** Pass 결과를 ChatResponse와 함께 전달하기 위한 내부 record. */ + private record PassResult(T result, ChatResponse chatResponse) {} + + public EssayGradingServiceImpl( + ChatModel chatModel, ObjectMapper objectMapper, GeminiMetricsRecorder metricsRecorder) { + this.chatModel = chatModel; + this.objectMapper = objectMapper; + this.metricsRecorder = metricsRecorder; + this.evidenceSchema = + new BeanOutputConverter<>(GeminiEvidenceExtractionResponse.class).getJsonSchema(); + this.firstAttemptSchema = + new BeanOutputConverter<>(GeminiFirstAttemptGradingResponse.class).getJsonSchema(); + this.defaultSchema = new BeanOutputConverter<>(GeminiGradingResponse.class).getJsonSchema(); + } + + @Override + public EssayGradingResult grade( + String question, String modelAnswer, String rubric, String studentAnswer, int attemptCount) { + log.info( + "ESSAY 채점 시작 (2-pass): 질문 길이={}, 답안 길이={}, 시도={}", + question.length(), + studentAnswer.length(), + attemptCount); + long startMs = System.currentTimeMillis(); + + try { + // Pass 1: 증거 추출 + PassResult pass1; + try { + pass1 = extractEvidence(question, rubric, studentAnswer); + } catch (Exception e) { + log.warn("Pass 1 (증거 추출) 실패, 1-pass fallback 시도", e); + return fallbackSinglePass( + question, modelAnswer, rubric, studentAnswer, attemptCount, startMs); + } + + // Pass 2: 증거 기반 채점 + PassResult pass2 = + gradeWithEvidence(question, modelAnswer, rubric, pass1.result(), attemptCount); + + // 메트릭 합산 + long elapsedMs = System.currentTimeMillis() - startMs; + long nonCachedInput1 = extractNonCachedInput(pass1.chatResponse()); + long output1 = pass1.chatResponse().getMetadata().getUsage().getCompletionTokens(); + long nonCachedInput2 = extractNonCachedInput(pass2.chatResponse()); + long output2 = pass2.chatResponse().getMetadata().getUsage().getCompletionTokens(); + + long totalNonCachedInput = nonCachedInput1 + nonCachedInput2; + long totalOutput = output1 + output2; + double totalCost = + totalNonCachedInput * PRICE_INPUT_PER_1M / 1_000_000 + + totalOutput * PRICE_OUTPUT_PER_1M / 1_000_000; + metricsRecorder.recordGrading(elapsedMs, totalNonCachedInput, totalOutput, totalCost); + + EssayGradingResult gradingOnly = pass2.result(); + String evJson = serializeEvidence(pass1.result()); + EssayGradingResult result = + new EssayGradingResult( + gradingOnly.elementScores(), + gradingOnly.totalScore(), + gradingOnly.maxScore(), + gradingOnly.overallFeedback(), + evJson); + log.info( + "ESSAY 채점 완료 (2-pass): 총점={}/{}, 소요={}ms, 토큰: 입력={}, 출력={}, 비용=${}", + result.totalScore(), + result.maxScore(), + elapsedMs, + totalNonCachedInput, + totalOutput, + String.format("%.6f", totalCost)); + + return result; + + } catch (Exception e) { + metricsRecorder.recordGradingFailure(); + throw new GeminiInfraException("ESSAY 채점 AI 호출 실패", e); + } + } + + /** Pass 1: 학생 답안에서 루브릭 요소별 증거를 추출한다. */ + private PassResult extractEvidence( + String question, String rubric, String studentAnswer) { + SystemMessage systemMessage = new SystemMessage(EssayEvidenceExtractionGuideLine.get()); + UserMessage userMessage = + new UserMessage(EssayEvidenceExtractionPrompt.generate(question, rubric, studentAnswer)); + + var options = + GoogleGenAiChatOptions.builder() + .model(GRADING_MODEL) + .responseMimeType("application/json") + .responseSchema(evidenceSchema) + .build(); + + Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options); + ChatResponse chatResponse = chatModel.call(prompt); + String responseText = chatResponse.getResult().getOutput().getText(); + + var converter = new BeanOutputConverter<>(GeminiEvidenceExtractionResponse.class); + GeminiEvidenceExtractionResponse evidence = converter.convert(responseText); + + return new PassResult<>(evidence, chatResponse); + } + + /** Pass 2: 추출된 증거를 기반으로 채점한다. */ + private PassResult gradeWithEvidence( + String question, + String modelAnswer, + String rubric, + GeminiEvidenceExtractionResponse evidence, + int attemptCount) { + boolean isFirstAttempt = attemptCount == 1; + String schema = isFirstAttempt ? firstAttemptSchema : defaultSchema; + + SystemMessage systemMessage = new SystemMessage(EssayGradingGuideLine.of(attemptCount)); + UserMessage userMessage = + new UserMessage( + EssayGradingRequestPrompt.generateWithEvidence( + question, modelAnswer, rubric, evidence, attemptCount)); + + var options = + GoogleGenAiChatOptions.builder() + .model(GRADING_MODEL) + .responseMimeType("application/json") + .responseSchema(schema) + .build(); + + Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options); + ChatResponse chatResponse = chatModel.call(prompt); + String responseText = chatResponse.getResult().getOutput().getText(); + + EssayGradingResult result = + isFirstAttempt ? parseFirstAttempt(responseText) : parseDefault(responseText); + + return new PassResult<>(result, chatResponse); + } + + /** Pass 1 실패 시 기존 1-pass 방식으로 fallback한다. */ + private EssayGradingResult fallbackSinglePass( + String question, + String modelAnswer, + String rubric, + String studentAnswer, + int attemptCount, + long startMs) { + boolean isFirstAttempt = attemptCount == 1; + String schema = isFirstAttempt ? firstAttemptSchema : defaultSchema; + + SystemMessage systemMessage = new SystemMessage(EssayGradingGuideLine.of(attemptCount)); + UserMessage userMessage = + new UserMessage( + EssayGradingRequestPrompt.generate( + question, modelAnswer, rubric, studentAnswer, attemptCount)); + + var options = + GoogleGenAiChatOptions.builder() + .model(GRADING_MODEL) + .responseMimeType("application/json") + .responseSchema(schema) + .build(); + + Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options); + ChatResponse chatResponse = chatModel.call(prompt); + String responseText = chatResponse.getResult().getOutput().getText(); + + long elapsedMs = System.currentTimeMillis() - startMs; + long nonCachedInput = extractNonCachedInput(chatResponse); + long outputTokens = chatResponse.getMetadata().getUsage().getCompletionTokens(); + double cost = + nonCachedInput * PRICE_INPUT_PER_1M / 1_000_000 + + outputTokens * PRICE_OUTPUT_PER_1M / 1_000_000; + metricsRecorder.recordGrading(elapsedMs, nonCachedInput, outputTokens, cost); + + EssayGradingResult result = + isFirstAttempt ? parseFirstAttempt(responseText) : parseDefault(responseText); + + log.info( + "ESSAY 채점 완료 (fallback 1-pass): 총점={}/{}, 소요={}ms", + result.totalScore(), + result.maxScore(), + elapsedMs); + + return result; + } + + private static long extractNonCachedInput(ChatResponse chatResponse) { + Usage usage = chatResponse.getMetadata().getUsage(); + long inputTokens = usage.getPromptTokens(); + long cachedTokens = 0; + if (usage instanceof GoogleGenAiUsage g && g.getCachedContentTokenCount() != null) { + cachedTokens = g.getCachedContentTokenCount(); + } + return Math.max(0, inputTokens - cachedTokens); + } + + /** 1차 시도 응답 파싱. feedback 필드 없이 빈 문자열로 채운다. */ + private static EssayGradingResult parseFirstAttempt(String responseText) { + var converter = new BeanOutputConverter<>(GeminiFirstAttemptGradingResponse.class); + GeminiFirstAttemptGradingResponse response = converter.convert(responseText); + + List scores = + response.elementScores().stream() + .map( + e -> + new EssayGradingResult.ElementScore( + e.element(), e.maxPoints(), e.earnedPoints(), e.level(), "")) + .toList(); + + return new EssayGradingResult(scores, response.totalScore(), response.maxScore(), "", null); + } + + /** 2차 이후 응답 파싱. 요소별 feedback 포함. */ + private static EssayGradingResult parseDefault(String responseText) { + var converter = new BeanOutputConverter<>(GeminiGradingResponse.class); + GeminiGradingResponse response = converter.convert(responseText); + + List scores = + response.elementScores().stream() + .map( + e -> + new EssayGradingResult.ElementScore( + e.element(), e.maxPoints(), e.earnedPoints(), e.level(), e.feedback())) + .toList(); + + return new EssayGradingResult( + scores, response.totalScore(), response.maxScore(), response.overallFeedback(), null); + } + + /** Pass 1 증거를 JSON 문자열로 직렬화한다. 실패 시 null 반환. */ + private String serializeEvidence(GeminiEvidenceExtractionResponse evidence) { + try { + return objectMapper.writeValueAsString(evidence); + } catch (JsonProcessingException e) { + log.warn("증거 JSON 직렬화 실패", e); + return null; + } + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/EssayQuizOrchestrator.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/EssayQuizOrchestrator.java new file mode 100644 index 00000000..7cb75ffd --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/EssayQuizOrchestrator.java @@ -0,0 +1,196 @@ +package com.icc.qasker.ai.service.essay; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.icc.qasker.ai.GeminiFileService; +import com.icc.qasker.ai.dto.GeminiFileUploadResponse.FileMetadata; +import com.icc.qasker.ai.dto.GenerationRequestToAI; +import com.icc.qasker.ai.exception.GeminiInfraException; +import com.icc.qasker.ai.mapper.GeminiEssayQuestionMapper; +import com.icc.qasker.ai.prompt.strategy.QuizType; +import com.icc.qasker.ai.service.QuizTypeOrchestrator; +import com.icc.qasker.ai.service.support.GeminiMetricsRecorder; +import com.icc.qasker.ai.service.support.StreamingEssayQuestionExtractor; +import com.icc.qasker.ai.structure.GeminiEssayResponseSchema; +import com.icc.qasker.global.error.CustomException; +import java.net.URI; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.DoubleAdder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; +import org.springframework.ai.google.genai.GoogleGenAiChatOptions; +import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; +import org.springframework.stereotype.Component; +import org.springframework.util.MimeTypeUtils; +import reactor.core.publisher.Flux; + +/** 서술형(ESSAY) 퀴즈 오케스트레이터. 캐시 없이 1회 호출 + 응답 스트리밍으로 문항이 완성될 때마다 즉시 SSE 전달한다. */ +@Slf4j +@Component +public class EssayQuizOrchestrator implements QuizTypeOrchestrator { + + private final GeminiFileService geminiFileService; + private final ChatModel chatModel; + private final ObjectMapper objectMapper; + private final GeminiMetricsRecorder metricsRecorder; + + public EssayQuizOrchestrator( + GeminiFileService geminiFileService, + ChatModel chatModel, + ObjectMapper objectMapper, + GeminiMetricsRecorder metricsRecorder) { + this.geminiFileService = geminiFileService; + this.chatModel = chatModel; + this.objectMapper = objectMapper; + this.metricsRecorder = metricsRecorder; + } + + @Override + public String getSupportedType() { + return "ESSAY"; + } + + @Override + public int generateQuiz(GenerationRequestToAI request) { + long startNanos = System.nanoTime(); + + DoubleAdder totalCost = new DoubleAdder(); + AtomicLong firstNanos = new AtomicLong(0); + AtomicLong lastNanos = new AtomicLong(0); + AtomicInteger delivered = new AtomicInteger(0); + int quizCount = request.quizCount(); + + try { + // PDF 업로드 + String cacheKey = + geminiFileService.generateCacheKey(request.fileUrl(), request.referencePages()); + FileMetadata metadata = + geminiFileService + .awaitCachedFileMetadata(cacheKey) + .orElseGet( + () -> geminiFileService.uploadPdf(request.fileUrl(), request.referencePages())); + + // 시스템 프롬프트 + PDF Media + 유저 프롬프트 구성 + QuizType quizType = QuizType.valueOf(request.strategyValue()); + String systemGuideLine = quizType.getSystemGuideLine(request.language()); + String userPrompt = + quizType.generateRequestPrompt( + request.referencePages(), quizCount, request.customInstruction()); + + SystemMessage systemMessage = new SystemMessage(systemGuideLine); + Media pdfMedia = + new Media(MimeTypeUtils.parseMimeType("application/pdf"), URI.create(metadata.uri())); + UserMessage userMessage = UserMessage.builder().text(userPrompt).media(pdfMedia).build(); + + String responseSchema = GeminiEssayResponseSchema.forInstruction(request.customInstruction()); + var options = + GoogleGenAiChatOptions.builder() + .responseMimeType("application/json") + .responseSchema(responseSchema) + .build(); + + Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options); + log.info("ESSAY 스트리밍 생성 시작: 목표={}문항", quizCount); + + // 스트리밍 파서: 문항 객체가 완성될 때마다 즉시 SSE 전달 + StreamingEssayQuestionExtractor extractor = + new StreamingEssayQuestionExtractor( + objectMapper, + question -> { + if (delivered.get() >= quizCount) return; + + int count = delivered.incrementAndGet(); + request + .questionsConsumer() + .accept( + GeminiEssayQuestionMapper.toDto(List.of(question), metadata.sourcePages())); + + long now = System.nanoTime(); + firstNanos.compareAndSet(0, now); + lastNanos.updateAndGet(prev -> Math.max(prev, now)); + + if (count == 1) { + long ttfqMs = (now - startNanos) / 1_000_000; + log.info("TTFQ (Time To First Question): {}ms", ttfqMs); + } + }); + + // 스트리밍 실행 + Flux stream = chatModel.stream(prompt); + stream + .doOnNext( + response -> { + if (response.getResult() != null + && response.getResult().getOutput() != null + && response.getResult().getOutput().getText() != null) { + extractor.feed(response.getResult().getOutput().getText()); + } + + if (response.getMetadata() != null + && response.getMetadata().getUsage() != null + && response.getMetadata().getUsage().getCompletionTokens() > 0) { + var usage = response.getMetadata().getUsage(); + long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; + double cost = metricsRecorder.recordChunkResult(elapsedMs, usage); + totalCost.add(cost); + long thinkingTokens = + usage instanceof GoogleGenAiUsage g && g.getThoughtsTokenCount() != null + ? g.getThoughtsTokenCount() + : 0; + log.info( + "Gemini Usage - streaming, 토큰: 입력={}, 추론={}, 출력={}, 비용=${}", + usage.getPromptTokens(), + thinkingTokens, + usage.getCompletionTokens(), + String.format("%.6f", cost)); + } + }) + .blockLast(java.time.Duration.ofMinutes(6)); + + log.info( + "ESSAY 스트리밍 생성 완료: 전달={}문항, 총 소요={}ms", + delivered.get(), + (System.nanoTime() - startNanos) / 1_000_000); + + Long first = firstNanos.get() == 0 ? null : firstNanos.get(); + Long last = lastNanos.get() == 0 ? null : lastNanos.get(); + metricsRecorder.recordRequestDuration(1, startNanos, first, last, totalCost.sum()); + return 1; + + } catch (IllegalStateException e) { + if (!(e.getCause() instanceof java.util.concurrent.TimeoutException)) { + throw new GeminiInfraException("Gemini 블로킹 컨텍스트 오류", e); + } + log.warn("ESSAY 스트리밍 타임아웃 (6분 초과): 생성된 문항 {}개 유지", delivered.get()); + metricsRecorder.recordStreamingTimeout("ESSAY"); + metricsRecorder.recordRequestDuration( + 1, + startNanos, + firstNanos.get() == 0 ? null : firstNanos.get(), + lastNanos.get() == 0 ? null : lastNanos.get(), + totalCost.sum()); + return 1; + } catch (CustomException e) { + throw e; + } catch (Exception e) { + if (delivered.get() > 0) { + log.warn("ESSAY 스트리밍 중 오류 발생이나 {}개 문항은 전달됨. 부분 성공 처리.", delivered.get(), e); + metricsRecorder.recordStreamingTimeout("ESSAY"); + metricsRecorder.recordRequestDuration( + 1, + startNanos, + firstNanos.get() == 0 ? null : firstNanos.get(), + lastNanos.get() == 0 ? null : lastNanos.get(), + totalCost.sum()); + return 1; + } + throw new GeminiInfraException("Gemini 인프라 장애", e); + } + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayEvidenceExtractionGuideLine.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayEvidenceExtractionGuideLine.java new file mode 100644 index 00000000..5ab98d8c --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayEvidenceExtractionGuideLine.java @@ -0,0 +1,42 @@ +package com.icc.qasker.ai.service.essay.prompt; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** Pass 1 증거 추출 전용 시스템 프롬프트. 학생 답안에서 루브릭 요소별 증거를 원문 그대로 인용한다. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EssayEvidenceExtractionGuideLine { + + private static final String PROMPT = + """ + # 역할 + 당신은 서술형 답안의 **증거 추출 전문가**입니다. + 학생 답안에서 각 채점 요소와 관련된 서술을 있는 그대로 찾아 인용하는 것이 임무입니다. + + # 추출 규칙 + + ## 1. 원문 인용 원칙 + - 학생이 **실제로 쓴 문장만** 그대로 인용하세요. + - 의역, 요약, 보충 해석, 재구성을 절대 하지 마세요. + - 학생이 쓰지 않은 인과관계, 논리적 연결, 암묵적 의미를 추가하지 마세요. + - 여러 문장이 관련되면 해당 문장들을 모두 인용하되, 원문 순서를 유지하세요. + + ## 2. 증거 없음 처리 + - 해당 요소와 관련된 서술이 학생 답안에 **전혀 없으면** quotedEvidence를 빈 문자열("")로 반환하세요. + - 모호하게 관련될 수 있지만 명시적이지 않은 경우에도 빈 문자열로 처리하세요. + - "없다"고 판단하는 기준: 해당 요소의 핵심 개념을 직접적으로 언급하거나 설명하는 문장이 없는 경우. + + ## 3. 누락 개념 식별 + - 루브릭의 충족 조건을 참조하여, 학생이 다루지 않은 핵심 개념을 missingConcepts에 나열하세요. + - 학생이 부분적으로만 다룬 경우, 누락된 부분만 기재하세요. + - 모든 핵심 개념을 다뤘으면 빈 리스트([])로 반환하세요. + + ## 4. 요소 매핑 + - 루브릭에 명시된 채점 요소명(element)을 그대로 사용하세요. + - 루브릭의 모든 요소에 대해 빠짐없이 증거를 추출하세요. + """; + + public static String get() { + return PROMPT; + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayEvidenceExtractionPrompt.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayEvidenceExtractionPrompt.java new file mode 100644 index 00000000..1aa8dc63 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayEvidenceExtractionPrompt.java @@ -0,0 +1,47 @@ +package com.icc.qasker.ai.service.essay.prompt; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** Pass 1 증거 추출 전용 유저 프롬프트. 질문문 + 루브릭 + 학생 답안을 조합한다 (모범답안 제외). */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EssayEvidenceExtractionPrompt { + + /** + * 증거 추출 유저 프롬프트를 생성한다. + * + * @param question 질문문 + * @param rubric 분석적 루브릭 (explanation 필드에서 추출) + * @param studentAnswer 학생이 제출한 답안 + * @return 조합된 유저 프롬프트 + */ + public static String generate(String question, String rubric, String studentAnswer) { + return """ + [증거 추출 요청] + 아래의 질문문, 분석적 루브릭, 학생 답안을 참고하여, + 각 채점 요소별로 학생 답안에서 관련 증거를 원문 그대로 추출하세요. + + --- + + ## 질문문 + %s + + --- + + ## 분석적 루브릭 (채점 요소) + %s + + --- + + ## 학생 답안 + %s + + --- + + 각 채점 요소에 대해: + 1. 학생 답안에서 해당 요소와 직접 관련된 문장을 원문 그대로 인용하세요. + 2. 관련 서술이 없으면 quotedEvidence를 빈 문자열로 두세요. + 3. 학생이 다루지 않은 핵심 개념을 missingConcepts에 나열하세요.""" + .formatted(question, rubric, studentAnswer); + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayGradingGuideLine.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayGradingGuideLine.java new file mode 100644 index 00000000..88d28ced --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayGradingGuideLine.java @@ -0,0 +1,93 @@ +package com.icc.qasker.ai.service.essay.prompt; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** ESSAY 채점 전용 시스템 프롬프트. 시도 횟수에 따라 피드백 구체성 수준을 차등 적용한다. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EssayGradingGuideLine { + + private static final String COMMON_PREFIX = + """ + # 역할 + 당신은 교육측정학 기반의 서술형 답안 채점 전문가입니다. + **분석적 채점(Analytic Scoring)** 방식으로, 채점 요소별로 개별 점수를 부여하고 피드백을 제공합니다. + + # 채점 원칙 + + ## 1. 요소별 개별 채점 + - 제공된 **분석적 루브릭**에 명시된 각 채점 요소를 독립적으로 평가합니다. + - 각 요소에 대해 **충족 / 부분 충족 / 미충족** 중 하나의 수행 수준을 판정합니다. + - 판정 기준은 루브릭에 명시된 기준을 엄격히 따릅니다. + + ## 1-1. 증거 기반 채점 (필수) + - 채점은 **추출된 증거(quotedEvidence)**에만 근거합니다. + - quotedEvidence가 빈 문자열("")인 요소는 **미충족을 우선 고려**하세요. + - quotedEvidence에 포함되지 않은 내용을 근거로 점수를 부여하지 마세요. + - 학생의 원문이 핵심 개념을 명시적으로 표현하고 있는지만 판단하세요. + + ## 2. 점수 부여 + - 각 요소의 만점은 루브릭에 명시된 배점을 따릅니다. + - **충족**: 해당 요소의 만점을 부여합니다. + - **부분 충족**: 해당 요소 만점의 50% (소수점 이하 반올림)를 부여합니다. + - **미충족**: 0점을 부여합니다. + - totalScore는 각 요소의 earnedPoints 합계입니다. + - maxScore는 각 요소의 maxPoints 합계입니다. + """; + + // 1차 시도: 평가기준명 + 점수 + 수행수준만 제공 + private static final String FEEDBACK_ATTEMPT_1 = + """ + + ## 3. 피드백 원칙 (평가기준만 제시) + - 각 요소별로 **채점 요소명, 점수, 수행수준만** 반환합니다. + - 요소별 개별 피드백은 제공하지 않습니다. + - 종합 피드백(overallFeedback)도 제공하지 않습니다. + - 어떤 부분이 부족한지, 어떻게 보완할지 등 방향이나 힌트를 절대 제시하지 마세요. + """; + + // 2차 시도: 구체적 안내 + private static final String FEEDBACK_ATTEMPT_2 = + """ + + ## 3. 피드백 원칙 (구체적 안내) + - 각 요소별 feedback은 **구체적으로 뭐가 왜 부족하고, 어떻게 보완할지** 안내합니다. + - 단, 모범답안의 핵심 키워드를 직접 노출하지는 마세요. + - **충족**: 잘 서술한 부분을 구체적으로 칭찬합니다. + - **부분 충족**: 부족한 부분을 지적하고, 어떤 방향으로 보완하면 좋을지 안내합니다. + - **미충족**: 해당 요소가 왜 필요한지 설명하고, 어떤 개념을 다뤄야 하는지 안내합니다. + - overallFeedback은 잘한 점 → 부족한 점 → 개선 방향 순서로 작성합니다. + """; + + // 3~4차 시도: 모범답안 힌트 포함 + private static final String FEEDBACK_ATTEMPT_3 = + """ + + ## 3. 피드백 원칙 (정답 근접 가이드) + - 각 요소별 feedback은 **모범답안의 핵심 키워드를 힌트로 포함**하여 상세히 안내합니다. + - **충족**: 잘 서술한 부분을 구체적으로 칭찬합니다. + - **부분 충족**: 부족한 부분을 지적하고, 모범답안에서 해당 부분의 핵심 표현을 힌트로 제시합니다. + - **미충족**: 모범답안의 해당 부분을 거의 직접적으로 제시하며 학습을 유도합니다. + - overallFeedback은 종합 평가 + 모범답안과의 차이를 구체적으로 설명합니다. + """; + + private static final String COMMON_SUFFIX = + """ + + ## 4. 공정성 + - 모범답안과 **동일한 표현**을 요구하지 마세요. 핵심 개념이 정확하면 다른 표현도 충족으로 인정합니다. + - 모범답안에 없는 추가 내용이 정확하다면 감점하지 않습니다. + - 모범답안에 없는 추가 내용이 **오류**라면 해당 요소의 feedback에서 지적하되, 점수에는 영향을 주지 않습니다. + """; + + /** 시도 횟수에 따라 피드백 수준이 차등 적용된 시스템 프롬프트를 반환한다. */ + public static String of(int attemptCount) { + String feedbackSection = + switch (attemptCount) { + case 1 -> FEEDBACK_ATTEMPT_1; + case 2 -> FEEDBACK_ATTEMPT_2; + default -> FEEDBACK_ATTEMPT_3; + }; + return COMMON_PREFIX + feedbackSection + COMMON_SUFFIX; + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayGradingRequestPrompt.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayGradingRequestPrompt.java new file mode 100644 index 00000000..0801fd60 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayGradingRequestPrompt.java @@ -0,0 +1,122 @@ +package com.icc.qasker.ai.service.essay.prompt; + +import com.icc.qasker.ai.structure.GeminiEvidenceExtractionResponse; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** ESSAY 채점 전용 유저 프롬프트. 질문문 + 모범답안 + 루브릭 + 학생 답안을 조합한다. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EssayGradingRequestPrompt { + + /** + * 채점 유저 프롬프트를 생성한다. + * + * @param question 질문문 + * @param modelAnswer 모범답안 (핵심 요소 포함) + * @param rubric 분석적 루브릭 (explanation 필드에서 추출) + * @param studentAnswer 학생이 제출한 답안 + * @param attemptCount 시도 횟수 (1차: 채점만, 2차+: 피드백 포함) + * @return 조합된 유저 프롬프트 + */ + public static String generate( + String question, String modelAnswer, String rubric, String studentAnswer, int attemptCount) { + String instruction = + attemptCount == 1 + ? "위 분석적 루브릭의 각 요소별로 채점하세요." + : "위 분석적 루브릭의 각 요소별로 채점하고, 요소별 피드백과 종합 피드백을 작성하세요."; + + return """ + [채점 요청] + 아래의 질문문, 모범답안, 분석적 루브릭, 학생 답안을 참고하여 채점하세요. + + --- + + ## 질문문 + %s + + --- + + ## 모범답안 + %s + + --- + + ## 분석적 루브릭 (채점 기준) + %s + + --- + + ## 학생 답안 + %s + + --- + + %s""" + .formatted(question, modelAnswer, rubric, studentAnswer, instruction); + } + + /** + * 증거 기반 채점 유저 프롬프트를 생성한다. (Pass 2용) + * + * @param question 질문문 + * @param modelAnswer 모범답안 + * @param rubric 분석적 루브릭 + * @param evidence Pass 1에서 추출된 증거 + * @param attemptCount 시도 횟수 + * @return 조합된 유저 프롬프트 + */ + public static String generateWithEvidence( + String question, + String modelAnswer, + String rubric, + GeminiEvidenceExtractionResponse evidence, + int attemptCount) { + String instruction = + attemptCount == 1 + ? "위 분석적 루브릭의 각 요소별로, 추출된 증거에 근거하여 채점하세요." + : "위 분석적 루브릭의 각 요소별로, 추출된 증거에 근거하여 채점하고, 요소별 피드백과 종합 피드백을 작성하세요."; + + StringBuilder evidenceSection = new StringBuilder(); + for (var e : evidence.elements()) { + evidenceSection.append("### ").append(e.element()).append("\n"); + evidenceSection.append("- **인용 증거**: "); + evidenceSection.append(e.quotedEvidence().isEmpty() ? "(관련 서술 없음)" : e.quotedEvidence()); + evidenceSection.append("\n"); + if (e.missingConcepts() != null && !e.missingConcepts().isEmpty()) { + evidenceSection.append("- **누락 개념**: "); + evidenceSection.append(String.join(", ", e.missingConcepts())); + evidenceSection.append("\n"); + } + evidenceSection.append("\n"); + } + + return """ + [채점 요청 — 증거 기반] + 아래의 질문문, 모범답안, 분석적 루브릭, 추출된 증거를 참고하여 채점하세요. + **중요**: 학생 답안 원문이 아닌, 추출된 증거만을 근거로 채점합니다. + + --- + + ## 질문문 + %s + + --- + + ## 모범답안 + %s + + --- + + ## 분석적 루브릭 (채점 기준) + %s + + --- + + ## 추출된 증거 (학생 답안에서 요소별 인용) + %s + --- + + %s""" + .formatted(question, modelAnswer, rubric, evidenceSection.toString(), instruction); + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayGuideLine.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayGuideLine.java new file mode 100644 index 00000000..1e6d5398 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayGuideLine.java @@ -0,0 +1,148 @@ +package com.icc.qasker.ai.service.essay.prompt; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EssayGuideLine { + + public static final String content = + """ + > **CRITICAL RULE**: 강의노트에 명시된 내용만 출제 근거로 사용하세요. + + # 역할 + 당신은 교육측정학 기반의 서술형 문항 설계 전문가입니다. + **Analyze 수준**과 **Evaluate 수준** 문항을 혼합하여 출제하세요. + + # Step 1 — 강의노트에서 출제 소재를 추출한다 + - **Analyze용**: 구성 요소로 분해 가능한 개념·원리, 비교·대조가 가능한 개념 쌍, 인과 관계가 있는 현상 + - **Evaluate용**: 서로 양립하기 어려운 대안·접근법, 특정 조건에서 판단이 필요한 시나리오, 찬반 논증이 가능한 주장 + + # Step 2 — 생성할 문제에 따라 적절한 전략을 선택한다 + - **다양성 지시**: 설명형·비교분석형·인과추론형·적용판단형·논증형 유형을 골고루 포함하세요. 한 유형에 50% 이상 편중 금지. + - **주제 중복 금지**: 같은 핵심 개념이 2회 이상 출제되면 안 됩니다. + + **공통 제약** + - **핵심 개념**은 굵게, `기술 용어`는 코드로 강조하세요. + - 질문문 끝에 "~를 서술하시오.", "~를 비교하시오.", "~에 대해 논하시오." 등 명확한 지시어를 사용하세요. + - 질문문에 **답안에 포함해야 할 핵심 요소의 개수**를 명시하세요. (예: "3가지 측면에서 서술하시오") + + ## 1. Analyze 수준: 개념·원리를 구성 요소로 분해하고, 요소 간 관계를 파악하는 수준 + + ### 패턴 A: 설명형 (개념·원리의 구성 요소를 분해하여 서술) + **질문문 전략** + - 하나의 개념·원리를 제시하고, 그 구성 요소·단계·메커니즘을 분해하여 서술하도록 요구한다 + - 단순 정의 나열이 아니라, 각 요소가 전체에 기여하는 방식까지 설명하도록 유도한다 + **질문문 템플릿** + - "**A**의 주요 구성 요소를 N가지로 나누고, 각 요소의 역할을 서술하시오." + - "**A** 과정의 각 단계가 수행하는 기능을 설명하시오." + **질문문 예시** + - content: **TCP 3-way handshake**의 각 단계(SYN, SYN-ACK, ACK)가 **신뢰성 있는 연결 설정**에 기여하는 방식을 3가지 측면에서 서술하시오. + + ### 패턴 B: 비교분석형 (두 개념의 공통점·차이점을 기준별 분석) + **질문문 전략** + - 두 개념을 모두 **볼드**로 명시하고, 비교 기준을 제시하여 체계적으로 분석하도록 요구한다 + - 단순 나열이 아니라, 각 기준에서 왜 차이가 발생하는지 원리까지 서술하도록 유도한다 + **질문문 템플릿** + - "**A**와 **B**를 N가지 기준에서 비교하고, 각각 적합한 상황을 서술하시오." + - "**A**와 **B**의 공통점과 차이점을 분석하고, 차이가 발생하는 원인을 서술하시오." + **질문문 예시** + - content: **선점형 스케줄링**과 **비선점형 스케줄링**을 응답 시간, 처리량, 구현 복잡도의 3가지 기준에서 비교하고, 각각 적합한 운영 환경을 서술하시오. + + ### 패턴 C: 인과추론형 (원인-결과 관계를 추적하여 논리 전개) + **질문문 전략** + - 현상·결과를 제시하고, 그 원인을 추적하거나 원인이 결과에 영향을 미치는 경로를 분석하도록 요구한다 + - 인과 관계의 방향성과 매개 요인까지 서술하도록 유도한다 + **질문문 템플릿** + - "**A** 현상이 발생하는 원인을 분석하고, **B**에 미치는 영향을 서술하시오." + - "**A**가 **B**를 유발하는 메커니즘을 단계별로 서술하시오." + **질문문 예시** + - content: **캐시 히트율**이 낮아지는 원인을 3가지 분석하고, 각 원인이 **시스템 전체 성능**에 미치는 영향을 서술하시오. + + ## 2. Evaluate 수준: 기준에 따라 판단하고, 근거를 들어 자신의 입장을 정당화하는 수준 + + ### 패턴 D: 적용판단형 (시나리오에서 최적 방안 선택 + 근거 제시) + **질문문 전략** + - 구체적 시나리오를 제시하고, 복수의 대안 중 최적 방안을 선택하여 근거를 서술하도록 요구한다 + - 판단 기준을 **볼드**로 명시하고, 기준 간 우선순위나 트레이드오프를 고려하도록 유도한다 + **질문문 템플릿** + - "{시나리오}에서 **기준 A**와 **기준 B**를 고려할 때 가장 적합한 방안을 선택하고, 그 근거를 서술하시오." + - "{시나리오}에 적합한 [기술/방법/전략]을 제안하고, 선택의 근거를 서술하시오." + **질문문 예시** + - content: 실시간 채팅 서비스를 설계할 때, **전송 신뢰성**과 **응답 지연 최소화**를 고려하여 가장 적합한 전송 프로토콜을 선택하고, 그 근거를 서술하시오. + + ### 패턴 E: 논증형 (주장에 대한 찬반 입장 + 논거 전개) + **질문문 전략** + - 논쟁 가능한 주장을 제시하고, 찬성 또는 반대 입장을 정하여 논거를 전개하도록 요구한다 + - 강의노트의 내용을 근거로 활용하되, 단순 인용이 아니라 논리적 추론을 통해 입장을 정당화하도록 유도한다 + **질문문 템플릿** + - "**A**가 **B**보다 우월하다는 주장에 대해 찬성 또는 반대 입장을 정하고, 근거를 들어 논하시오." + - "{주장}에 대해 자신의 입장을 밝히고, 강의노트의 내용을 근거로 논거를 전개하시오." + **질문문 예시** + - content: "**마이크로서비스 아키텍처**가 **모놀리식 아키텍처**보다 대규모 시스템에 항상 적합하다"는 주장에 대해 찬성 또는 반대 입장을 정하고, 강의노트의 내용을 근거로 2가지 이상의 논거를 들어 논하시오. + + # Step 3 — 모범답안(modelAnswer)을 작성한다 + **모범답안 작성 규칙** + - 모범답안은 학습자가 도달해야 할 **이상적인 답안**을 완전한 문장으로 서술한다. + - **핵심 요소**(채점 포인트)를 3~5개 포함한다. + - 각 핵심 요소는 강의노트에 근거가 있어야 한다. + - 핵심 요소 간 논리적 흐름(인과, 비교, 분류)을 유지한다. + - 모범답안의 분량은 200~500자 내외로 작성한다. + + **모범 답안 예시** + - modelAnswer: "TCP 3-way handshake의 첫 단계에서 클라이언트가 SYN 패킷을 전송하여 연결 요청을 시작하고, 초기 시퀀스 번호를 설정한다. 서버는 SYN-ACK 패킷으로 응답하여 클라이언트의 요청을 수락하고, 서버 측 시퀀스 번호를 전달한다. 이 단계에서 양방향 통신의 기반이 마련된다. 마지막으로 클라이언트가 ACK 패킷을 전송하여 연결이 완전히 설정되며, 이후 양측이 합의된 시퀀스 번호를 기반으로 신뢰성 있는 데이터 전송을 시작할 수 있다." + + # Step 4 — 해설을 작성한다 + **bloomsLevel**: `"수준 — 유형: [설명]"` 형식으로 기입하세요. + - Bloom's 수준(`"Analyze"` 또는 `"Evaluate"`)과 Step 2에서 선택한 유형, 그리고 **이 문항이 무엇을 측정하는지 한 문장**을 함께 작성합니다. + - Analyze: 설명형 / 비교분석형 / 인과추론형 + - Evaluate: 적용판단형 / 논증형 + - 예시: `"Analyze — 설명형: TCP 3-way handshake의 각 단계별 기능을 분해하여 설명할 수 있는지 측정함"` + + **해설 (explanation) — 채점 기준 표 형식으로 작성**: + 요소별로 개별 점수를 부여하여 학습자에게 상세한 진단 피드백을 제공합니다. + + - `**채점 기준 표**`: 핵심 요소별로 배점·충족 기준·부분 점수 기준을 **표**로 명시 + - 구조 (마크다운 표): + | 요소 | 충족 (N점) | 부분 충족 (N점) | 미충족 (0점) | + |---|---|---|---| + | 요소명 [카테고리, N점] | 충족 조건 기술 | 부분 충족 조건 기술 | 미충족 조건 기술 | + | 요소명 [카테고리, N점] | 충족 조건 기술 | 부분 충족 조건 기술 | 미충족 조건 기술 | + | ... | ... | ... | ... | + - **배점 설계 원칙**: + · **배점 결정 기준 2가지** — 각 요소의 배점은 아래 두 축을 모두 고려하여 결정한다: + (1) **내용 핵심성**: 질문의 핵심 논점에 직접 답하는 요소일수록 높은 배점 + (2) **인지적 난이도**: 단순 사실 확인 < 개념 설명 < 비교·분석 < 인과 추론·판단 순으로 높은 배점 + · **배점 범위**: 요소당 1~5점. 사실 확인 1~2점, 개념 설명 2~3점, 분석·추론·판단 3~5점 + · **균형 제약**: 가장 높은 배점이 가장 낮은 배점의 3배를 초과하지 않는다 + · **부분 충족**: 해당 요소 만점의 50% (소수점 이하 반올림) + · **수준 기술 규칙**: "우수함", "미흡함" 같은 평가적 표현을 사용하지 말고, 학생 답안에서 관찰 가능한 구체적 특성을 기술한다 + - (X) "핵심 개념을 우수하게 서술함" + - (O) "클라이언트의 SYN 전송과 초기 시퀀스 번호 설정을 모두 언급" + · **충족 조건 구체화 규칙**: "논리적으로 서술", "체계적으로 분석" 같은 추상적 동사를 사용하지 말고, 학생 답안에서 확인 가능한 구체적 내용을 기술한다 + - (X) "인과 관계를 논리적으로 연결하여 서술" + - (O) "A가 B를 유발하는 경로(A → C → B)를 명시" + · **부분 충족 긍정 기술**: 부분 충족은 "충족 못 한 것"의 부정형이 아니라, 학생이 실제로 보여줄 수 있는 불완전한 답변의 특성을 기술한다 + - (X) "인과적 연결을 서술하지 않음" + - (O) "결과만 언급하고 원인이나 매개 과정을 생략" + · **내용 우선 원칙**: 표현의 정교함이나 문법이 아닌, 핵심 개념의 정확성과 논리적 연결에 배점을 집중한다 + - `**근거**`: 근거들을 페이지 번호와 함께 인용함 + - 구조: [Np] > "강의노트 원문 인용"\\n[Np] > "강의노트 원문 인용", ... + - `**스스로 점검**`: 이 문제의 서술 과정에서 놓치기 쉬운 사고 패턴을 질문 형태로 1개 제시 + + --- + + # 완성본 예시 + ```json + { + "questions": [{ + "content": "**TCP 3-way handshake**의 각 단계(SYN, SYN-ACK, ACK)가 **신뢰성 있는 연결 설정**에 기여하는 방식을 3가지 측면에서 서술하시오.", + "referencedPages": [8, 12], + "bloomsLevel": "Analyze — 설명형: TCP 3-way handshake의 각 단계별 기능을 분해하여 설명할 수 있는지 측정함", + "modelAnswer": "TCP 3-way handshake의 첫 단계에서 클라이언트가 SYN 패킷을 전송하여 연결 요청을 시작하고, 초기 시퀀스 번호를 설정한다. 서버는 SYN-ACK 패킷으로 응답하여 클라이언트의 요청을 수락하고, 서버 측 시퀀스 번호를 전달한다. 이 단계에서 양방향 통신의 기반이 마련된다. 마지막으로 클라이언트가 ACK 패킷을 전송하여 연결이 완전히 설정되며, 이후 양측이 합의된 시퀀스 번호를 기반으로 신뢰성 있는 데이터 전송을 시작할 수 있다.", + "explanation": "| 요소 | 충족 | 부분 충족 | 미충족 (0점) |\\n|---|---|---|---|\\n| SYN 단계 — 연결 요청과 시퀀스 번호 설정 [사실 확인, 2점] | (2점) 클라이언트의 SYN 전송과 초기 시퀀스 번호 설정을 모두 언급 | (1점) SYN 전송만 언급하고 시퀀스 번호 설정을 누락 | SYN 단계를 언급하지 않음 |\\n| SYN-ACK 단계 — 수락 응답과 양방향 통신 기반 [개념 설명, 3점] | (3점) 서버의 SYN-ACK 응답과 서버 측 시퀀스 번호 전달을 언급하고, 양측 시퀀스 번호 교환이 양방향 통신을 가능하게 하는 원리를 명시 | (2점) 서버 응답은 언급하나 시퀀스 번호 교환과 양방향 통신의 관계를 생략 | SYN-ACK 단계를 언급하지 않음 |\\n| ACK 단계와 신뢰성 확보의 인과적 연결 [분석·추론, 핵심 논점, 4점] | (4점) ACK 전송으로 연결이 완성됨을 언급하고, 시퀀스 번호 합의 → 순서 보장 → 신뢰성 확보의 인과 경로를 명시 | (2점) ACK 전송으로 연결 완성은 언급하나, 시퀀스 번호와 신뢰성 사이의 인과 경로를 생략 | ACK 단계를 언급하지 않음 |\\n\\n- **근거**: [8p] > \\"TCP는 3-way handshake를 통해 연결을 설정한다\\"\\n[12p] > \\"SYN → SYN-ACK → ACK 순서로 양측의 시퀀스 번호를 교환한다\\"\\n- **스스로 점검**: 각 단계의 '동작'만 나열하지 않고, 각 단계가 '신뢰성 확보'에 어떻게 기여하는지까지 서술했나요?" + }] + } + ``` + """; +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayRequestPrompt.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayRequestPrompt.java new file mode 100644 index 00000000..fb3ab64a --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/essay/prompt/EssayRequestPrompt.java @@ -0,0 +1,93 @@ +package com.icc.qasker.ai.service.essay.prompt; + +import java.util.List; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** ESSAY 퀴즈 전용 유저 프롬프트. Analyze/Evaluate 2수준. 1청크 단일 호출. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EssayRequestPrompt { + + private static final String APPLIED_INSTRUCTION_SPEC = + """ + # 사용자 지시 반영 + - 지시가 특정 형식을 요청하면, 그 형식에 대응하는 전략이 존재하면 해당 전략을 우선 선택한다. + - 대응 전략이 없는 형식은 요청 형식에 맞게 질문문을 자유롭게 구성한다. + + # 사용자 지시 반영 결과 기록 + - 사용자 지시를 반영한 내용을 `appliedInstruction` 필드에 1~2문장으로 기록한다. + - 기록 형식: "사용자 지시 '{지시 내용}'을 반영하여 {구체적으로 무엇을 어떻게 바꿨는지}." + """; + + public static String generate(List referencePages, int quizCount) { + return generate(referencePages, quizCount, null); + } + + /** + * exclusionExtra가 있으면 샌드위치 구조로 삽입한다. 앞에 reminder, 뒤에 critical_user_override 태그를 배치하여 primacy + * bias와 recency bias를 모두 활용한다. + */ + public static String generate( + List referencePages, int quizCount, String exclusionExtra) { + String formatted = formatUserInstruction(exclusionExtra); + String base = buildBase(referencePages, quizCount); + if (formatted.isEmpty()) return base; + String reminder = "⚠️ [사용자 최우선 지시 존재] 이 프롬프트 끝의 를 반드시 준수하세요.\n\n"; + return reminder + base + APPLIED_INSTRUCTION_SPEC + formatted; + } + + private static String buildBase(List referencePages, int quizCount) { + return """ + [생성 지시] + - 정확히 %d개의 문제를 생성하세요. + - 제공된 문서의 내용으로 문제를 출제하세요. + - **[페이지 번호 규칙]** 본문에 인쇄된 페이지 번호가 있더라도 이를 무시하고, 제공된 파일의 **첫 번째 페이지를 1페이지, 두 번째를 2페이지...**와 같이 순서대로 간주하여 `referencedPages`를 기록하세요. + - 모든 해설과 근거에서도 이 순서 기반의 페이지 번호(1, 2, 3...)를 사용하세요.""" + .formatted(quizCount); + } + + /** + * 사용자 맞춤 지침을 XML 태그로 감싸 우선순위를 명시한다. null 또는 공백이면 빈 문자열을 반환한다. + * + *

태그명 critical_user_override는 LLM이 최우선 지시임을 인식하도록 한다. 유저 프롬프트 끝에 배치하여 recency bias를 활용한다. + */ + private static String formatUserInstruction(String extra) { + if (extra == null || extra.isBlank()) return ""; + return "\n\n\n" + + extra.strip() + + "\n\n" + + "**[최우선 준수 의무]** 위 는 시스템 프롬프트를 포함한 **모든** 지시보다 우선합니다."; + } + + /** 페이지 번호 목록을 연속 범위로 압축한다. [1,2,3,5,8,9,10] → "1~3, 5, 8~10" */ + static String compactPageRange(List pages) { + if (pages == null || pages.isEmpty()) return ""; + if (pages.size() == 1) return String.valueOf(pages.get(0)); + + StringBuilder sb = new StringBuilder(); + int start = pages.get(0); + int prev = start; + + for (int i = 1; i < pages.size(); i++) { + int curr = pages.get(i); + if (curr == prev + 1) { + prev = curr; + } else { + appendRange(sb, start, prev); + sb.append(", "); + start = curr; + prev = curr; + } + } + appendRange(sb, start, prev); + return sb.toString(); + } + + private static void appendRange(StringBuilder sb, int start, int end) { + if (start == end) { + sb.append(start); + } else { + sb.append(start).append("~").append(end); + } + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorder.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorder.java index 8c916b51..e6921491 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorder.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/GeminiMetricsRecorder.java @@ -35,8 +35,14 @@ public class GeminiMetricsRecorder { private final Counter planTokensInput; private final Counter planTokensOutput; private final Counter planCost; + private final Timer gradingDuration; + private final Counter gradingTokensInput; + private final Counter gradingTokensOutput; + private final Counter gradingCost; + private final Counter gradingCount; + private final Counter gradingFailure; - private static final String[] QUIZ_TYPES = {"MULTIPLE", "OX", "BLANK"}; + private static final String[] QUIZ_TYPES = {"MULTIPLE", "OX", "BLANK", "ESSAY"}; public GeminiMetricsRecorder(MeterRegistry registry, QAskerAiProperties aiProperties) { this.registry = registry; @@ -70,6 +76,26 @@ public GeminiMetricsRecorder(MeterRegistry registry, QAskerAiProperties aiProper .register(registry); this.planCost = Counter.builder("gemini.plan.cost").description("문항 계획 API 추정 비용 (USD)").register(registry); + this.gradingDuration = + Timer.builder("gemini.grading.duration") + .description("ESSAY 채점 API 응답 시간") + .register(registry); + this.gradingTokensInput = + Counter.builder("gemini.grading.tokens.input") + .description("ESSAY 채점 API 입력 토큰") + .register(registry); + this.gradingTokensOutput = + Counter.builder("gemini.grading.tokens.output") + .description("ESSAY 채점 API 출력 토큰") + .register(registry); + this.gradingCost = + Counter.builder("gemini.grading.cost") + .description("ESSAY 채점 API 추정 비용 (USD)") + .register(registry); + this.gradingCount = + Counter.builder("gemini.grading.count").description("ESSAY 채점 요청 횟수").register(registry); + this.gradingFailure = + Counter.builder("gemini.grading.failure").description("ESSAY 채점 실패 횟수").register(registry); this.chunkDuration = Timer.builder("gemini.chunk.duration") @@ -174,6 +200,20 @@ public void recordEqualization(long inputTokens, long outputTokens, double cost) eqCost.increment(cost); } + /** ESSAY 채점 완료 메트릭을 기록한다. */ + public void recordGrading(long elapsedMs, long inputTokens, long outputTokens, double cost) { + gradingDuration.record(elapsedMs, TimeUnit.MILLISECONDS); + gradingTokensInput.increment(inputTokens); + gradingTokensOutput.increment(outputTokens); + gradingCost.increment(cost); + gradingCount.increment(); + } + + /** ESSAY 채점 실패를 기록한다. */ + public void recordGradingFailure() { + gradingFailure.increment(); + } + /** 스트리밍 타임아웃 발생을 기록한다. */ public void recordStreamingTimeout(String quizType) { Counter.builder("gemini.streaming.timeout") diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingEssayQuestionExtractor.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingEssayQuestionExtractor.java new file mode 100644 index 00000000..5ee3d6fb --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/support/StreamingEssayQuestionExtractor.java @@ -0,0 +1,100 @@ +package com.icc.qasker.ai.service.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.icc.qasker.ai.structure.GeminiEssayQuestion; +import java.util.function.Consumer; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * 스트리밍 JSON 응답에서 GeminiEssayQuestion 객체가 완성될 때마다 콜백을 호출한다. + * + *

Gemini 응답 형식: {"questions": [{Q1}, {Q2}, ...]} + * + *

questions 배열 내부의 각 객체({...})가 완성되면 즉시 파싱하여 consumer에 전달한다. + */ +@Slf4j +public class StreamingEssayQuestionExtractor { + + private final ObjectMapper objectMapper; + private final Consumer questionConsumer; + + private final StringBuilder buffer = new StringBuilder(); + private boolean inArray = false; + private int braceDepth = 0; + private int objectStart = -1; + private boolean inString = false; + private boolean escaped = false; + + @Getter private int questionCount = 0; + + public StreamingEssayQuestionExtractor( + ObjectMapper objectMapper, Consumer questionConsumer) { + this.objectMapper = objectMapper; + this.questionConsumer = questionConsumer; + } + + public void feed(String chunk) { + if (chunk == null) return; + + for (int i = 0; i < chunk.length(); i++) { + char c = chunk.charAt(i); + buffer.append(c); + + if (escaped) { + escaped = false; + continue; + } + if (c == '\\' && inString) { + escaped = true; + continue; + } + if (c == '"') { + inString = !inString; + continue; + } + if (inString) continue; + + if (c == '[' && !inArray) { + inArray = true; + continue; + } + + if (!inArray) continue; + + if (c == '{') { + if (braceDepth == 0) { + objectStart = buffer.length() - 1; + } + braceDepth++; + } else if (c == '}') { + braceDepth--; + if (braceDepth == 0 && objectStart >= 0) { + String objectJson = buffer.substring(objectStart, buffer.length()); + emitQuestion(objectJson); + objectStart = -1; + } + } + } + } + + private void emitQuestion(String json) { + GeminiEssayQuestion question; + try { + question = objectMapper.readValue(json, GeminiEssayQuestion.class); + } catch (Exception e) { + log.warn( + "ESSAY JSON 파싱 실패: {} | JSON 길이: {} | JSON 앞 300자: {}", + e.getMessage(), + json.length(), + json.length() > 300 ? json.substring(0, 300) : json); + return; + } + try { + questionCount++; + questionConsumer.accept(question); + } catch (Exception e) { + log.warn("ESSAY 문항 처리 실패: {}", e.getMessage(), e); + } + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEssayQuestion.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEssayQuestion.java new file mode 100644 index 00000000..b9ee8ad3 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEssayQuestion.java @@ -0,0 +1,14 @@ +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 GeminiEssayQuestion( + @JsonPropertyDescription("질문문") String content, + @JsonPropertyDescription("이 문항에 적용된 Bloom's 수준") String bloomsLevel, + @JsonPropertyDescription("참조한 강의노트 페이지 번호") List referencedPages, + @JsonPropertyDescription("모범답안 (핵심 요소 포함)") String modelAnswer, + @JsonPropertyDescription("해설 (분석적 루브릭 포함)") String explanation, + @JsonPropertyDescription("사용자 지시 반영 결과") String appliedInstruction) {} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEssayResponse.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEssayResponse.java new file mode 100644 index 00000000..b76d15c6 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEssayResponse.java @@ -0,0 +1,9 @@ +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 GeminiEssayResponse( + @JsonPropertyDescription("서술형 문제 목록 — 문제+모범답안+해설 모두 포함") List questions) {} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEssayResponseSchema.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEssayResponseSchema.java new file mode 100644 index 00000000..f7f01ae8 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEssayResponseSchema.java @@ -0,0 +1,67 @@ +package com.icc.qasker.ai.structure; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.ai.converter.BeanOutputConverter; + +/** ESSAY 전용 JSON 스키마. customInstruction 유무에 따라 appliedInstruction 필드를 포함/제외한다. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class GeminiEssayResponseSchema { + + private static final String WITH_INSTRUCTION = + new BeanOutputConverter<>(GeminiEssayResponse.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 (JsonProcessingException 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.fields() + .forEachRemaining( + entry -> { + if (entry.getValue().isObject()) { + stripFieldRecursive(entry.getValue(), fieldName); + } + }); + } +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEvidenceExtractionResponse.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEvidenceExtractionResponse.java new file mode 100644 index 00000000..74ff1e72 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiEvidenceExtractionResponse.java @@ -0,0 +1,17 @@ +package com.icc.qasker.ai.structure; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import java.util.List; + +/** Pass 1 증거 추출 AI 응답 구조체. 루브릭 요소별로 학생 답안에서 인용된 증거를 담는다. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GeminiEvidenceExtractionResponse( + @JsonPropertyDescription("루브릭 요소별 증거 추출 결과") List elements) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElementEvidence( + @JsonPropertyDescription("채점 요소명 (루브릭에 명시된 그대로)") String element, + @JsonPropertyDescription("학생 답안에서 이 요소와 관련된 원문 인용. 관련 서술이 없으면 빈 문자열") String quotedEvidence, + @JsonPropertyDescription("이 요소에서 학생이 언급하지 않은 핵심 개념 목록") List missingConcepts) {} +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiFirstAttemptGradingResponse.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiFirstAttemptGradingResponse.java new file mode 100644 index 00000000..f138820d --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiFirstAttemptGradingResponse.java @@ -0,0 +1,20 @@ +package com.icc.qasker.ai.structure; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import java.util.List; + +/** ESSAY 1차 시도 채점 AI 응답 구조체. 피드백 없이 요소명 + 점수 + 수행수준만 반환한다. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GeminiFirstAttemptGradingResponse( + @JsonPropertyDescription("요소별 채점 결과 목록") List elementScores, + @JsonPropertyDescription("획득 총점") int totalScore, + @JsonPropertyDescription("만점") int maxScore) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElementScore( + @JsonPropertyDescription("채점 요소명") String element, + @JsonPropertyDescription("해당 요소의 만점") int maxPoints, + @JsonPropertyDescription("획득 점수") int earnedPoints, + @JsonPropertyDescription("수행 수준: 충족 / 부분 충족 / 미충족") String level) {} +} diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiGradingResponse.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiGradingResponse.java new file mode 100644 index 00000000..e0a6c129 --- /dev/null +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/structure/GeminiGradingResponse.java @@ -0,0 +1,22 @@ +package com.icc.qasker.ai.structure; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import java.util.List; + +/** ESSAY 채점 AI 응답 구조체. 분석적 루브릭 기반 요소별 채점 결과를 담는다. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GeminiGradingResponse( + @JsonPropertyDescription("요소별 채점 결과 목록") List elementScores, + @JsonPropertyDescription("획득 총점") int totalScore, + @JsonPropertyDescription("만점") int maxScore, + @JsonPropertyDescription("종합 피드백 — 잘한 점, 부족한 점, 개선 방향을 포함") String overallFeedback) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElementScore( + @JsonPropertyDescription("채점 요소명") String element, + @JsonPropertyDescription("해당 요소의 만점") int maxPoints, + @JsonPropertyDescription("획득 점수") int earnedPoints, + @JsonPropertyDescription("수행 수준: 충족 / 부분 충족 / 미충족") String level, + @JsonPropertyDescription("해당 요소에 대한 구체적 피드백") String feedback) {} +} diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/QuizHistoryQueryService.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/QuizHistoryQueryService.java index c80cfcb8..934d85fe 100644 --- a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/QuizHistoryQueryService.java +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/QuizHistoryQueryService.java @@ -1,5 +1,6 @@ package com.icc.qasker.quizhistory; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistoryCheckResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistoryDetailResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistoryPageResponse; @@ -10,5 +11,7 @@ public interface QuizHistoryQueryService { HistoryDetailResponse getHistoryDetail(String userId, String historyId); + EssayHistoryDetailResponse getEssayHistoryDetail(String userId, String historyId); + HistoryCheckResponse checkHistory(String userId, String problemSetId); } diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/EssayGradeRequest.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/EssayGradeRequest.java new file mode 100644 index 00000000..ab6bccc4 --- /dev/null +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/EssayGradeRequest.java @@ -0,0 +1,15 @@ +package com.icc.qasker.quizhistory.dto.ferequest; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record EssayGradeRequest( + @NotBlank(message = "답안이 비어있습니다.") @Size(max = 1000, message = "답안은 1000자를 초과할 수 없습니다.") + String textAnswer, + @NotNull(message = "시도 횟수가 비어있습니다.") + @Min(value = 1, message = "시도 횟수는 1 이상이어야 합니다.") + @Max(value = 4, message = "시도 횟수는 4 이하여야 합니다.") + Integer attemptCount) {} diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/SaveHistoryRequest.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/SaveHistoryRequest.java index 0bacf207..5cf3f263 100644 --- a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/SaveHistoryRequest.java +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/SaveHistoryRequest.java @@ -1,5 +1,6 @@ package com.icc.qasker.quizhistory.dto.ferequest; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.util.List; @@ -7,6 +8,6 @@ public record SaveHistoryRequest( @NotBlank(message = "problemSetId가 null입니다.") String problemSetId, String title, - @NotNull(message = "userAnswers가 null입니다.") List userAnswers, + @Valid @NotNull(message = "userAnswers가 null입니다.") List userAnswers, int score, String totalTime) {} diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/UserAnswer.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/UserAnswer.java index 8f396963..0a3fb7ad 100644 --- a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/UserAnswer.java +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/UserAnswer.java @@ -1,3 +1,9 @@ package com.icc.qasker.quizhistory.dto.ferequest; -public record UserAnswer(int number, int userAnswer, boolean inReview) {} +import jakarta.validation.constraints.Size; + +public record UserAnswer( + int number, + int userAnswer, + boolean inReview, + @Size(max = 1000, message = "답안은 1000자를 초과할 수 없습니다.") String textAnswer) {} diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/EssayGradeResponse.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/EssayGradeResponse.java new file mode 100644 index 00000000..3c6860e6 --- /dev/null +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/EssayGradeResponse.java @@ -0,0 +1,14 @@ +package com.icc.qasker.quizhistory.dto.feresponse; + +import java.util.List; + +/** ESSAY 채점 결과 응답 DTO. */ +public record EssayGradeResponse( + List elementScores, + int totalScore, + int maxScore, + String overallFeedback) { + + public record ElementScoreResponse( + String element, int maxPoints, int earnedPoints, String level, String feedback) {} +} diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/EssayHistoryDetailResponse.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/EssayHistoryDetailResponse.java new file mode 100644 index 00000000..f9bb4ea6 --- /dev/null +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/EssayHistoryDetailResponse.java @@ -0,0 +1,35 @@ +package com.icc.qasker.quizhistory.dto.feresponse; + +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import java.time.Instant; +import java.util.List; + +/** ESSAY 히스토리 상세 응답 DTO. 문제별 최신 채점 결과를 포함한다. */ +public record EssayHistoryDetailResponse( + String historyId, + String problemSetId, + QuizType quizType, + int totalCount, + String totalTime, + Instant takenAt, + List problems) { + + public record EssayProblemWithGrade( + int number, + String title, + String textAnswer, + boolean inReview, + List selections, + GradeResult gradeResult) {} + + public record EssaySelection(int id, String content) {} + + public record GradeResult( + int totalScore, + int maxScore, + String overallFeedback, + List elementScores) {} + + public record ElementScoreDetail( + String element, int maxPoints, int earnedPoints, String level, String feedback) {} +} 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 e6fcd62e..55c2ff95 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 @@ -9,4 +9,5 @@ public record ProblemWithAnswer( int userAnswer, boolean correct, boolean inReview, - List selections) {} + List selections, + String textAnswer) {} diff --git a/modules/quiz-history/impl/build.gradle b/modules/quiz-history/impl/build.gradle index 73c28387..d39fc7a4 100644 --- a/modules/quiz-history/impl/build.gradle +++ b/modules/quiz-history/impl/build.gradle @@ -13,6 +13,8 @@ dependencies { implementation project(":quiz-history-api") // quiz-api: 퀴즈 관련 인터페이스·DTO (ProblemSetReadService 등) implementation project(":quiz-set-api") + // quiz-ai-api: AI 채점 서비스 인터페이스 (EssayGradingService) + implementation project(":quiz-ai-api") // ──────────────────────────────── // 스프링부트 Web & JPA diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/controller/EssayGradeController.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/controller/EssayGradeController.java new file mode 100644 index 00000000..90e11772 --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/controller/EssayGradeController.java @@ -0,0 +1,42 @@ +package com.icc.qasker.quizhistory.controller; + +import com.icc.qasker.global.annotation.RateLimit; +import com.icc.qasker.global.annotation.UserId; +import com.icc.qasker.global.ratelimit.RateLimitTier; +import com.icc.qasker.quizhistory.dto.ferequest.EssayGradeRequest; +import com.icc.qasker.quizhistory.dto.feresponse.EssayGradeResponse; +import com.icc.qasker.quizhistory.service.EssayGradeService; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** ESSAY 채점 API. 문제별 개별 채점을 수행하고 결과를 반환한다. */ +@Tag(name = "EssayGrade", description = "서술형 채점 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/essay") +public class EssayGradeController { + + private final EssayGradeService essayGradeService; + + @Operation(summary = "서술형 문제를 채점한다") + @RateLimit(RateLimitTier.WRITE) + @PostMapping("/problem-sets/{problemSetId}/problems/{problemNumber}/grade") + public ResponseEntity gradeEssay( + @UserId String userId, + @PathVariable String problemSetId, + @PathVariable int problemNumber, + @Valid @RequestBody EssayGradeRequest request) { + EssayGradeResponse response = + essayGradeService.grade( + userId, problemSetId, problemNumber, request.textAnswer(), request.attemptCount()); + return ResponseEntity.ok(response); + } +} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/controller/QuizHistoryQueryController.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/controller/QuizHistoryQueryController.java index 9cca10c3..0e2b22df 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/controller/QuizHistoryQueryController.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/controller/QuizHistoryQueryController.java @@ -2,6 +2,7 @@ import com.icc.qasker.global.annotation.UserId; import com.icc.qasker.quizhistory.QuizHistoryQueryService; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistoryCheckResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistoryDetailResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistoryPageResponse; @@ -50,4 +51,11 @@ public ResponseEntity getHistoryDetail( @UserId String userId, @PathVariable String historyId) { return ResponseEntity.ok(quizHistoryQueryService.getHistoryDetail(userId, historyId)); } + + @Operation(summary = "ESSAY 히스토리 상세를 조회한다 (문제 + 답안 + 최신 채점 결과)") + @GetMapping("/{historyId}/essay") + public ResponseEntity getEssayHistoryDetail( + @UserId String userId, @PathVariable String historyId) { + return ResponseEntity.ok(quizHistoryQueryService.getEssayHistoryDetail(userId, historyId)); + } } diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/AnswerSnapshot.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/AnswerSnapshot.java index b441c721..66193aeb 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/AnswerSnapshot.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/AnswerSnapshot.java @@ -1,3 +1,3 @@ package com.icc.qasker.quizhistory.entity; -public record AnswerSnapshot(int number, int userAnswer, boolean inReview) {} +public record AnswerSnapshot(int number, int userAnswer, boolean inReview, String textAnswer) {} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/EssayGradeLog.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/EssayGradeLog.java new file mode 100644 index 00000000..d829061b --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/EssayGradeLog.java @@ -0,0 +1,67 @@ +package com.icc.qasker.quizhistory.entity; + +import com.icc.qasker.global.entity.CreatedAt; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** ESSAY 채점 API 호출 로그. 비동기로 저장된다. */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +@Table(name = "essay_grade_log") +public class EssayGradeLog extends CreatedAt { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String userId; + + @Column(nullable = false) + private Long problemSetId; + + @Column(nullable = false) + private int problemNumber; + + @Column(nullable = false, length = 500) + private String question; + + @Column(nullable = false, columnDefinition = "TEXT") + private String studentAnswer; + + @Column(nullable = false) + private int attemptCount; + + @Column(nullable = false) + private int totalScore; + + @Column(nullable = false) + private int maxScore; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "JSON") + private List elementScores; + + @Column(columnDefinition = "TEXT") + private String overallFeedback; + + @Column(columnDefinition = "JSON") + private String evidenceJson; + + public record ElementScoreSnapshot( + String element, int maxPoints, int earnedPoints, String level, String feedback) {} +} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/repository/EssayGradeLogRepository.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/repository/EssayGradeLogRepository.java new file mode 100644 index 00000000..f89489f9 --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/repository/EssayGradeLogRepository.java @@ -0,0 +1,23 @@ +package com.icc.qasker.quizhistory.repository; + +import com.icc.qasker.quizhistory.entity.EssayGradeLog; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface EssayGradeLogRepository extends JpaRepository { + + /** 각 문제별 가장 최신 채점 로그를 조회한다. */ + @Query( + """ + SELECT e FROM EssayGradeLog e + WHERE e.userId = :userId AND e.problemSetId = :problemSetId + AND e.createdAt = ( + SELECT MAX(e2.createdAt) FROM EssayGradeLog e2 + WHERE e2.userId = e.userId + AND e2.problemSetId = e.problemSetId + AND e2.problemNumber = e.problemNumber + ) + """) + List findLatestByUserIdAndProblemSetId(String userId, Long problemSetId); +} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/EssayGradeService.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/EssayGradeService.java new file mode 100644 index 00000000..328f409e --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/EssayGradeService.java @@ -0,0 +1,135 @@ +package com.icc.qasker.quizhistory.service; + +import com.icc.qasker.ai.EssayGradingService; +import com.icc.qasker.ai.dto.EssayGradingResult; +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.feresponse.EssayGradeResponse; +import com.icc.qasker.quizhistory.dto.feresponse.EssayGradeResponse.ElementScoreResponse; +import com.icc.qasker.quizhistory.entity.EssayGradeLog; +import com.icc.qasker.quizhistory.entity.EssayGradeLog.ElementScoreSnapshot; +import com.icc.qasker.quizhistory.repository.EssayGradeLogRepository; +import com.icc.qasker.quizset.ProblemSetReadService; +import com.icc.qasker.quizset.dto.readonly.ProblemDetail; +import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** ESSAY 채점 서비스. Problem 조회 → AI 채점 → 응답 반환. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EssayGradeService { + + private final ProblemSetReadService problemSetReadService; + private final EssayGradingService essayGradingService; + private final EssayGradeLogRepository essayGradeLogRepository; + private final HashUtil hashUtil; + + public EssayGradeResponse grade( + String userId, String problemSetId, int problemNumber, String textAnswer, int attemptCount) { + Long decodedProblemSetId = hashUtil.decode(problemSetId); + + // 문제 조회 + List problems = + problemSetReadService.findProblemsByProblemSetId(decodedProblemSetId); + ProblemDetail problem = + problems.stream() + .filter(p -> p.number() == problemNumber) + .findFirst() + .orElseThrow(() -> new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND)); + + // 모범답안 추출 (selections[0].content) + String modelAnswer = + problem.selections().stream() + .filter(SelectionDetail::correct) + .map(SelectionDetail::content) + .findFirst() + .orElseThrow(() -> new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND)); + + // 루브릭 추출 (explanationContent) + String rubric = problem.explanationContent(); + if (rubric == null || rubric.isBlank()) { + throw new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND); + } + + // AI 채점 (시도 횟수에 따라 피드백 구체성 차등 적용) + EssayGradingResult result = + essayGradingService.grade(problem.title(), modelAnswer, rubric, textAnswer, attemptCount); + + // 비동기 로그 저장 + saveLogAsync( + userId, + decodedProblemSetId, + problemNumber, + problem.title(), + textAnswer, + attemptCount, + result); + + // 응답 변환 + List responseScores = + result.elementScores().stream() + .map( + e -> + new ElementScoreResponse( + e.element(), e.maxPoints(), e.earnedPoints(), e.level(), e.feedback())) + .toList(); + + return new EssayGradeResponse( + responseScores, result.totalScore(), result.maxScore(), result.overallFeedback()); + } + + private void saveLogAsync( + String userId, + Long problemSetId, + int problemNumber, + String question, + String studentAnswer, + int attemptCount, + EssayGradingResult result) { + CompletableFuture.runAsync( + () -> { + try { + List snapshots = + result.elementScores().stream() + .map( + e -> + new ElementScoreSnapshot( + e.element(), + e.maxPoints(), + e.earnedPoints(), + e.level(), + e.feedback())) + .toList(); + + EssayGradeLog log = + EssayGradeLog.builder() + .userId(userId) + .problemSetId(problemSetId) + .problemNumber(problemNumber) + .question(question) + .studentAnswer(studentAnswer) + .attemptCount(attemptCount) + .totalScore(result.totalScore()) + .maxScore(result.maxScore()) + .elementScores(snapshots) + .overallFeedback(result.overallFeedback()) + .evidenceJson(result.evidenceJson()) + .build(); + + essayGradeLogRepository.save(log); + } catch (Exception e) { + log.warn( + "서술형 채점 로그 저장 실패: problemSetId={}, problemNumber={}", + problemSetId, + problemNumber, + e); + } + }); + } +} 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 2e27f19c..be5207a6 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 @@ -49,7 +49,7 @@ public String initHistory(String userId, InitHistoryRequest request) { public String saveHistory(String userId, SaveHistoryRequest request) { List snapshots = request.userAnswers().stream() - .map(a -> new AnswerSnapshot(a.number(), a.userAnswer(), a.inReview())) + .map(a -> new AnswerSnapshot(a.number(), a.userAnswer(), a.inReview(), a.textAnswer())) .toList(); QuizHistory history = 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 d0df6f5f..5961894c 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 @@ -4,14 +4,21 @@ import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; import com.icc.qasker.quizhistory.QuizHistoryQueryService; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.ElementScoreDetail; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.EssayProblemWithGrade; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.EssaySelection; +import com.icc.qasker.quizhistory.dto.feresponse.EssayHistoryDetailResponse.GradeResult; import com.icc.qasker.quizhistory.dto.feresponse.HistoryCheckResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistoryDetailResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistoryPageResponse; import com.icc.qasker.quizhistory.dto.feresponse.HistorySummaryResponse; import com.icc.qasker.quizhistory.dto.feresponse.ProblemWithAnswer; import com.icc.qasker.quizhistory.entity.AnswerSnapshot; +import com.icc.qasker.quizhistory.entity.EssayGradeLog; import com.icc.qasker.quizhistory.entity.QuizHistory; import com.icc.qasker.quizhistory.mapper.QuizHistoryMapper; +import com.icc.qasker.quizhistory.repository.EssayGradeLogRepository; import com.icc.qasker.quizhistory.repository.QuizHistoryRepository; import com.icc.qasker.quizset.ProblemSetReadService; import com.icc.qasker.quizset.dto.feresponse.Selection; @@ -35,6 +42,7 @@ public class QuizHistoryQueryServiceImpl implements QuizHistoryQueryService { private final QuizHistoryRepository quizHistoryRepository; + private final EssayGradeLogRepository essayGradeLogRepository; private final ProblemSetReadService problemSetReadService; private final HashUtil hashUtil; private final QuizHistoryMapper quizHistoryMapper; @@ -105,6 +113,11 @@ public HistoryDetailResponse getHistoryDetail(String userId, String historyId) { history.getAnswers().stream() .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::inReview)); + Map textAnswerMap = + history.getAnswers().stream() + .filter(a -> a.textAnswer() != null) + .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::textAnswer)); + List problemWithAnswers = problems.stream() .map( @@ -124,8 +137,9 @@ public HistoryDetailResponse getHistoryDetail(String userId, String historyId) { rawSelections.get(i).correct())) .toList(); + String textAnswer = textAnswerMap.get(p.number()); return new ProblemWithAnswer( - p.number(), p.title(), userAnswer, correct, inReview, selections); + p.number(), p.title(), userAnswer, correct, inReview, selections, textAnswer); }) .toList(); @@ -140,6 +154,89 @@ public HistoryDetailResponse getHistoryDetail(String userId, String historyId) { problemWithAnswers); } + @Override + public EssayHistoryDetailResponse getEssayHistoryDetail(String userId, String historyId) { + long id = hashUtil.decode(historyId); + QuizHistory history = + quizHistoryRepository + .findById(id) + .filter(h -> h.getUserId().equals(userId)) + .orElseThrow(() -> new CustomException(ExceptionMessage.QUIZ_HISTORY_NOT_FOUND)); + + Long problemSetId = history.getProblemSetId(); + ProblemSetSummary problemSet = + problemSetReadService + .findProblemSetById(problemSetId) + .orElseThrow(() -> new CustomException(ExceptionMessage.PROBLEM_SET_NOT_FOUND)); + + List problems = problemSetReadService.findProblemsByProblemSetId(problemSetId); + + // 답안 스냅샷 매핑 + Map textAnswerMap = + history.getAnswers().stream() + .filter(a -> a.textAnswer() != null) + .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::textAnswer)); + Map inReviewMap = + history.getAnswers().stream() + .collect(Collectors.toMap(AnswerSnapshot::number, AnswerSnapshot::inReview)); + + // 문제별 최신 채점 결과 조회 + Map gradeLogMap = + essayGradeLogRepository.findLatestByUserIdAndProblemSetId(userId, problemSetId).stream() + .collect(Collectors.toMap(EssayGradeLog::getProblemNumber, Function.identity())); + + List essayProblems = + problems.stream() + .map( + p -> { + // selections (id, content만 노출) + List selections = + IntStream.range(0, p.selections().size()) + .mapToObj(i -> new EssaySelection(i + 1, p.selections().get(i).content())) + .toList(); + + // 채점 결과 변환 + EssayGradeLog gradeLog = gradeLogMap.get(p.number()); + GradeResult gradeResult = toGradeResult(gradeLog); + + return new EssayProblemWithGrade( + p.number(), + p.title(), + textAnswerMap.get(p.number()), + inReviewMap.getOrDefault(p.number(), false), + selections, + gradeResult); + }) + .toList(); + + return new EssayHistoryDetailResponse( + hashUtil.encode(history.getId()), + hashUtil.encode(problemSetId), + problemSet.quizType(), + problemSet.totalQuizCount(), + history.getTotalTime(), + history.getCreatedAt(), + essayProblems); + } + + private GradeResult toGradeResult(EssayGradeLog gradeLog) { + if (gradeLog == null) { + return null; + } + List elementScores = + gradeLog.getElementScores().stream() + .map( + e -> + new ElementScoreDetail( + e.element(), e.maxPoints(), e.earnedPoints(), e.level(), e.feedback())) + .toList(); + return new GradeResult( + gradeLog.getTotalScore(), + gradeLog.getMaxScore(), + gradeLog.getOverallFeedback(), + elementScores); + } + @Override public HistoryCheckResponse checkHistory(String userId, String problemSetId) { long id = hashUtil.decode(problemSetId); diff --git a/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/airequest/GenerationRequestToAI.java b/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/airequest/GenerationRequestToAI.java index c08da608..77cb82e7 100644 --- a/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/airequest/GenerationRequestToAI.java +++ b/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/airequest/GenerationRequestToAI.java @@ -1,15 +1,10 @@ package com.icc.qasker.quizmake.dto.airequest; -import com.icc.qasker.quizmake.dto.ferequest.enums.DifficultyType; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import java.util.List; import lombok.Builder; @Builder public record GenerationRequestToAI( - String uploadedUrl, - int quizCount, - QuizType quizType, - DifficultyType difficultyType, - List pageNumbers) {} + String uploadedUrl, int quizCount, QuizType quizType, List pageNumbers) {} ; diff --git a/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/ferequest/GenerationRequest.java b/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/ferequest/GenerationRequest.java index b769b317..7f2a9b00 100644 --- a/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/ferequest/GenerationRequest.java +++ b/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/ferequest/GenerationRequest.java @@ -1,6 +1,5 @@ package com.icc.qasker.quizmake.dto.ferequest; -import com.icc.qasker.quizmake.dto.ferequest.enums.DifficultyType; import com.icc.qasker.quizmake.dto.ferequest.enums.Language; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import jakarta.validation.constraints.Min; @@ -20,7 +19,6 @@ public record GenerationRequest( @NotBlank(message = "title이 존재하지 않습니다.") String title, int quizCount, @NotNull(message = "quizType이 null입니다.") QuizType quizType, - @NotNull(message = "difficultyType가 null입니다.") DifficultyType difficultyType, @NotNull(message = "pageNumbers가 null입니다.") @Size(min = 1, max = 150, message = "pageNumbers는 1개 이상 150 이하이어야 합니다.") List< diff --git a/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/ferequest/enums/DifficultyType.java b/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/ferequest/enums/DifficultyType.java deleted file mode 100644 index b61d9a74..00000000 --- a/modules/quiz-make/api/src/main/java/com/icc/qasker/quizmake/dto/ferequest/enums/DifficultyType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.icc.qasker.quizmake.dto.ferequest.enums; - -public enum DifficultyType { - RECALL, - SKILLS, - STRATEGIC -} diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/EssayExplanationMarkdownBuilder.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/EssayExplanationMarkdownBuilder.java new file mode 100644 index 00000000..810b3f1f --- /dev/null +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/mapper/EssayExplanationMarkdownBuilder.java @@ -0,0 +1,71 @@ +package com.icc.qasker.quizmake.mapper; + +import com.icc.qasker.quizmake.dto.ferequest.enums.Language; +import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI; +import com.icc.qasker.quizset.dto.airesponse.ProblemSetGeneratedEvent.QuizGeneratedFromAI.SelectionsOfAI; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 에세이 문제의 해설 마크다운을 조립한다. + * + *

bloomsLevel(Bloom's 수준 태그), 모범답안(correct selection), 분석적 루브릭(explanation)을 조합한다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EssayExplanationMarkdownBuilder { + + /** + * 에세이 문제의 해설 마크다운을 조립한다. + * + * @param quiz 문제 (selections에 모범답안 1개 + explanation에 루브릭) + * @param language 언어 + * @return 병합된 마크다운 문자열 + */ + public static String build(QuizGeneratedFromAI quiz, Language language) { + String modelAnswerHeader = language == Language.EN ? "## Model Answer" : "## 모범답안"; + String rubricHeader = language == Language.EN ? "## Scoring Rubric" : "## 채점 기준 표"; + + StringBuilder sb = new StringBuilder(); + + // 1. Bloom's 수준 태그er + if (hasText(quiz.getBloomsLevel())) { + String raw = quiz.getBloomsLevel().strip(); + sb.append("- **평가 수준**: ").append(raw); + sb.append("\n\n---\n\n"); + } + + // 2. 모범답안 (correct selection의 content) + if (quiz.getSelections() != null) { + for (SelectionsOfAI sel : quiz.getSelections()) { + if (!sel.isCorrect()) { + continue; + } + sb.append(modelAnswerHeader).append("\n\n"); + if (hasText(sel.getContent())) { + sb.append(sel.getContent().strip()); + sb.append("\n\n---\n\n"); + } + // 에세이는 모범답안 1개만 존재 + break; + } + } + + // 3. 분석적 루브릭 (quiz.explanation) + if (hasText(quiz.getExplanation())) { + sb.append(rubricHeader).append("\n\n"); + sb.append(quiz.getExplanation().strip()); + sb.append("\n\n"); + } + + // 마지막 구분선 제거 + String result = sb.toString(); + if (result.endsWith("---\n\n")) { + result = result.substring(0, result.length() - "---\n\n".length()); + } + return result.stripTrailing(); + } + + private static boolean hasText(String s) { + return s != null && !s.isBlank(); + } +} 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 ff2ab4d5..bf897269 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 @@ -28,6 +28,7 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -84,8 +85,10 @@ public void triggerGeneration(String userId, GenerationRequest request) { private void processGenerationAsync( String sessionId, Long problemSetId, GenerationRequest request) { + long startNanos = System.nanoTime(); AtomicInteger atomicGeneratedCount = new AtomicInteger(0); AtomicInteger numberCounter = new AtomicInteger(1); + AtomicLong firstConsumerNanos = new AtomicLong(0); ReentrantLock consumerLock = new ReentrantLock(); GenerationRequestToAI requestToAI = @@ -172,6 +175,7 @@ private void processGenerationAsync( quizForFeList)); atomicGeneratedCount.addAndGet(quizViews.size()); + firstConsumerNanos.compareAndSet(0, System.nanoTime()); } finally { consumerLock.unlock(); } @@ -193,6 +197,8 @@ private void processGenerationAsync( int generatedCount = atomicGeneratedCount.get(); int quizCount = request.quizCount(); + long ttfqMs = + firstConsumerNanos.get() > 0 ? (firstConsumerNanos.get() - startNanos) / 1_000_000 : -1; // 요청/생성/실패 문제 수 메트릭 기록 (finalize 결과와 무관하게 항상 실행) resultRecorder.recordQuizCounts(request.quizType(), quizCount, generatedCount, maxChunkCount); @@ -204,25 +210,30 @@ private void processGenerationAsync( request.quizType(), ExceptionMessage.AI_GENERATION_FAILED.getMessage()); } else if (generatedCount == quizCount) { - finalizeSuccess(sessionId, problemSetId, request.quizType(), generatedCount); + finalizeSuccess(sessionId, problemSetId, request.quizType(), generatedCount, ttfqMs); } else { finalizePartialSuccess( - sessionId, problemSetId, request.quizType(), generatedCount, quizCount); + sessionId, problemSetId, request.quizType(), generatedCount, quizCount, ttfqMs); } } private void finalizeSuccess( - String sessionId, Long problemSetId, QuizType quizType, long generatedCount) { + String sessionId, Long problemSetId, QuizType quizType, long generatedCount, long ttfqMs) { quizCommandService.updateStatus(problemSetId, COMPLETED); notificationService.sendComplete(sessionId); - resultRecorder.recordSuccess(problemSetId, quizType, generatedCount); + resultRecorder.recordSuccess(problemSetId, quizType, generatedCount, ttfqMs); } private void finalizePartialSuccess( - String sessionId, Long problemSetId, QuizType quizType, long generatedCount, long quizCount) { + String sessionId, + Long problemSetId, + QuizType quizType, + long generatedCount, + long quizCount, + long ttfqMs) { quizCommandService.updateStatus(problemSetId, COMPLETED); notificationService.sendComplete(sessionId); - resultRecorder.recordPartialSuccess(problemSetId, quizType, generatedCount, quizCount); + resultRecorder.recordPartialSuccess(problemSetId, quizType, generatedCount, quizCount, ttfqMs); } private void finalizeError( diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationResultRecorder.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationResultRecorder.java index c668ea11..75ad71fe 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationResultRecorder.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationResultRecorder.java @@ -53,14 +53,15 @@ public GenerationResultRecorder( } } - public void recordSuccess(Long problemSetId, QuizType quizType, long generatedCount) { - slackNotifier.notifySuccess(problemSetId, quizType, generatedCount); + public void recordSuccess( + Long problemSetId, QuizType quizType, long generatedCount, long ttfqMs) { + slackNotifier.notifySuccess(problemSetId, quizType, generatedCount, ttfqMs); incrementOutcome("success", quizType); } public void recordPartialSuccess( - Long problemSetId, QuizType quizType, long generatedCount, long quizCount) { - slackNotifier.notifyPartialSuccess(problemSetId, quizType, generatedCount, quizCount); + Long problemSetId, QuizType quizType, long generatedCount, long quizCount, long ttfqMs) { + slackNotifier.notifyPartialSuccess(problemSetId, quizType, generatedCount, quizCount, ttfqMs); incrementOutcome("partial", quizType); } diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationSlackNotifier.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationSlackNotifier.java index 141d4fef..51b6a3cd 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationSlackNotifier.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationSlackNotifier.java @@ -15,7 +15,8 @@ public class GenerationSlackNotifier { private final HashUtil hashUtil; private final QAskerProperties qAskerProperties; - public void notifySuccess(Long problemSetId, QuizType quizType, long generatedCount) { + public void notifySuccess( + Long problemSetId, QuizType quizType, long generatedCount, long ttfqMs) { String encodedId = hashUtil.encode(problemSetId); String quizUrl = buildQuizUrl(encodedId); slackNotifier.asyncNotifyText( @@ -24,12 +25,13 @@ public void notifySuccess(Long problemSetId, QuizType quizType, long generatedCo ProblemSetId: <%s|%s> 퀴즈 타입: %s 문제 수: %d + TTFQ: %s """ - .formatted(quizUrl, encodedId, quizType, generatedCount)); + .formatted(quizUrl, encodedId, quizType, generatedCount, formatTtfq(ttfqMs))); } public void notifyPartialSuccess( - Long problemSetId, QuizType quizType, long generatedCount, long quizCount) { + Long problemSetId, QuizType quizType, long generatedCount, long quizCount, long ttfqMs) { String encodedId = hashUtil.encode(problemSetId); String quizUrl = buildQuizUrl(encodedId); slackNotifier.asyncNotifyText( @@ -38,8 +40,10 @@ public void notifyPartialSuccess( ProblemSetId: <%s|%s> 퀴즈 타입: %s 생성된 문제 수: %d개 / 총 문제 수: %d개 + TTFQ: %s """ - .formatted(quizUrl, encodedId, quizType, generatedCount, quizCount)); + .formatted( + quizUrl, encodedId, quizType, generatedCount, quizCount, formatTtfq(ttfqMs))); } public void notifyError(Long problemSetId, String errorMessage) { @@ -57,4 +61,11 @@ public void notifyError(Long problemSetId, String errorMessage) { private String buildQuizUrl(String encodedId) { return qAskerProperties.getFrontendDeployUrl() + "/quiz/" + encodedId; } + + private String formatTtfq(long ttfqMs) { + if (ttfqMs < 0) { + return "N/A"; + } + return ttfqMs >= 1000 ? String.format("%.1f초", ttfqMs / 1000.0) : ttfqMs + "ms"; + } } 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 52da24d8..18f500df 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 @@ -3,5 +3,6 @@ public enum QuizType { MULTIPLE, BLANK, - OX + OX, + ESSAY } diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemDetail.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemDetail.java index 94d39940..15087256 100644 --- a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemDetail.java +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemDetail.java @@ -3,4 +3,5 @@ import java.util.List; /** Problem Entity의 read-only DTO. 모듈 경계를 넘어 Problem 데이터를 전달할 때 사용. */ -public record ProblemDetail(int number, String title, List selections) {} +public record ProblemDetail( + int number, String title, List selections, String explanationContent) {} 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 ffff83a8..cc8c5f6f 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 @@ -47,6 +47,7 @@ private ProblemSetSummary toSummary(ProblemSet ps) { private ProblemDetail toDetail(Problem p) { List selections = p.getSelections().stream().map(s -> new SelectionDetail(s.content(), s.correct())).toList(); - return new ProblemDetail(p.getId().getNumber(), p.getTitle(), selections); + return new ProblemDetail( + p.getId().getNumber(), p.getTitle(), selections, p.getExplanationContent()); } }