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
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ plugins {
group = "com.icc.qasker"
// 프로젝트 버전 (Docker 이미지 태그, 배포 아티팩트 버전에 사용)
// 예: jib으로 빌드하면 Docker 이미지에 "1.7.0" 태그가 붙음
version = "3.0.3"
version = "3.0.4"

// Git hooks 경로를 .githooks/로 자동 설정
// 예: ./gradlew build 실행 시 자동으로 git config core.hooksPath .githooks 적용
Expand Down
3 changes: 3 additions & 0 deletions modules/quiz-ai/api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ plugins {
id 'java-library'
}

dependencies {
api "com.fasterxml.jackson.core:jackson-annotations"
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package com.icc.qasker.ai.dto;

import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.List;

/** ESSAY 채점 결과 DTO. quiz-ai 모듈 경계를 넘어 채점 결과를 전달한다. */
/** ESSAY 채점 결과 DTO. AI 서비스 결과·FE 응답·DB 스냅샷을 단일 record로 통일한다. */
public record EssayGradingResult(
List<ElementScore> elementScores,
int totalScore,
int maxScore,
String overallFeedback,
String evidenceJson) {
@JsonIgnore String evidenceJson) {

public record ElementScore(
String element, int maxPoints, int earnedPoints, String level, String feedback) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import com.icc.qasker.ai.dto.GenerationRequestToAI;
import com.icc.qasker.ai.exception.GeminiInfraException;
import com.icc.qasker.ai.mapper.GeminiQuestionMapper;
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.StreamingQuestionExtractor;
import com.icc.qasker.ai.strategy.QuizType;
import com.icc.qasker.ai.structure.GeminiResponseSchema;
import com.icc.qasker.global.error.CustomException;
import java.net.URI;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
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;
Expand Down Expand Up @@ -43,8 +42,7 @@ public class EssayGradingServiceImpl implements EssayGradingService {
private final ObjectMapper objectMapper;
private final GeminiMetricsRecorder metricsRecorder;
private final String evidenceSchema;
private final String firstAttemptSchema;
private final String defaultSchema;
private final String gradingSchema;

/** Pass 결과를 ChatResponse와 함께 전달하기 위한 내부 record. */
private record PassResult<T>(T result, ChatResponse chatResponse) {}
Expand All @@ -56,9 +54,7 @@ public EssayGradingServiceImpl(
this.metricsRecorder = metricsRecorder;
this.evidenceSchema =
new BeanOutputConverter<>(GeminiEvidenceExtractionResponse.class).getJsonSchema();
this.firstAttemptSchema =
new BeanOutputConverter<>(GeminiFirstAttemptGradingResponse.class).getJsonSchema();
this.defaultSchema = new BeanOutputConverter<>(GeminiGradingResponse.class).getJsonSchema();
this.gradingSchema = new BeanOutputConverter<>(GeminiGradingResponse.class).getJsonSchema();
}

@Override
Expand Down Expand Up @@ -157,9 +153,6 @@ private PassResult<EssayGradingResult> gradeWithEvidence(
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(
Expand All @@ -170,15 +163,14 @@ private PassResult<EssayGradingResult> gradeWithEvidence(
GoogleGenAiChatOptions.builder()
.model(GRADING_MODEL)
.responseMimeType("application/json")
.responseSchema(schema)
.responseSchema(gradingSchema)
.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);
EssayGradingResult result = parseGradingResponse(responseText);

return new PassResult<>(result, chatResponse);
}
Expand All @@ -191,9 +183,6 @@ private EssayGradingResult fallbackSinglePass(
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(
Expand All @@ -204,7 +193,7 @@ private EssayGradingResult fallbackSinglePass(
GoogleGenAiChatOptions.builder()
.model(GRADING_MODEL)
.responseMimeType("application/json")
.responseSchema(schema)
.responseSchema(gradingSchema)
.build();

Prompt prompt = new Prompt(List.of(systemMessage, userMessage), options);
Expand All @@ -219,8 +208,7 @@ private EssayGradingResult fallbackSinglePass(
+ outputTokens * PRICE_OUTPUT_PER_1M / 1_000_000;
metricsRecorder.recordGrading(elapsedMs, nonCachedInput, outputTokens, cost);

EssayGradingResult result =
isFirstAttempt ? parseFirstAttempt(responseText) : parseDefault(responseText);
EssayGradingResult result = parseGradingResponse(responseText);

log.info(
"ESSAY 채점 완료 (fallback 1-pass): 총점={}/{}, 소요={}ms",
Expand All @@ -241,24 +229,8 @@ private static long extractNonCachedInput(ChatResponse chatResponse) {
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<EssayGradingResult.ElementScore> 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) {
/** 채점 응답 파싱. 요소별 feedback과 종합 feedback을 포함한다. */
private static EssayGradingResult parseGradingResponse(String responseText) {
var converter = new BeanOutputConverter<>(GeminiGradingResponse.class);
GeminiGradingResponse response = converter.convert(responseText);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
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.strategy.QuizType;
import com.icc.qasker.ai.structure.GeminiEssayResponseSchema;
import com.icc.qasker.global.error.CustomException;
import java.net.URI;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ public class EssayGradingGuideLine {
- maxScore는 각 요소의 maxPoints 합계입니다. 루브릭의 배점 합계와 반드시 일치해야 합니다.
""";

// 1차 시도: 평가기준명 + 점수 + 수행수준만 제공
// 1차 시도: 경미한 힌트만 제공 (방향성만, 정답 키워드 노출 금지)
private static final String FEEDBACK_ATTEMPT_1 =
"""

## 3. 피드백 원칙 (평가기준만 제시)
- 각 요소별로 **채점 요소명, 점수, 수행수준만** 반환합니다.
- 요소별 개별 피드백은 제공하지 않습니다.
- 종합 피드백(overallFeedback)도 제공하지 않습니다.
- 어떤 부분이 부족한지, 어떻게 보완할지 등 방향이나 힌트를 절대 제시하지 마세요.
## 3. 피드백 원칙 (경미한 힌트만 제시)
- 각 요소별 feedback은 **1문장 이내의 짧은 방향성 힌트**로만 작성합니다.
- **충족**: 잘 작성된 점을 한 문장으로 짧게 인정합니다. (예: "핵심을 잘 짚었어요.")
- **부분 충족**: 어떤 부분을 더 보완해야 하는지 **개략적 방향만** 한 문장으로 제시합니다. 구체적 키워드·표현·정답 내용은 노출하지 마세요. (예: "이 개념의 작동 원리를 한 단계 더 풀어 써 보세요.")
- **미충족**: 해당 요소를 답안에서 다루지 않았음을 짧게 알리고, 다시 생각해볼 수 있도록 가볍게 유도합니다. (예: "이 요소가 답안에 거의 드러나지 않아요. 강의노트의 관련 부분을 다시 살펴보세요.")
- 종합 피드백(overallFeedback)은 **빈 문자열("")**로 둡니다.
- 모범답안에 등장하는 구체 용어·수치·핵심 키워드를 직접 노출하지 마세요. 1차 시도는 **'다시 생각해보게 하는 가벼운 푸시'** 수준에 머물러야 합니다.
""";

// 2차 시도: 구체적 안내
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public static String generate(
String question, String modelAnswer, String rubric, String studentAnswer, int attemptCount) {
String instruction =
attemptCount == 1
? "위 분석적 루브릭의 각 요소별로 채점하세요."
? "위 분석적 루브릭의 각 요소별로 채점하고, 요소별 feedback은 경미한 방향성 힌트(1문장 이내)만 작성하세요. overallFeedback은 빈 문자열로 둡니다."
: "위 분석적 루브릭의 각 요소별로 채점하고, 요소별 피드백과 종합 피드백을 작성하세요.";

return """
Expand Down Expand Up @@ -73,7 +73,7 @@ public static String generateWithEvidence(
int attemptCount) {
String instruction =
attemptCount == 1
? "위 분석적 루브릭의 각 요소별로, 추출된 증거에 근거하여 채점하세요."
? "위 분석적 루브릭의 각 요소별로, 추출된 증거에 근거하여 채점하고, 요소별 feedback은 경미한 방향성 힌트(1문장 이내)만 작성하세요. overallFeedback은 빈 문자열로 둡니다."
: "위 분석적 루브릭의 각 요소별로, 추출된 증거에 근거하여 채점하고, 요소별 피드백과 종합 피드백을 작성하세요.";

StringBuilder evidenceSection = new StringBuilder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import com.icc.qasker.ai.dto.GenerationRequestToAI;
import com.icc.qasker.ai.exception.GeminiInfraException;
import com.icc.qasker.ai.mapper.GeminiQuestionMapper;
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.StreamingQuestionExtractor;
import com.icc.qasker.ai.strategy.QuizType;
import com.icc.qasker.ai.structure.GeminiResponseSchema;
import com.icc.qasker.global.error.CustomException;
import java.net.URI;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import com.icc.qasker.ai.dto.GenerationRequestToAI;
import com.icc.qasker.ai.exception.GeminiInfraException;
import com.icc.qasker.ai.mapper.GeminiQuestionMapper;
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.StreamingQuestionExtractor;
import com.icc.qasker.ai.strategy.QuizType;
import com.icc.qasker.ai.structure.GeminiResponseSchema;
import com.icc.qasker.global.error.CustomException;
import java.net.URI;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.google.genai.types.Content;
import com.google.genai.types.FileData;
import com.google.genai.types.Part;
import com.icc.qasker.ai.prompt.strategy.QuizType;
import com.icc.qasker.ai.strategy.QuizType;
import com.icc.qasker.global.error.CustomException;
import com.icc.qasker.global.error.ExceptionMessage;
import java.time.Duration;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import com.icc.qasker.ai.dto.ChunkInfo;
import com.icc.qasker.ai.prompt.strategy.QuizType;
import com.icc.qasker.ai.strategy.QuizType;
import com.icc.qasker.ai.structure.GeminiResponse;
import com.icc.qasker.ai.structure.GeminiResponseSchema;
import java.util.List;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.icc.qasker.ai.prompt.strategy;
package com.icc.qasker.ai.strategy;

import java.util.List;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.icc.qasker.ai.prompt.strategy;
package com.icc.qasker.ai.strategy;

import com.icc.qasker.ai.i18n.ENGLISH;
import com.icc.qasker.ai.service.blank.prompt.BlankGuideLine;
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.icc.qasker.quizhistory.controller;

import com.icc.qasker.ai.dto.EssayGradingResult;
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;
Expand All @@ -29,12 +29,12 @@ public class EssayGradeController {
@Operation(summary = "서술형 문제를 채점한다")
@RateLimit(RateLimitTier.WRITE)
@PostMapping("/problem-sets/{problemSetId}/problems/{problemNumber}/grade")
public ResponseEntity<EssayGradeResponse> gradeEssay(
public ResponseEntity<EssayGradingResult> gradeEssay(
@UserId String userId,
@PathVariable String problemSetId,
@PathVariable int problemNumber,
@Valid @RequestBody EssayGradeRequest request) {
EssayGradeResponse response =
EssayGradingResult response =
essayGradeService.grade(
userId, problemSetId, problemNumber, request.textAnswer(), request.attemptCount());
return ResponseEntity.ok(response);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.icc.qasker.quizhistory.entity;

import com.icc.qasker.ai.dto.EssayGradingResult;
import com.icc.qasker.global.entity.CreatedAt;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
Expand Down Expand Up @@ -54,14 +55,11 @@ public class EssayGradeLog extends CreatedAt {

@JdbcTypeCode(SqlTypes.JSON)
@Column(nullable = false, columnDefinition = "JSON")
private List<ElementScoreSnapshot> elementScores;
private List<EssayGradingResult.ElementScore> 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) {}
}
Loading
Loading