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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE essay_grade_log
ADD COLUMN attempt_count INT NOT NULL DEFAULT 1 AFTER student_answer;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE essay_grade_log ADD COLUMN evidence_json JSON NULL AFTER overall_feedback;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.icc.qasker.ai.dto;

import java.util.List;

/** ESSAY 채점 결과 DTO. quiz-ai 모듈 경계를 넘어 채점 결과를 전달한다. */
public record EssayGradingResult(
List<ElementScore> elementScores,
int totalScore,
int maxScore,
String overallFeedback,
String evidenceJson) {

public record ElementScore(
String element, int maxPoints, int earnedPoints, String level, String feedback) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.icc.qasker.ai.mapper;

import com.icc.qasker.ai.dto.AIProblem;
import com.icc.qasker.ai.dto.AIProblemSet;
import com.icc.qasker.ai.dto.AISelection;
import com.icc.qasker.ai.structure.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<GeminiEssayQuestion> questions) {
return toDto(questions, null);
}

/**
* ESSAY 문항을 AIProblemSet으로 변환한다. modelAnswer는 Selection(modelAnswer, true)로 저장하고, explanation은
* appliedInstruction 위치에 임시 저장하여 다음 레이어에서 explanationContent로 매핑한다.
*/
public static AIProblemSet toDto(List<GeminiEssayQuestion> questions, List<Integer> sourcePages) {
List<AIProblem> result =
questions.stream()
.map(
q -> {
// modelAnswer → Selection(content=modelAnswer, correct=true)
List<AISelection> 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<Integer> sourcePages) {
if (text == null || sourcePages == null || sourcePages.isEmpty()) {
return text;
}

StringBuilder sb = new StringBuilder();
Matcher matcher = PAGE_PATTERN.matcher(text);
int lastEnd = 0;

while (matcher.find()) {
sb.append(text, lastEnd, matcher.start());
try {
int aiPage = Integer.parseInt(matcher.group(1));
int index = aiPage - 1;
if (index >= 0 && index < sourcePages.size()) {
sb.append("[").append(sourcePages.get(index)).append("p] >");
} else {
sb.append(matcher.group());
}
} catch (NumberFormatException e) {
sb.append(matcher.group());
}
lastEnd = matcher.end();
}
sb.append(text.substring(lastEnd));
return sb.toString();
}

private static List<Integer> remapPages(List<Integer> aiPages, List<Integer> sourcePages) {
if (aiPages == null) return List.of();
if (sourcePages == null || sourcePages.isEmpty()) return aiPages;

return aiPages.stream()
.map(
page -> {
int index = page - 1;
if (index >= 0 && index < sourcePages.size()) {
return sourcePages.get(index);
}
return page;
})
.distinct()
.sorted()
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +52,18 @@ public String generateRequestPrompt(
// customInstruction이 있으면 XML 태그로 감싸 유저 프롬프트 끝에 우선 삽입
return OXRequestPrompt.generateWithUserInstruction(referencePages, quizCount, planExtra);
}
},
ESSAY(EssayGuideLine.content) {
@Override
public String generateRequestPrompt(List<Integer> referencePages, int quizCount) {
return EssayRequestPrompt.generate(referencePages, quizCount);
}

@Override
public String generateRequestPrompt(
List<Integer> referencePages, int quizCount, String planExtra) {
return EssayRequestPrompt.generate(referencePages, quizCount, planExtra);
}
};

private final String systemGuideLine;
Expand Down
Loading
Loading