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
34 changes: 34 additions & 0 deletions app/src/main/resources/db/migration/V21__add_wrong_answer_set.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- V21: 오답 모아풀기 — 세트 출처 구분, 재출제 혈통, 풀이 완료 시각
--
-- problem_set.origin 자료 기반(DOCUMENT) vs 오답 모음(WRONG_ANSWER) 구분.
-- 목록에서 두 종류를 구별하고, 원본 자료를 전제로 하는 후속 동작을 오답 문제집에서 감추는 근거다.
-- problem_set.source_folder_id 어느 폴더에서 모았는지.
-- problem.origin_* 재출제된 문항이 가리키는 최초 조상(세트 id + 문항 번호). 복제할 때 조상 값을 물려받으므로
-- 세대가 반복돼도 항상 최초 문항을 가리킨다. 같은 문항이 두 번 담기지 않게 하는 중복 제거 키다.
-- quiz_history.completed_at 풀이를 마친 시각. created_at 은 기록 행이 처음 만들어진 시각이라 재풀이해도 갱신되지 않아
-- "가장 최근에 틀린 순"을 표현할 수 없다. 이 정렬이 상한에서 어떤 문항이 남는지를 결정한다.
-- 기존 완료 행은 created_at 으로 백필해 표시 변화를 만들지 않는다.

ALTER TABLE problem_set
ADD COLUMN origin VARCHAR(20) NOT NULL DEFAULT 'DOCUMENT',
ADD COLUMN source_folder_id BIGINT NULL;

ALTER TABLE problem
ADD COLUMN origin_problem_set_id BIGINT NULL,
ADD COLUMN origin_number INT NULL;

ALTER TABLE quiz_history
ADD COLUMN completed_at DATETIME(6) NULL;

UPDATE quiz_history
SET completed_at = created_at
WHERE status = 'COMPLETED';

-- PII 분류(새 컬럼 커버리지 게이트) — 출처 구분·혈통 참조·완료 시각은 개인정보 아님.
INSERT INTO pii_classification (table_name, column_name, strategy, note)
VALUES ('problem_set', 'origin', 'SAFE', '세트 출처(DOCUMENT/WRONG_ANSWER)'),
('problem_set', 'source_folder_id', 'SAFE', 'FK→quiz_folder (수집 범위)'),
('problem', 'origin_problem_set_id', 'SAFE', '재출제 혈통(최초 조상 세트)'),
('problem', 'origin_number', 'SAFE', '재출제 혈통(최초 조상 문항번호)'),
('quiz_history', 'completed_at', 'SAFE', '풀이 완료 시각')
ON DUPLICATE KEY UPDATE strategy = VALUES(strategy), note = VALUES(note);
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.icc.qasker.quizhistory;

import com.icc.qasker.quizhistory.dto.ferequest.CreateWrongAnswerSetRequest;
import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse;

/** 한 폴더에서 내가 틀린 문항을 모아 유형마다 새 문제집을 만든다. */
public interface WrongAnswerSetService {

/**
* @param idempotencyKey 한 번의 사용자 조작을 가리키는 값. 같은 값으로 다시 들어오면 새로 만들지 않고 먼저 만들어진 것을 돌려준다.
*/
WrongAnswerSetResponse createFromFolder(
String userId, CreateWrongAnswerSetRequest request, String idempotencyKey);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.icc.qasker.quizhistory.dto.ferequest;

import jakarta.validation.constraints.NotBlank;

/** 오답 모아풀기 요청. 수집 범위는 폴더 하나이며, 요청자는 토큰에서 얻으므로 바디에 담지 않는다. */
public record CreateWrongAnswerSetRequest(
@NotBlank(message = "folderId가 존재하지 않습니다.") String folderId) {}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.icc.qasker.quizhistory.dto.feresponse;

import com.icc.qasker.quizset.ProblemSetOrigin;
import com.icc.qasker.quizset.dto.ferequest.enums.QuizType;
import java.time.Instant;

Expand All @@ -14,4 +15,5 @@ public record HistorySummaryResponse(
Integer score,
Instant takenAt,
String folderId,
String folderName) {}
String folderName,
ProblemSetOrigin origin) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.icc.qasker.quizhistory.dto.feresponse;

import com.icc.qasker.quizset.dto.ferequest.enums.QuizType;
import java.util.List;

/**
* 오답 모아풀기 결과. 모을 오답이 없거나 상한에 걸리거나 일부 유형이 실패해도 실패 응답이 아니다 — 사용자에게 알려야 할 상태이지 요청의 실패가 아니고, 화면 문구는 이
* 사실들로 프론트가 만든다.
*
* @param createdSets 만들어진 문제집. 유형마다 하나이며, 하나도 없으면 빈 목록이다.
* @param excludedEssayCount 수집에서 빠진 서술형 문항 수. 서술형은 정오답이 규칙으로 갈리지 않아 "틀린 수"가 아니라 "제외된 수"다.
* @param deletedSourceCount 원본 문제집이 이미 지워져 건너뛴 기록 수.
* @param failedTypes 만들다 실패한 유형. 성공한 문제집은 그대로 남는다.
* @param emptyReason 만들어진 문제집이 하나도 없을 때의 사유. 하나라도 있으면 null.
*/
public record WrongAnswerSetResponse(
List<CreatedSet> createdSets,
int excludedEssayCount,
int deletedSourceCount,
List<QuizType> failedTypes,
EmptyReason emptyReason) {

/**
* @param truncated 상한에 걸려 일부만 담겼는지. 걸리지 않은 유형은 false다.
*/
public record CreatedSet(
String problemSetId,
String historyId,
QuizType quizType,
String title,
int questionCount,
boolean truncated) {}

/** 모을 것이 없던 이유. 판정 우선순위는 선언 순서와 같다. */
public enum EmptyReason {
/** 폴더에 끝까지 푼 기록이 하나도 없다. */
NO_HISTORY,
/** 수집할 수 있는 기록이 서술형뿐이었다. */
ESSAY_ONLY,
/** 기록은 있었으나 원본 문제집이 모두 지워졌다. */
SOURCE_DELETED,
/** 수집 대상은 있었고 틀린 문항이 하나도 없었다. */
ALL_CORRECT
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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.WrongAnswerSetService;
import com.icc.qasker.quizhistory.dto.ferequest.CreateWrongAnswerSetRequest;
import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* 오답 모아풀기 API.
*
* <p>모을 오답이 없거나, 상한에 걸려 일부만 담기거나, 일부 유형이 실패해도 200이다 — 사용자에게 알려야 할 상태이지 요청의 실패가 아니고, 오류로 내리면 프론트의 공통
* 오류 처리가 문구를 가로채 상황에 맞는 안내를 할 수 없다.
*/
@Tag(name = "ProblemSet", description = "문제세트 관련 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/problem-set")
public class WrongAnswerSetController {

private final WrongAnswerSetService wrongAnswerSetService;

@Operation(summary = "현재 폴더에서 틀린 문항을 모아 유형별로 새 문제집을 만든다")
@RateLimit(RateLimitTier.WRITE)
@PostMapping("/wrong-answers")
public ResponseEntity<WrongAnswerSetResponse> createWrongAnswerSets(
@UserId String userId,
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
@Valid @RequestBody CreateWrongAnswerSetRequest request) {
// 키가 없으면 멱등 보장 없이 매번 새로 만든다. 연타·재시도를 흡수하려면 한 번의 조작에 같은 키를 보내야 한다.
String key =
(idempotencyKey == null || idempotencyKey.isBlank())
? UUID.randomUUID().toString()
: idempotencyKey;
return ResponseEntity.ok(wrongAnswerSetService.createFromFolder(userId, request, key));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.Instant;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
Expand Down Expand Up @@ -55,6 +56,9 @@ public class QuizHistory extends CreatedAt {

@Column private Integer score;

/** 풀이를 마친 시각. createdAt 은 기록 행이 처음 만들어진 시각이라 재풀이해도 갱신되지 않아 "가장 최근에 푼 순"을 표현하지 못한다. 미완료면 null. */
@Column private Instant completedAt;

private String totalTime;

@Enumerated(EnumType.STRING)
Expand All @@ -76,6 +80,7 @@ public void completeQuiz(List<AnswerSnapshot> answers, Integer score, String tot
this.score = score;
this.totalTime = totalTime;
this.status = QuizHistoryStatus.COMPLETED;
this.completedAt = Instant.now();
}

public enum QuizHistoryStatus {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.icc.qasker.quizhistory.grading;

import com.icc.qasker.quizhistory.entity.AnswerSnapshotView;
import com.icc.qasker.quizset.dto.ferequest.enums.QuizType;
import com.icc.qasker.quizset.dto.readonly.ProblemDetail;
import com.icc.qasker.quizset.dto.readonly.SelectionDetail;
import com.icc.qasker.quizset.grading.RealBlankGrader;
import java.util.List;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

/**
* 저장된 답안 스냅샷으로 문항별 정오답을 되짚는 판정기(SSOT). 정오답은 저장되지 않고(기록에는 답안과 총점만 남는다) 조회 시마다 다시 판정하므로, 기록 상세와 오답
* 수집이 같은 함수를 거쳐 동일한 답을 얻어야 한다.
*
* <p>ESSAY는 규칙으로 정오답이 갈리지 않아(AI 채점 점수만 있다) 여기서 다루지 않는다.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class AnswerJudge {

/** 정답 선택지의 1-based 위치. 정답 선택지가 없으면 어떤 답과도 같지 않도록 -1. */
public static int correctIndex(List<SelectionDetail> selections) {
if (selections == null) {
return -1;
}
for (int i = 0; i < selections.size(); i++) {
if (selections.get(i).correct()) {
return i + 1;
}
}
return -1;
}

/**
* REAL_BLANK는 텍스트 정규화 + 인정 집합 멤버십으로, 그 외 유형은 정답 인덱스와 사용자 답을 비교해 판정한다. 미응답(userAnswer=0)은 어떤 정답
* 인덱스와도 같지 않아 오답이 된다.
*/
public static boolean isCorrect(
QuizType quizType, ProblemDetail problem, AnswerSnapshotView answers) {
if (quizType == QuizType.REAL_BLANK) {
return RealBlankGrader.grade(problem.selections(), answers.textAnswer(problem.number()))
.isCorrect();
}
return answers.userAnswer(problem.number()) == correctIndex(problem.selections());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.icc.qasker.quizhistory.entity.AnswerSnapshotView;
import com.icc.qasker.quizhistory.entity.EssayGradeLog;
import com.icc.qasker.quizhistory.entity.QuizHistory;
import com.icc.qasker.quizhistory.grading.AnswerJudge;
import com.icc.qasker.quizset.dto.ferequest.enums.QuizType;
import com.icc.qasker.quizset.dto.feresponse.Selection;
import com.icc.qasker.quizset.dto.readonly.ProblemDetail;
Expand All @@ -28,7 +29,12 @@ public final class QuizHistoryMapper {

private final HashUtil hashUtil;

/** QuizHistory + ProblemSetSummary → HistorySummaryResponse 변환. folderName은 미분류면 null. */
/**
* QuizHistory + ProblemSetSummary → HistorySummaryResponse 변환. folderName은 미분류면 null.
*
* <p>{@code takenAt}은 완료 시각이다. 오답 모아풀기가 미완료 기록을 폴더에 만들기 때문에, 기록 생성 시각을 쓰면 아직 풀지도 않은 문제집에 완료일이
* 찍힌다. 완료하지 않은 기록은 완료 시각이 없으므로 null이다.
*/
public HistorySummaryResponse toSummary(
QuizHistory history, ProblemSetSummary problemSet, String folderName) {
boolean completed =
Expand All @@ -46,9 +52,10 @@ public HistorySummaryResponse toSummary(
problemSet.totalQuizCount(),
completed,
history.getScore(),
history.getCreatedAt(),
history.getCompletedAt(),
folderId,
folderName);
folderName,
problemSet.origin());
}

/**
Expand All @@ -61,9 +68,8 @@ public ProblemWithAnswer toProblemWithAnswer(
return toRealBlankProblemWithAnswer(problem, answers);
}
List<SelectionDetail> rawSelections = problem.selections();
int correctIndex = findCorrectIndex(rawSelections);
int userAnswer = answers.userAnswer(problem.number());
boolean correct = userAnswer == correctIndex;
boolean correct = AnswerJudge.isCorrect(quizType, problem, answers);
List<Selection> selections =
IntStream.range(0, rawSelections.size())
.mapToObj(
Expand Down Expand Up @@ -131,13 +137,4 @@ private GradeResult toGradeResult(EssayGradeLog gradeLog) {
gradeLog.getOverallFeedback(),
elementScores);
}

private int findCorrectIndex(List<SelectionDetail> selections) {
for (int i = 0; i < selections.size(); i++) {
if (selections.get(i).correct()) {
return i + 1;
}
}
return -1;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Page<QuizHistory> findAllByUserIdAndFolderIdIsNullOrderByCreatedAtDesc(

Optional<QuizHistory> findByUserIdAndProblemSetId(String userId, Long problemSetId);

/** 폴더 하나에 든 내 기록. 오답 모아풀기의 수집 범위가 이 한 줄로 닫힌다(폴더 밖·타인 기록이 섞일 자리가 없다). */
List<QuizHistory> findAllByUserIdAndFolderIdAndStatusOrderByCreatedAtDesc(
String userId, Long folderId, QuizHistory.QuizHistoryStatus status);

/** 사용자의 폴더별 기록 수(미분류 제외). */
@Query(
"SELECT h.folderId AS folderId, COUNT(h) AS count FROM QuizHistory h"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.icc.qasker.quizhistory.service.wronganswer;

import com.icc.qasker.quizset.dto.ferequest.enums.QuizType;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

/**
* 문제집 제목에 쓰는 유형 이름. 사용자가 문제를 만들 때 옵션 화면에서 고른 바로 그 단어를 쓴다 — 목록의 제목과 화면의 유형 표기가 다른 말을 하지 않게 하려는 것이다.
* 두 빈칸 유형이 같은 이름을 쓰면 유형별로 나뉘어 만들어진 문제집을 서로 구별할 수 없다.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
final class QuizTypeLabel {

static String of(QuizType quizType) {
return switch (quizType) {
case MULTIPLE -> "객관식";
case OX -> "OX 퀴즈";
case BLANK -> "빈칸 넣기";
case REAL_BLANK -> "빈칸 직접입력";
case ESSAY -> "서술형";
};
}
}
Loading