diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java
index 5f49b75..a8438ad 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java
@@ -93,6 +93,10 @@ public static class Item {
// 이 답변에 이어질 예상 꼬리질문 한 문장. 새로 생긴 필드라 구버전 분석 서버는 보내지 않는다.
// 문자열이 아닌 값이 와도 본문 전체를 잃지 않도록 모양을 가리지 않고 받는다. 푸는 것은 FeedbackService가 한다.
private JsonNode followUpQuestion;
+ // 이 문항의 응답 적중 등급(0~4). competency-v1부터 실린다.
+ private JsonNode responseLevel;
+ // 역량별 등급과 근거 인용. competency-v1부터 실린다. 모양을 가리지 않고 받아 CompetencyReader가 푼다.
+ private JsonNode competencies;
}
@Getter
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java
index 277b956..c6c8a48 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java
@@ -67,6 +67,9 @@ public static class Question {
// ORIGINAL이면 반드시 null, FOLLOW면 반드시 값이 있어야 한다. 어기면 422다.
private String parentId;
private Type type;
+ // 질문 카테고리(tech_choice, motivation 등). 1:1과 같다 — 없거나 모르는 값이면 분석 서버가 미분류로 채점한다.
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private String category;
private String intention;
private String content;
// ISO 8601 + UTC 오프셋(Z)으로 직렬화된다.
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java
index 1d715bf..ca81f14 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java
@@ -47,6 +47,10 @@ public static class Question {
// ORIGINAL이면 반드시 null, FOLLOW면 반드시 값이 있어야 한다. 어기면 분석 서버가 422로 거부한다.
private String parentId;
private Type type;
+ // 질문 카테고리(tech_choice 등). 분석 서버가 이 값으로 채점 역량을 고르고, 없거나 모르는 값이면 미분류로 채점한다.
+ // 꼬리질문이 비어 있으면 분석 서버가 부모 값을 물려 쓴다. 저장된 값이 없으면 키 자체를 보내지 않는다.
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private String category;
private String intention;
private String content;
// ISO 8601 + UTC 오프셋(Z)으로 직렬화된다.
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/AxisScoreResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/AxisScoreResponse.java
index c3011b1..07bc457 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/AxisScoreResponse.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/AxisScoreResponse.java
@@ -1,5 +1,6 @@
package repit.repit_api_server.domain.userdata.feedback.dto.response;
+import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -8,17 +9,30 @@
/**
* 채점 축 하나의 점수와 가중치.
*
- *
면접 전체가 해당 없는 축(주로 ACCURACY)은 점수와 가중치가 모두 비어 있다. 그때 나머지 가중치의
- * 합은 100이 아니다.
+ *
면접 전체가 해당 없는 축(axis-v4의 ACCURACY, competency-v1에서 확인되지 않은 역량)은 점수와 가중치가 모두
+ * 비어 있다. 그때 나머지 가중치의 합은 100이 아니다.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AxisScoreResponse {
- // INTENT / DEPTH / SPECIFICITY / ACCURACY. 표시 이름은 클라이언트가 정한다.
+ // axis-v4: INTENT / DEPTH / SPECIFICITY / ACCURACY
+ // competency-v1: PROBLEM_SOLVING / KNOWLEDGE / OWNERSHIP / COLLABORATION / GROWTH
+ // 표시 이름은 클라이언트가 정한다.
private String axis;
private Integer score;
// 실제 적용된 가중치(%). 비율(0.35)로 와도 %로 바꿔 둔다.
private Integer weight;
+ // STRONG / SOLID / BORDERLINE / WEAK. competency-v1부터. 점수가 없으면 비어 있다.
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private String verdict;
+ // 이 축 점수에 들어간 문항 수. 1이면 화면에 "근거 부족"을 띄운다. competency-v1부터.
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private Integer evidenceCount;
+
+ /** 판정과 근거 문항 수가 없는 axis-v4 축. */
+ public AxisScoreResponse(String axis, Integer score, Integer weight) {
+ this(axis, score, weight, null, null);
+ }
}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CompetencyScoreResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CompetencyScoreResponse.java
new file mode 100644
index 0000000..e8ca790
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CompetencyScoreResponse.java
@@ -0,0 +1,28 @@
+package repit.repit_api_server.domain.userdata.feedback.dto.response;
+
+import java.util.List;
+
+/**
+ * 문항 하나에서 점수에 들어간 역량 하나(competency-v1). 주 역량이 앞에 온다.
+ *
+ *
등급은 분석 서버가 근거 인용 조합으로 계산하고 응답 적중 상한까지 적용한 최종값이다. 인용은 사용자 답변(꼬리질문이면
+ * 부모 답변 포함)의 원문 조각이라, 화면은 그대로 "이 문장 때문에"로 보여 준다. 콜백을 저장할 때 모양이 어긋난 항목과
+ * 인용은 이미 버렸다({@code CompetencyReader}).
+ *
+ * @param competency PROBLEM_SOLVING / KNOWLEDGE / OWNERSHIP / COLLABORATION / GROWTH / MOTIVATION.
+ * MOTIVATION은 종합 점수에 들어가지 않는 별도 지표다.
+ * @param role PRIMARY(문항이 겨냥한 역량, 항상 채점) / SECONDARY(근거가 있어서 더한 역량)
+ * @param level 0..4
+ * @param score 0..100 (= level × 25)
+ */
+public record CompetencyScoreResponse(String competency, String role, Integer level, Integer score,
+ List evidence) {
+
+ public CompetencyScoreResponse {
+ evidence = evidence == null ? List.of() : evidence;
+ }
+
+ /** @param slot 근거 종류(reason, mechanism 등). 화면에는 쓰지 않는다. */
+ public record Evidence(String slot, String quote) {
+ }
+}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CredibilityResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CredibilityResponse.java
new file mode 100644
index 0000000..10a47f0
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CredibilityResponse.java
@@ -0,0 +1,26 @@
+package repit.repit_api_server.domain.userdata.feedback.dto.response;
+
+import java.util.List;
+
+/**
+ * 신뢰성 — 답변 사이의 모순(competency-v1). 표시만 하고 종합 점수에 들어가지 않는다.
+ *
+ * 꼬리질문에서 설명하지 못한 것은 모순이 아니다. 앞 답변과 뒤집힐 때만 여기에 실린다.
+ *
+ * @param score 일관성 점수(0..100). 답변이 1개면 비어 있다
+ * @param issues 서로 어긋난 답변 쌍. 없으면 빈 목록
+ */
+public record CredibilityResponse(Integer score, List issues) {
+
+ public CredibilityResponse {
+ issues = issues == null ? List.of() : issues;
+ }
+
+ /**
+ * @param kind ROLE / TECH_CHOICE / CAUSE / DETAIL / OTHER
+ * @param questionIds 서로 어긋난 두 문항. 분석 서버에 보낸 질문 id 그대로라 feedbacks[].questionId와 맞춰 잇는다
+ * @param quotes 각 문항 답변의 원문 인용. questionIds와 같은 순서다
+ */
+ public record Issue(String kind, List questionIds, List quotes) {
+ }
+}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackItemResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackItemResponse.java
index d88e682..aad6215 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackItemResponse.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackItemResponse.java
@@ -27,6 +27,10 @@ public class FeedbackItemResponse {
private Map axisScores;
// 이 답변에 이어질 예상 꼬리질문. 이전 피드백이나 만들지 못한 문항은 null이다.
private String followUpQuestion;
+ // 응답 적중 등급(0..4). competency-v1 이전 결과는 null이다.
+ private Integer responseLevel;
+ // 점수에 들어간 역량별 등급과 근거 인용. 주 역량이 앞에 온다. competency-v1 이전 결과는 null이다.
+ private List competencies;
public static FeedbackItemResponse from(FeedbackItemEntity item) {
return FeedbackItemResponse.builder()
@@ -41,6 +45,8 @@ public static FeedbackItemResponse from(FeedbackItemEntity item) {
.comment(item.getComment())
.axisScores(item.getAxisScores())
.followUpQuestion(item.getFollowUpQuestion())
+ .responseLevel(item.getResponseLevel())
+ .competencies(item.getCompetencies())
.build();
}
}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/MotivationSummaryResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/MotivationSummaryResponse.java
new file mode 100644
index 0000000..580ef25
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/MotivationSummaryResponse.java
@@ -0,0 +1,12 @@
+package repit.repit_api_server.domain.userdata.feedback.dto.response;
+
+/**
+ * 지원 동기·회사 이해(competency-v1). motivation 카테고리 문항만으로 매기고 종합 점수에 들어가지 않는다.
+ * 축 하나와 같은 의미의 값이다({@link AxisScoreResponse}).
+ *
+ * @param score 0..100. 채점한 문항이 없으면 비어 있다
+ * @param verdict STRONG / SOLID / BORDERLINE / WEAK. 점수가 없으면 비어 있다
+ * @param evidenceCount 이 점수에 들어간 문항 수
+ */
+public record MotivationSummaryResponse(Integer score, String verdict, Integer evidenceCount) {
+}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/ResponseSummaryResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/ResponseSummaryResponse.java
new file mode 100644
index 0000000..3d18b1e
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/ResponseSummaryResponse.java
@@ -0,0 +1,11 @@
+package repit.repit_api_server.domain.userdata.feedback.dto.response;
+
+/**
+ * 응답 적중 — 질문이 물은 것에 답했는지(competency-v1). 종합 점수에 들어가지 않는다.
+ *
+ * @param score 채점한 문항의 응답 등급 평균 × 25 (0..100)
+ * @param hitCount 응답 등급이 3 이상인 문항 수. 화면의 "응답 적중 5/6문항"에서 5
+ * @param gradedCount 채점한 문항 수
+ */
+public record ResponseSummaryResponse(Integer score, Integer hitCount, Integer gradedCount) {
+}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/ScoreBreakdownResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/ScoreBreakdownResponse.java
index 11dd10e..66b010f 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/ScoreBreakdownResponse.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/ScoreBreakdownResponse.java
@@ -1,5 +1,6 @@
package repit.repit_api_server.domain.userdata.feedback.dto.response;
+import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -11,19 +12,35 @@
* 종합 점수가 어떻게 나왔는지. 축마다 점수와 실제 적용된 가중치가 실린다.
*
* 분석 서버는 문항마다 축 등급만 매기고 점수는 직접 계산한다(axis-v1). 그래서 표시된 축 점수와
- * 가중치로 {@code Σ score×weight / Σ weight}를 반올림하면 종합 점수와 같아야 한다.
+ * 가중치로 {@code Σ score×weight / Σ weight}를 반올림하면 종합 점수와 같아야 한다. 응답 적중·지원 동기·신뢰성은
+ * 이 식에 들어가지 않는다.
*
*
콜백으로 받은 값을 그대로 jsonb에 두고 그대로 내보낸다. 방식이 바뀌면 축이 늘거나 이름이 바뀔 수
- * 있어 축 이름은 문자열로 둔다.
+ * 있어 축 이름은 문자열로 둔다. 버전은 키가 있는지가 아니라 scoringVersion으로 가린다.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ScoreBreakdownResponse {
- // 채점 방식 버전(예: axis-v1). 버전이 다른 점수끼리는 같은 기준으로 비교하면 안 된다.
+ // 채점 방식 버전(예: axis-v4, competency-v1). 버전이 다른 점수끼리는 같은 기준으로 비교하면 안 된다.
private String scoringVersion;
private List axes;
// 답변 사이의 일관성. 답변이 1개면 비어 있다. 종합 점수에는 들어가지 않는다.
private Integer consistencyScore;
+ // 아래 셋은 competency-v1부터 실린다. 그 전 결과에는 키가 없다.
+ // 응답 적중. 종합 점수에는 들어가지 않는다.
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private ResponseSummaryResponse response;
+ // 지원 동기·회사 이해. motivation 문항이 없으면 비어 있다. 종합 점수에는 들어가지 않는다.
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private MotivationSummaryResponse motivation;
+ // 답변 사이의 모순. 면접관별 근거에는 실리지 않는다. 종합 점수에는 들어가지 않는다.
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private CredibilityResponse credibility;
+
+ /** 응답 적중·지원 동기·신뢰성이 없는 axis-v4 이전 근거. */
+ public ScoreBreakdownResponse(String scoringVersion, List axes, Integer consistencyScore) {
+ this(scoringVersion, axes, consistencyScore, null, null, null);
+ }
}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackItemEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackItemEntity.java
index 82db5f2..5479483 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackItemEntity.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackItemEntity.java
@@ -4,6 +4,7 @@
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.CompetencyScoreResponse;
import java.util.List;
import java.util.Map;
@@ -66,4 +67,14 @@ public class FeedbackItemEntity {
// 이 답변에 이어질 예상 꼬리질문. 이전 피드백이나 만들지 못한 문항은 값이 없다.
@Column(columnDefinition = "TEXT")
private String followUpQuestion;
+
+ // 질문이 물은 것에 답했는지(응답 적중 등급 0..4). competency-v1 이전 결과는 값이 없다.
+ // 컬럼이 SMALLINT라 타입을 맞춘다. 맞추지 않으면 스키마 검증(ddl-auto: validate)에서 기동이 막힌다.
+ @JdbcTypeCode(SqlTypes.SMALLINT)
+ private Integer responseLevel;
+
+ // 점수에 들어간 역량마다 등급과 근거 인용. competency-v1 이전 결과는 값이 없다.
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(columnDefinition = "jsonb")
+ private List competencies;
}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompetencyReader.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompetencyReader.java
new file mode 100644
index 0000000..65c7cf9
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompetencyReader.java
@@ -0,0 +1,150 @@
+package repit.repit_api_server.domain.userdata.feedback.service;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.CompetencyScoreResponse;
+import tools.jackson.databind.JsonNode;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import static repit.repit_api_server.domain.userdata.feedback.service.ScoreBreakdownReader.QUOTE_MAX;
+import static repit.repit_api_server.domain.userdata.feedback.service.ScoreBreakdownReader.isAbsent;
+import static repit.repit_api_server.domain.userdata.feedback.service.ScoreBreakdownReader.length;
+import static repit.repit_api_server.domain.userdata.feedback.service.ScoreBreakdownReader.wholeNumber;
+
+/**
+ * 채점 콜백 문항의 응답 적중 등급과 역량별 근거(competency-v1)를 읽는다.
+ *
+ * {@link ScoreBreakdownReader}와 같은 이유로 요청 본문에서는 모양을 가리지 않고 받아 여기서 푼다. 한 필드 모양이
+ * 어긋나 본문 전체가 400이 되면 분석 서버가 재시도 뒤 결과를 영구 폐기한다. 어긋난 항목과 인용만 버리고, 문항의 나머지
+ * 평가와 점수는 그대로 저장한다.
+ *
+ *
인용은 사용자 답변의 원문이다. 로그에는 사유와 길이만 남기고 인용 자체는 남기지 않는다.
+ */
+final class CompetencyReader {
+
+ private static final Logger log = LoggerFactory.getLogger(CompetencyReader.class);
+
+ private static final Set COMPETENCIES = Set.of(
+ "PROBLEM_SOLVING", "KNOWLEDGE", "OWNERSHIP", "COLLABORATION", "GROWTH", "MOTIVATION");
+ private static final Set ROLES = Set.of("PRIMARY", "SECONDARY");
+ private static final int LEVEL_MAX = 4;
+ private static final int SCORE_MAX = 100;
+ // 근거 종류 이름(reason, mechanism 등)의 최대 글자 수.
+ private static final int SLOT_MAX = 40;
+
+ private CompetencyReader() {
+ }
+
+ /** 응답 적중 등급. 0..4 정수만 받고 그 밖의 값은 비운다. */
+ static Integer readLevel(JsonNode node, String label, Long interviewId) {
+ if (isAbsent(node)) {
+ return null;
+ }
+ Integer level = wholeNumber(node);
+ if (level != null && level >= 0 && level <= LEVEL_MAX) {
+ return level;
+ }
+ log.warn("{} 응답 적중 등급이 0..{} 정수가 아니라 비워 둡니다. interviewId={}, 받은 값={}",
+ label, LEVEL_MAX, interviewId, node);
+ return null;
+ }
+
+ /**
+ * 점수에 들어간 역량 목록. 배열이 아니면 비운다.
+ *
+ * 역량 코드·역할·등급·점수 중 하나라도 어긋난 항목은 버린다. 무엇을 몇 점으로 매겼는지 모르는 항목은 화면에 그릴 수
+ * 없다. 근거 인용은 어긋난 것만 버리고 항목은 남긴다 — 인용이 없어도 등급은 보여 줄 수 있다.
+ */
+ static List read(JsonNode node, String label, Long interviewId) {
+ if (isAbsent(node)) {
+ return null;
+ }
+ if (!node.isArray()) {
+ log.warn("{} 역량 목록이 배열이 아니라 비워 둡니다. interviewId={}, 받은 모양={}",
+ label, interviewId, node.getNodeType());
+ return null;
+ }
+ List competencies = new ArrayList<>();
+ int index = 0;
+ for (JsonNode element : node.values()) {
+ CompetencyScoreResponse competency = readCompetency(element, label + " 역량 " + index++, interviewId);
+ if (competency != null) {
+ competencies.add(competency);
+ }
+ }
+ return competencies;
+ }
+
+ private static CompetencyScoreResponse readCompetency(JsonNode node, String label, Long interviewId) {
+ if (!node.isObject()) {
+ log.warn("{} 항목이 객체가 아니라 버립니다. interviewId={}, 받은 모양={}", label, interviewId, node.getNodeType());
+ return null;
+ }
+ String competency = code(node.get("competency"), COMPETENCIES);
+ String role = code(node.get("role"), ROLES);
+ Integer level = ranged(node.get("level"), LEVEL_MAX);
+ Integer score = ranged(node.get("score"), SCORE_MAX);
+ if (competency == null || role == null || level == null || score == null) {
+ log.warn("{} 항목을 버립니다. interviewId={}, competency={}, role={}, level={}, score={}",
+ label, interviewId, node.get("competency"), node.get("role"), node.get("level"), node.get("score"));
+ return null;
+ }
+ return new CompetencyScoreResponse(competency, role, level, score,
+ readEvidence(node.get("evidence"), label + "(" + competency + ")", interviewId));
+ }
+
+ /** 근거 인용. 근거 종류는 1..40자, 인용은 1..1000자 문자열이어야 한다. 어긋난 인용만 버린다. */
+ private static List readEvidence(JsonNode node, String label, Long interviewId) {
+ if (isAbsent(node)) {
+ return List.of();
+ }
+ if (!node.isArray()) {
+ log.warn("{} 근거 인용 목록이 배열이 아니라 비워 둡니다. interviewId={}, 받은 모양={}",
+ label, interviewId, node.getNodeType());
+ return List.of();
+ }
+ List evidence = new ArrayList<>();
+ for (JsonNode element : node.values()) {
+ String slot = element.isObject() ? string(element.get("slot"), SLOT_MAX) : null;
+ String quote = element.isObject() ? string(element.get("quote"), QUOTE_MAX) : null;
+ if (slot == null || quote == null) {
+ log.warn("{} 근거 인용 하나를 버립니다. interviewId={}, 받은 모양={}, 근거 종류 길이={}, 인용 길이={}",
+ label, interviewId, element.getNodeType(),
+ element.isObject() ? lengthOf(element.get("slot")) : null,
+ element.isObject() ? lengthOf(element.get("quote")) : null);
+ continue;
+ }
+ evidence.add(new CompetencyScoreResponse.Evidence(slot, quote));
+ }
+ return evidence;
+ }
+
+ private static String code(JsonNode node, Set allowed) {
+ return node != null && node.isString() && allowed.contains(node.stringValue()) ? node.stringValue() : null;
+ }
+
+ private static Integer ranged(JsonNode node, int max) {
+ Integer value = wholeNumber(node);
+ return value != null && value >= 0 && value <= max ? value : null;
+ }
+
+ // 비지 않고 maxLength(글자 수) 이하인 문자열. 원문 인용이라 공백을 다듬지 않는다.
+ private static String string(JsonNode node, int maxLength) {
+ if (node == null || !node.isString() || node.stringValue().isBlank()
+ || length(node.stringValue()) > maxLength) {
+ return null;
+ }
+ return node.stringValue();
+ }
+
+ // 로그에 원문 대신 남기는 길이. 문자열이 아니면 -1이다.
+ private static Integer lengthOf(JsonNode node) {
+ if (isAbsent(node)) {
+ return null;
+ }
+ return node.isString() ? length(node.stringValue()) : -1;
+ }
+}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java
index 91f8bf4..5f86630 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java
@@ -463,6 +463,8 @@ private FeedbackSoloRequest.Question toQuestion(QuestionEntity question, Set item.getFollowUpQuestion() != null).count(),
+ itemRows.stream().filter(item -> item.getCompetencies() != null).count(),
isPresent(result.getCompanyFit()), feedback.getCompanyFit() != null);
}
@@ -811,6 +816,10 @@ private List verifiedItems(FeedbackEntity feedback,
.axisScores(ScoreBreakdownReader.readAxisScores(item.getAxisScores(),
"문항 " + questionId, feedback.getInterviewId()))
.followUpQuestion(followUpQuestionOf(feedback, questionId, item.getFollowUpQuestion()))
+ .responseLevel(CompetencyReader.readLevel(item.getResponseLevel(),
+ "문항 " + questionId, feedback.getInterviewId()))
+ .competencies(CompetencyReader.read(item.getCompetencies(),
+ "문항 " + questionId, feedback.getInterviewId()))
.build());
}
return entities;
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/ScoreBreakdownReader.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/ScoreBreakdownReader.java
index 8120ae2..cadb618 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/ScoreBreakdownReader.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/ScoreBreakdownReader.java
@@ -3,6 +3,9 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import repit.repit_api_server.domain.userdata.feedback.dto.response.AxisScoreResponse;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.CredibilityResponse;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.MotivationSummaryResponse;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.ResponseSummaryResponse;
import repit.repit_api_server.domain.userdata.feedback.dto.response.ScoreBreakdownResponse;
import tools.jackson.databind.JsonNode;
@@ -10,6 +13,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* 채점 콜백의 점수 산출 근거를 읽는다.
@@ -26,6 +30,12 @@ final class ScoreBreakdownReader {
// feedback.scoring_version 컬럼 길이. 넘는 값을 그대로 넣으면 플러시에서 터져 결과가 폐기된다.
static final int SCORING_VERSION_MAX_LENGTH = 64;
+ // 답변 원문 인용 한 조각의 최대 글자 수. 분석 서버 인용은 짧은 구절이라, 넘으면 인용이 아니라 답변을 통째로 실은 것이다.
+ static final int QUOTE_MAX = 1_000;
+
+ private static final Set VERDICTS = Set.of("STRONG", "SOLID", "BORDERLINE", "WEAK");
+ private static final Set ISSUE_KINDS = Set.of("ROLE", "TECH_CHOICE", "CAUSE", "DETAIL", "OTHER");
+
private ScoreBreakdownReader() {
}
@@ -48,13 +58,17 @@ static ScoreBreakdownResponse read(JsonNode node, Integer total, String label, L
return null;
}
+ // 응답 적중·지원 동기는 축 목록에 넣지 않는다. 그래야 아래 재계산 식에 섞이지 않는다.
List axes = readAxes(node.get("axes"), label, interviewId);
warnIfTotalDiffers(axes, total, label, interviewId);
return new ScoreBreakdownResponse(
text(node.get("scoringVersion")),
axes,
- score(node.get("consistencyScore"), label + " 일관성 점수", interviewId));
+ score(node.get("consistencyScore"), label + " 일관성 점수", interviewId),
+ readResponse(node.get("response"), label, interviewId),
+ readMotivation(node.get("motivation"), label, interviewId),
+ readCredibility(node.get("credibility"), label, interviewId));
}
/**
@@ -133,15 +147,171 @@ private static List readAxes(JsonNode node, String label, Lon
List axes = new ArrayList<>();
for (int i = 0; i < names.size(); i++) {
JsonNode value = values.get(i);
- // 축 이름을 키로 한 모양에서는 값이 점수 하나일 수도 있다.
- JsonNode scoreNode = value.isObject() ? value.get("score") : value;
+ String what = label + " " + names.get(i) + " 축";
+ // 축 이름을 키로 한 모양에서는 값이 점수 하나일 수도 있다. 판정과 근거 문항 수는 competency-v1부터 실린다.
+ boolean object = value.isObject();
axes.add(new AxisScoreResponse(names.get(i),
- score(scoreNode, label + " " + names.get(i) + " 축 점수", interviewId),
- weights.get(i)));
+ score(object ? value.get("score") : value, what + " 점수", interviewId),
+ weights.get(i),
+ object ? verdict(value.get("verdict"), what + " 판정", interviewId) : null,
+ object ? count(value.get("evidenceCount"), what + " 근거 문항 수", interviewId) : null));
}
return axes;
}
+ /** 응답 적중(competency-v1). */
+ private static ResponseSummaryResponse readResponse(JsonNode node, String label, Long interviewId) {
+ String what = label + " 응답 적중";
+ if (!isObject(node, what, interviewId)) {
+ return null;
+ }
+ return new ResponseSummaryResponse(
+ score(node.get("score"), what + " 점수", interviewId),
+ count(node.get("hitCount"), what + " 문항 수", interviewId),
+ count(node.get("gradedCount"), what + " 채점 문항 수", interviewId));
+ }
+
+ /** 지원 동기·회사 이해(competency-v1). motivation 문항이 없는 면접은 비어서 온다. */
+ private static MotivationSummaryResponse readMotivation(JsonNode node, String label, Long interviewId) {
+ String what = label + " 지원 동기";
+ if (!isObject(node, what, interviewId)) {
+ return null;
+ }
+ return new MotivationSummaryResponse(
+ score(node.get("score"), what + " 점수", interviewId),
+ verdict(node.get("verdict"), what + " 판정", interviewId),
+ count(node.get("evidenceCount"), what + " 근거 문항 수", interviewId));
+ }
+
+ /** 신뢰성(competency-v1). 면접관별 근거에는 비어서 온다. */
+ private static CredibilityResponse readCredibility(JsonNode node, String label, Long interviewId) {
+ String what = label + " 신뢰성";
+ if (!isObject(node, what, interviewId)) {
+ return null;
+ }
+ return new CredibilityResponse(
+ score(node.get("score"), what + " 점수", interviewId),
+ readIssues(node.get("issues"), what, interviewId));
+ }
+
+ /**
+ * 서로 어긋난 답변 쌍. 모양이 어긋난 항목만 버린다.
+ *
+ * 모르는 종류는 버리지 않고 OTHER로 둔다 — 어긋났다는 사실은 그대로 쓸 수 있다. 문항 id와 인용은 둘씩 짝이 맞아야
+ * 화면이 "Q1 ↔ Q5"로 그릴 수 있어, 짝이 안 맞으면 그 항목을 버린다. 인용은 답변 원문이라 로그에는 사유와 길이만 남긴다.
+ */
+ private static List readIssues(JsonNode node, String label, Long interviewId) {
+ if (isAbsent(node)) {
+ return List.of();
+ }
+ if (!node.isArray()) {
+ log.warn("{} 모순 목록이 배열이 아니라 비워 둡니다. interviewId={}, 받은 모양={}",
+ label, interviewId, node.getNodeType());
+ return List.of();
+ }
+ List issues = new ArrayList<>();
+ int index = 0;
+ for (JsonNode element : node.values()) {
+ String what = label + " 모순 " + index++;
+ if (!element.isObject()) {
+ log.warn("{}이 객체가 아니라 버립니다. interviewId={}, 받은 모양={}",
+ what, interviewId, element.getNodeType());
+ continue;
+ }
+ List questionIds = pair(element.get("questionIds"), Integer.MAX_VALUE);
+ if (questionIds == null) {
+ log.warn("{}의 문항 id가 문자열 두 개가 아니라 버립니다. interviewId={}, 받은 값={}",
+ what, interviewId, element.get("questionIds"));
+ continue;
+ }
+ List quotes = pair(element.get("quotes"), QUOTE_MAX);
+ if (quotes == null) {
+ JsonNode received = element.get("quotes");
+ log.warn("{}의 인용이 {}자 이하 문자열 두 개가 아니라 버립니다. interviewId={}, 받은 모양={}, 길이={}",
+ what, QUOTE_MAX, interviewId, received == null ? null : received.getNodeType(),
+ quoteLengths(received));
+ continue;
+ }
+ issues.add(new CredibilityResponse.Issue(issueKind(element.get("kind"), what, interviewId),
+ questionIds, quotes));
+ }
+ return issues;
+ }
+
+ private static String issueKind(JsonNode node, String what, Long interviewId) {
+ String kind = text(node);
+ if (kind != null && ISSUE_KINDS.contains(kind)) {
+ return kind;
+ }
+ log.warn("{}의 종류를 알 수 없어 OTHER로 둡니다. interviewId={}, 받은 값={}", what, interviewId, node);
+ return "OTHER";
+ }
+
+ /** 비지 않은 문자열 정확히 두 개. 하나라도 maxLength(글자 수)를 넘으면 쓰지 않는다. */
+ private static List pair(JsonNode node, int maxLength) {
+ if (isAbsent(node) || !node.isArray() || node.size() != 2) {
+ return null;
+ }
+ List values = new ArrayList<>();
+ for (JsonNode element : node.values()) {
+ if (!element.isString() || element.stringValue().isBlank() || length(element.stringValue()) > maxLength) {
+ return null;
+ }
+ values.add(element.stringValue());
+ }
+ return values;
+ }
+
+ // 인용 원문 대신 남기는 길이. 문자열이 아닌 원소는 -1이다.
+ private static List quoteLengths(JsonNode node) {
+ if (isAbsent(node) || !node.isArray()) {
+ return null;
+ }
+ List lengths = new ArrayList<>();
+ for (JsonNode element : node.values()) {
+ lengths.add(element.isString() ? length(element.stringValue()) : -1);
+ }
+ return lengths;
+ }
+
+ /** 판정. 네 값 밖이면 비운다. */
+ private static String verdict(JsonNode node, String what, Long interviewId) {
+ if (isAbsent(node)) {
+ return null;
+ }
+ String verdict = node.isString() ? node.stringValue() : null;
+ if (verdict != null && VERDICTS.contains(verdict)) {
+ return verdict;
+ }
+ log.warn("{}이 {} 중 하나가 아니라 비워 둡니다. interviewId={}, 받은 값={}", what, VERDICTS, interviewId, node);
+ return null;
+ }
+
+ /** 0 이상의 정수 개수. 음수나 정수가 아닌 값은 비운다. */
+ private static Integer count(JsonNode node, String what, Long interviewId) {
+ if (isAbsent(node)) {
+ return null;
+ }
+ Integer count = wholeNumber(node);
+ if (count != null && count >= 0) {
+ return count;
+ }
+ log.warn("{}가 0 이상의 정수가 아니라 비워 둡니다. interviewId={}, 받은 값={}", what, interviewId, node);
+ return null;
+ }
+
+ // 없으면 조용히 비운다. 객체가 아니면 비우고 로그를 남긴다.
+ private static boolean isObject(JsonNode node, String what, Long interviewId) {
+ if (isAbsent(node)) {
+ return false;
+ }
+ if (!node.isObject()) {
+ log.warn("{} 값이 객체가 아니라 비워 둡니다. interviewId={}, 받은 모양={}", what, interviewId, node.getNodeType());
+ return false;
+ }
+ return true;
+ }
+
/**
* 축마다 가중치(%).
*
@@ -249,6 +419,24 @@ private static Double number(JsonNode node, String what, Long interviewId) {
return null;
}
+ /** 정수 값. 4.0처럼 소수점 아래가 없는 숫자도 받는다. 문자열이나 소수는 null이다. */
+ static Integer wholeNumber(JsonNode node) {
+ if (isAbsent(node) || !node.isNumber()) {
+ return null;
+ }
+ double value = node.doubleValue();
+ if (!Double.isFinite(value) || value != Math.rint(value)
+ || value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
+ return null;
+ }
+ return (int) value;
+ }
+
+ // 분석 서버(Python)가 세는 글자 수와 맞춘다. String.length()는 이모지 하나를 둘로 센다.
+ static int length(String value) {
+ return value.codePointCount(0, value.length());
+ }
+
private static Double finite(double value, JsonNode node, String what, Long interviewId) {
if (Double.isFinite(value)) {
return value;
@@ -264,7 +452,7 @@ private static String text(JsonNode node) {
return node.isString() ? node.stringValue() : node.asString();
}
- private static boolean isAbsent(JsonNode node) {
+ static boolean isAbsent(JsonNode node) {
return node == null || node.isNull() || node.isMissingNode();
}
}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequest.java
index 322c440..5bf9210 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequest.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequest.java
@@ -61,7 +61,8 @@ public static class Question {
// 채팅 서버가 매긴 번호. 우리 PK와는 다른 체계다. 꼬리질문은 음수로 온다.
private Long id;
private Long personaId;
- // 이 질문이 무엇을 묻는 갈래인지. 꼬리질문은 채팅 서버가 만든 의도가 실려 온다.
+ // 이 질문이 무엇을 묻는 갈래인지(tech_choice 등). 꼬리질문은 채팅 서버가 부모 질문의 값을 복사해 보낸다.
+ // 옛 채팅 면접은 이 자리에 질문 의도 문장을 보낸다 — 저장할 때 형식으로 걸러 낸다.
private String category;
private String question;
// 채점 기준. 면접을 열 때 우리가 넘긴 값이 돌아오고, 꼬리질문은 채팅 서버가 정한 의도가 온다.
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java
index e7dad24..81bc102 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java
@@ -49,10 +49,12 @@
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
@@ -61,6 +63,8 @@ public class InterviewService {
private static final Logger log = LoggerFactory.getLogger(InterviewService.class);
+ // 질문 카테고리로 받는 형식. 분석 서버 채점 계약(competency-v1)과 같다.
+ private static final Pattern CATEGORY = Pattern.compile("^[a-z][a-z0-9_]{0,63}$");
private final InterviewRepository interviewRepository;
private final QuestionRepository questionRepository;
@@ -545,6 +549,7 @@ private Map saveQuestions(InterviewEntity interview, List옛 채팅 면접은 카테고리 자리에 질문 의도 문장을 보낸다. 그 문장을 카테고리로 저장하면 분석 서버가 모르는 값으로 보고
+ * 어차피 미분류로 채점하지만, 화면·통계에서 분류처럼 보이므로 비운다. 의도 문장일 수 있어 원문은 로그에 남기지 않는다.
+ */
+ private String categoryOf(SaveInterviewRequest.Question question) {
+ String value = question.getCategory() == null ? null : question.getCategory().strip().toLowerCase(Locale.ROOT);
+ return value != null && CATEGORY.matcher(value).matches() ? value : null;
+ }
+
private void saveAnswers(InterviewEntity interview, List qnAs,
Map questionIdByChatId) {
List answers = new ArrayList<>();
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionEntity.java
index a692a58..687a065 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionEntity.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionEntity.java
@@ -43,6 +43,11 @@ public class QuestionEntity {
@Column(nullable = false)
private Type type;
+ // 질문 사이클·N:1 생성 질문의 카테고리(tech_choice 등). 분석 서버가 이 값으로 채점 역량을 고른다.
+ // 소문자 스네이크케이스만 담는다. 옛 채팅 면접이 이 자리에 보낸 의도 문장은 담지 않아 비어 있다.
+ @Column(length = 64)
+ private String category;
+
// 채팅 서버가 만드는 꼬리질문은 의도가 비어 올 수 있다. 여기서 막으면 면접 결과 저장이
// 통째로 실패하고, 사용자에게는 "면접 완료"가 실패로 보인다.
@Column(columnDefinition = "TEXT")
diff --git a/src/main/resources/db/migration/V42__competency_scoring.sql b/src/main/resources/db/migration/V42__competency_scoring.sql
new file mode 100644
index 0000000..82e30a3
--- /dev/null
+++ b/src/main/resources/db/migration/V42__competency_scoring.sql
@@ -0,0 +1,8 @@
+-- 질문 사이클·N:1 생성 질문의 카테고리. 분석 서버가 채점 역량을 고르는 열쇠다.
+-- 형식에 맞지 않는 값(옛 채팅 서버가 의도 문장을 넣어 보낸 경우)은 저장하지 않아 비어 있다.
+-- 기존 행은 채우지 않는다. 옛 면접은 재채점해도 미분류로 채점된다.
+ALTER TABLE interview_question ADD COLUMN category VARCHAR(64);
+
+-- 문항별 응답 적중 등급(0~4)과 역량별 등급·근거 인용. competency-v1 이전 결과는 비어 있다.
+ALTER TABLE feedback_item ADD COLUMN response_level SMALLINT;
+ALTER TABLE feedback_item ADD COLUMN competencies JSONB;
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java
index c83b591..7a8eb21 100644
--- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java
+++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java
@@ -63,6 +63,23 @@ private FeedbackMultiRequest request() {
});
}
+ @Test
+ void 질문_카테고리는_있을_때만_직렬화된다() {
+ contextRunner.run(context -> {
+ ObjectMapper objectMapper = context.getBean(ObjectMapper.class);
+ FeedbackMultiRequest request = FeedbackMultiRequest.builder()
+ .questions(List.of(
+ FeedbackMultiRequest.Question.builder().questionId("901").category("motivation").build(),
+ FeedbackMultiRequest.Question.builder().questionId("902").build()))
+ .build();
+
+ JsonNode questions = objectMapper.readTree(objectMapper.writeValueAsString(request)).get("questions");
+
+ assertThat(questions.get(0).get("category").stringValue()).isEqualTo("motivation");
+ assertThat(questions.get(1).has("category")).isFalse();
+ });
+ }
+
@Test
void 시각은_ISO_8601_UTC로_나간다() {
contextRunner.run(context -> {
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java
index 2636e3e..5d20ac3 100644
--- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java
+++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java
@@ -68,6 +68,24 @@ class FeedbackSoloRequestSerializationTest {
});
}
+ /** 카테고리가 없는 질문은 키 자체를 보내지 않는다. 분석 서버는 키가 없으면 미분류로 채점한다. */
+ @Test
+ void 질문_카테고리는_있을_때만_직렬화된다() {
+ contextRunner.run(context -> {
+ ObjectMapper mapper = context.getBean(ObjectMapper.class);
+ FeedbackSoloRequest request = FeedbackSoloRequest.builder()
+ .questions(List.of(
+ FeedbackSoloRequest.Question.builder().questionId("11").category("tech_choice").build(),
+ FeedbackSoloRequest.Question.builder().questionId("12").build()))
+ .build();
+
+ JsonNode questions = mapper.readTree(mapper.writeValueAsString(request)).get("questions");
+
+ assertThat(questions.get(0).get("category").stringValue()).isEqualTo("tech_choice");
+ assertThat(questions.get(1).has("category")).isFalse();
+ });
+ }
+
@Test
void 면접_상태는_interviewStatus_키로_직렬화된다() {
contextRunner.run(context -> {
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/CompetencyReaderTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/CompetencyReaderTest.java
new file mode 100644
index 0000000..a2d6f7a
--- /dev/null
+++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/CompetencyReaderTest.java
@@ -0,0 +1,158 @@
+package repit.repit_api_server.domain.userdata.feedback.service;
+
+import org.junit.jupiter.api.Test;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.CompetencyScoreResponse;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.CompetencyScoreResponse.Evidence;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 문항의 응답 적중 등급과 역량별 근거(competency-v1)를 모양이 어긋나도 읽어내는지 본다.
+ *
+ * 어긋난 항목과 인용만 버리고 콜백 전체는 거절하지 않는다. 거절하면 분석 서버가 채점 결과를 폐기한다.
+ */
+class CompetencyReaderTest {
+
+ private static JsonNode json(String text) {
+ return JsonMapper.shared().readTree(text);
+ }
+
+ private static Integer level(String text) {
+ return CompetencyReader.readLevel(json(text), "문항 101", 1L);
+ }
+
+ private static List read(String text) {
+ return CompetencyReader.read(json(text), "문항 101", 1L);
+ }
+
+ @Test
+ void 응답_적중_등급은_0부터_4까지_정수만_받는다() {
+ assertThat(level("0")).isZero();
+ assertThat(level("4")).isEqualTo(4);
+ assertThat(level("4.0")).isEqualTo(4);
+
+ assertThat(level("5")).isNull();
+ assertThat(level("-1")).isNull();
+ assertThat(level("2.5")).isNull();
+ assertThat(level("\"3\"")).isNull();
+ assertThat(level("null")).isNull();
+ assertThat(CompetencyReader.readLevel(null, "문항 101", 1L)).isNull();
+ }
+
+ /** 연동 계약(01 문서) 4.3 예시. */
+ @Test
+ void 계약대로_온_역량_목록을_읽는다() {
+ List competencies = read("""
+ [
+ {
+ "competency": "PROBLEM_SOLVING", "role": "PRIMARY", "level": 3, "score": 75,
+ "evidence": [
+ { "slot": "reason", "quote": "재고 차감 경합이 잦아" },
+ { "slot": "alternatives", "quote": "DB 비관적 락은 커넥션을 오래 잡아서" }
+ ]
+ },
+ {
+ "competency": "KNOWLEDGE", "role": "SECONDARY", "level": 4, "score": 100,
+ "evidence": [ { "slot": "mechanism", "quote": "TTL이 지나면 락이 자동으로 풀리고" } ]
+ }
+ ]
+ """);
+
+ assertThat(competencies).containsExactly(
+ new CompetencyScoreResponse("PROBLEM_SOLVING", "PRIMARY", 3, 75, List.of(
+ new Evidence("reason", "재고 차감 경합이 잦아"),
+ new Evidence("alternatives", "DB 비관적 락은 커넥션을 오래 잡아서"))),
+ new CompetencyScoreResponse("KNOWLEDGE", "SECONDARY", 4, 100, List.of(
+ new Evidence("mechanism", "TTL이 지나면 락이 자동으로 풀리고"))));
+ }
+
+ /** 지원 동기 문항은 종합에 들어가지 않는 MOTIVATION 하나로 온다. */
+ @Test
+ void 지원_동기도_역량_항목으로_읽는다() {
+ assertThat(read("""
+ [{ "competency": "MOTIVATION", "role": "PRIMARY", "level": 2, "score": 50, "evidence": [] }]
+ """)).extracting(CompetencyScoreResponse::competency).containsExactly("MOTIVATION");
+ }
+
+ /** 무엇을 몇 점으로 매겼는지 모르는 항목은 화면에 그릴 수 없다. 그 항목만 버리고 나머지는 남긴다. */
+ @Test
+ void 코드나_등급이_어긋난_항목만_버린다() {
+ List competencies = read("""
+ [
+ { "competency": "LEADERSHIP", "role": "PRIMARY", "level": 3, "score": 75 },
+ { "competency": "KNOWLEDGE", "role": "MAIN", "level": 3, "score": 75 },
+ { "competency": "KNOWLEDGE", "role": "PRIMARY", "level": 5, "score": 75 },
+ { "competency": "KNOWLEDGE", "role": "PRIMARY", "level": 3, "score": 101 },
+ { "competency": "KNOWLEDGE", "role": "PRIMARY", "level": 3 },
+ { "competency": "knowledge", "role": "PRIMARY", "level": 3, "score": 75 },
+ "OWNERSHIP",
+ { "competency": "OWNERSHIP", "role": "SECONDARY", "level": 2, "score": 50 }
+ ]
+ """);
+
+ assertThat(competencies).containsExactly(
+ new CompetencyScoreResponse("OWNERSHIP", "SECONDARY", 2, 50, List.of()));
+ }
+
+ /** 인용이 없어도 등급은 보여 줄 수 있다. 어긋난 인용만 버리고 항목은 남긴다. */
+ @Test
+ void 어긋난_근거_인용만_버린다() {
+ String longQuote = "가".repeat(1_001);
+ String longSlot = "s".repeat(41);
+ List competencies = read("""
+ [{
+ "competency": "OWNERSHIP", "role": "PRIMARY", "level": 4, "score": 100,
+ "evidence": [
+ { "slot": "action", "quote": "배포 스크립트를 직접 고쳤고" },
+ { "slot": "method", "quote": " " },
+ { "slot": "", "quote": "롤백 절차를 문서로 남겼습니다" },
+ { "slot": "%s", "quote": "결과" },
+ { "slot": "result", "quote": "%s" },
+ { "slot": "result", "quote": 3 },
+ "배포 시간이 절반으로 줄었습니다",
+ { "slot": "result", "quote": "%s" }
+ ]
+ }]
+ """.formatted(longSlot, longQuote, "가".repeat(1_000)));
+
+ assertThat(competencies).singleElement().satisfies(competency -> {
+ assertThat(competency.level()).isEqualTo(4);
+ assertThat(competency.evidence()).containsExactly(
+ new Evidence("action", "배포 스크립트를 직접 고쳤고"),
+ new Evidence("result", "가".repeat(1_000)));
+ });
+ }
+
+ /** 분석 서버(Python)는 글자 수를 코드 포인트로 센다. 이모지가 섞인 인용도 같은 한도로 본다. */
+ @Test
+ void 인용_길이는_코드_포인트로_센다() {
+ String emojiQuote = "😀".repeat(1_000);
+
+ List competencies = read("""
+ [{ "competency": "GROWTH", "role": "PRIMARY", "level": 2, "score": 50,
+ "evidence": [{ "slot": "lesson", "quote": "%s" }] }]
+ """.formatted(emojiQuote));
+
+ assertThat(competencies.getFirst().evidence()).extracting(Evidence::quote).containsExactly(emojiQuote);
+ }
+
+ @Test
+ void 근거_인용_목록이_배열이_아니면_비운_채_항목을_남긴다() {
+ assertThat(read("""
+ [{ "competency": "GROWTH", "role": "PRIMARY", "level": 1, "score": 25, "evidence": "많이 배웠다" }]
+ """)).singleElement().satisfies(competency -> assertThat(competency.evidence()).isEmpty());
+ }
+
+ @Test
+ void 배열이_아닌_역량_목록은_비운다() {
+ assertThat(read("{ \"PROBLEM_SOLVING\": 75 }")).isNull();
+ assertThat(read("\"PROBLEM_SOLVING\"")).isNull();
+ assertThat(read("null")).isNull();
+ assertThat(CompetencyReader.read(null, "문항 101", 1L)).isNull();
+ assertThat(read("[]")).isEmpty();
+ }
+}
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackCallbackPersistenceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackCallbackPersistenceTest.java
index d235176..bcd546a 100644
--- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackCallbackPersistenceTest.java
+++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackCallbackPersistenceTest.java
@@ -9,7 +9,10 @@
import org.springframework.transaction.annotation.Transactional;
import repit.repit_api_server.domain.userdata.feedback.dto.request.FeedbackCallbackRequest;
import repit.repit_api_server.domain.userdata.feedback.dto.response.AxisScoreResponse;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.CompetencyScoreResponse;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.CredibilityResponse;
import repit.repit_api_server.domain.userdata.feedback.dto.response.FrequentWordResponse;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.ResponseSummaryResponse;
import repit.repit_api_server.domain.userdata.feedback.dto.response.ScoreBreakdownResponse;
import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackEntity;
import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackItemEntity;
@@ -40,6 +43,7 @@
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.groups.Tuple.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@@ -110,7 +114,7 @@ private FeedbackCallbackRequest multiCallback() {
"조회가 쓰기보다 많아서요.", "지연을 수치로 제시한다.",
List.of("p99 지연을 근거로 든 점"), List.of("무효화 전략 미언급"),
"부작용까지는 못 짚었습니다.", null,
- JsonMapper.shared().valueToTree(FOLLOW_UP_QUESTION));
+ JsonMapper.shared().valueToTree(FOLLOW_UP_QUESTION), null, null);
return new FeedbackCallbackRequest(jobId, sessionId, "succeeded",
new FeedbackCallbackRequest.Result(overall, List.of(tech, hr), List.of(item), null), null);
@@ -178,7 +182,7 @@ private void flushAndClear() {
void 예상_꼬리질문이_없는_콜백도_문항이_저장된다() {
FeedbackEntity accepted = givenAccepted();
FeedbackCallbackRequest.Item item = new FeedbackCallbackRequest.Item(
- "901", 11L, "질문", "의도", "답변", "모범답변", List.of(), List.of(), "총평", null, null);
+ "901", 11L, "질문", "의도", "답변", "모범답변", List.of(), List.of(), "총평", null, null, null, null);
FeedbackCallbackRequest.Overall overall = new FeedbackCallbackRequest.Overall(
70, 70, 70, "요약", List.of(), List.of(), List.of(), 1, 1, null);
@@ -241,7 +245,7 @@ private void flushAndClear() {
12L, "HR", null, "담당 답변 없음", List.of(), List.of(), 0, 0, null);
FeedbackCallbackRequest.Item item = new FeedbackCallbackRequest.Item(
"901", 11L, "질문", "의도", "답변", "모범답변", List.of(), List.of(), "총평",
- JsonMapper.shared().valueToTree(axisScores), null);
+ JsonMapper.shared().valueToTree(axisScores), null, null, null);
feedbackService.handleCallback(new FeedbackCallbackRequest(jobId, sessionId, "succeeded",
new FeedbackCallbackRequest.Result(overall, List.of(tech, hr), List.of(item), null), null));
@@ -269,6 +273,162 @@ private void flushAndClear() {
.containsEntry("ACCURACY", null);
}
+ /**
+ * competency-v1 콜백. 연동 계약(01 문서) 4장 예시 본문이다. 종합 점수만 축 점수와 가중치로 다시 계산한 값(66)으로 바꿨다 —
+ * 예시의 68은 계약의 보장 식과 어긋난다.
+ */
+ private static final String COMPETENCY_CALLBACK = """
+ {
+ "jobId": "%s",
+ "sessionId": "%s",
+ "status": "succeeded",
+ "result": {
+ "overall": {
+ "totalScore": 66,
+ "intentAlignmentScore": 88,
+ "reliabilityScore": 75,
+ "scoreBreakdown": {
+ "scoringVersion": "competency-v1",
+ "axes": [
+ { "axis": "PROBLEM_SOLVING", "score": 75, "weight": 30, "verdict": "SOLID", "evidenceCount": 3 },
+ { "axis": "KNOWLEDGE", "score": 58, "weight": 25, "verdict": "BORDERLINE", "evidenceCount": 2 },
+ { "axis": "OWNERSHIP", "score": 70, "weight": 25, "verdict": "SOLID", "evidenceCount": 3 },
+ { "axis": "COLLABORATION", "score": null, "weight": null, "verdict": null, "evidenceCount": 0 },
+ { "axis": "GROWTH", "score": 50, "weight": 10, "verdict": "BORDERLINE", "evidenceCount": 1 }
+ ],
+ "consistencyScore": 75,
+ "response": { "score": 88, "hitCount": 5, "gradedCount": 6 },
+ "motivation": null,
+ "credibility": {
+ "score": 75,
+ "issues": [
+ {
+ "kind": "ROLE",
+ "questionIds": ["101", "105"],
+ "quotes": ["결제 모듈은 제가 전부 맡았습니다", "결제 쪽은 동료가 개발했고"]
+ }
+ ]
+ }
+ },
+ "summary": "요약",
+ "strengths": [],
+ "improvements": [],
+ "frequentWords": [],
+ "answeredCount": 6,
+ "questionCount": 6
+ },
+ "feedbacks": [
+ {
+ "questionId": "101",
+ "questionContent": "재고 차감에 Redis 분산락을 쓴 이유는 무엇인가요?",
+ "intention": "재고 차감에 분산락을 고른 이유를 DB 락과 비교해 설명할 수 있는지",
+ "userAnswer": "재고 차감 경합이 잦아 DB 비관적 락은 커넥션을 오래 잡아서 분산락을 썼고, TTL이 지나면 락이 자동으로 풀리고",
+ "comment": "총평",
+ "responseLevel": 4,
+ "axisScores": { "PROBLEM_SOLVING": 75, "KNOWLEDGE": 100 },
+ "competencies": [
+ {
+ "competency": "PROBLEM_SOLVING",
+ "role": "PRIMARY",
+ "level": 3,
+ "score": 75,
+ "evidence": [
+ { "slot": "reason", "quote": "재고 차감 경합이 잦아" },
+ { "slot": "alternatives", "quote": "DB 비관적 락은 커넥션을 오래 잡아서" }
+ ]
+ },
+ {
+ "competency": "KNOWLEDGE",
+ "role": "SECONDARY",
+ "level": 4,
+ "score": 100,
+ "evidence": [ { "slot": "mechanism", "quote": "TTL이 지나면 락이 자동으로 풀리고" } ]
+ }
+ ]
+ }
+ ]
+ }
+ }
+ """;
+
+ /** 역량 축 결과는 새 필드가 저장되고, 조회 응답에 그대로 나간다. 비어 있는 지원 동기는 키가 없다. */
+ @Test
+ void competency_v1_결과가_저장되고_조회_응답에_그대로_나간다() {
+ FeedbackEntity accepted = givenAccepted();
+ FeedbackCallbackRequest callback = JsonMapper.shared().readValue(
+ COMPETENCY_CALLBACK.formatted(jobId, sessionId), FeedbackCallbackRequest.class);
+
+ feedbackService.handleCallback(callback);
+ flushAndClear();
+
+ FeedbackEntity saved = feedbackRepository.findById(accepted.getFeedbackId()).orElseThrow();
+ assertThat(saved.getScoringVersion()).isEqualTo("competency-v1");
+ assertThat(saved.getTotalScore()).isEqualTo(66);
+ ScoreBreakdownResponse breakdown = saved.getScoreBreakdown();
+ assertThat(breakdown.getAxes())
+ .extracting(AxisScoreResponse::getAxis, AxisScoreResponse::getScore, AxisScoreResponse::getWeight,
+ AxisScoreResponse::getVerdict, AxisScoreResponse::getEvidenceCount)
+ .containsExactly(
+ tuple("PROBLEM_SOLVING", 75, 30, "SOLID", 3),
+ tuple("KNOWLEDGE", 58, 25, "BORDERLINE", 2),
+ tuple("OWNERSHIP", 70, 25, "SOLID", 3),
+ tuple("COLLABORATION", null, null, null, 0),
+ tuple("GROWTH", 50, 10, "BORDERLINE", 1));
+ assertThat(breakdown.getResponse()).isEqualTo(new ResponseSummaryResponse(88, 5, 6));
+ assertThat(breakdown.getMotivation()).isNull();
+ assertThat(breakdown.getCredibility()).isEqualTo(new CredibilityResponse(75, List.of(
+ new CredibilityResponse.Issue("ROLE", List.of("101", "105"),
+ List.of("결제 모듈은 제가 전부 맡았습니다", "결제 쪽은 동료가 개발했고")))));
+
+ FeedbackItemEntity item =
+ feedbackItemRepository.findAllByFeedbackIdOrderBySortOrderAsc(accepted.getFeedbackId()).getFirst();
+ assertThat(item.getResponseLevel()).isEqualTo(4);
+ // jsonb는 키 순서를 지키지 않는다.
+ assertThat(item.getAxisScores()).containsOnly(Map.entry("PROBLEM_SOLVING", 75), Map.entry("KNOWLEDGE", 100));
+ assertThat(item.getCompetencies()).containsExactly(
+ new CompetencyScoreResponse("PROBLEM_SOLVING", "PRIMARY", 3, 75, List.of(
+ new CompetencyScoreResponse.Evidence("reason", "재고 차감 경합이 잦아"),
+ new CompetencyScoreResponse.Evidence("alternatives", "DB 비관적 락은 커넥션을 오래 잡아서"))),
+ new CompetencyScoreResponse("KNOWLEDGE", "SECONDARY", 4, 100, List.of(
+ new CompetencyScoreResponse.Evidence("mechanism", "TTL이 지나면 락이 자동으로 풀리고"))));
+
+ JsonNode body = JsonMapper.shared().valueToTree(feedbackService.getFeedback(9L, 999_999L));
+ JsonNode responseBreakdown = body.get("scoreBreakdown");
+ assertThat(responseBreakdown.get("axes").get(3).has("verdict")).isFalse();
+ assertThat(responseBreakdown.get("axes").get(3).get("evidenceCount").intValue()).isZero();
+ assertThat(responseBreakdown.get("response").get("hitCount").intValue()).isEqualTo(5);
+ assertThat(responseBreakdown.has("motivation")).isFalse();
+ assertThat(responseBreakdown.get("credibility").get("issues").get(0).get("kind").stringValue())
+ .isEqualTo("ROLE");
+ JsonNode responseItem = body.get("feedbacks").get(0);
+ assertThat(responseItem.get("responseLevel").intValue()).isEqualTo(4);
+ assertThat(responseItem.get("competencies").get(1).get("evidence").get(0).get("quote").stringValue())
+ .isEqualTo("TTL이 지나면 락이 자동으로 풀리고");
+ }
+
+ /** axis-v4 결과에는 새 필드가 없다. 조회 응답에도 키가 생기지 않아 지금 화면이 그대로다. */
+ @Test
+ void axis_v4_결과의_조회_응답에는_새_키가_없다() {
+ givenAccepted();
+ JsonNode breakdown = JsonMapper.shared().valueToTree(new ScoreBreakdownResponse("axis-v4", List.of(
+ new AxisScoreResponse("INTENT", 88, 35)), 75));
+ FeedbackCallbackRequest.Overall overall = new FeedbackCallbackRequest.Overall(
+ 88, 88, 69, "요약", List.of(), List.of(), List.of(), 1, 1, breakdown);
+ FeedbackCallbackRequest.Item item = new FeedbackCallbackRequest.Item(
+ "901", null, "질문", "의도", "답변", "모범답변", List.of(), List.of(), "총평", null, null, null, null);
+
+ feedbackService.handleCallback(new FeedbackCallbackRequest(jobId, sessionId, "succeeded",
+ new FeedbackCallbackRequest.Result(overall, null, List.of(item), null), null));
+ flushAndClear();
+
+ JsonNode body = JsonMapper.shared().valueToTree(feedbackService.getFeedback(9L, 999_999L));
+ assertThat(body.get("scoreBreakdown").get("axes").get(0).has("verdict")).isFalse();
+ assertThat(body.get("scoreBreakdown").get("axes").get(0).has("evidenceCount")).isFalse();
+ assertThat(body.get("scoreBreakdown").has("response")).isFalse();
+ assertThat(body.get("scoreBreakdown").has("credibility")).isFalse();
+ assertThat(body.get("feedbacks").get(0).get("competencies").isNull()).isTrue();
+ }
+
/** 컬럼보다 긴 버전을 그대로 넣으면 플러시에서 터지고, 트랜잭션이 롤백돼 채점 결과가 폐기된다. */
@Test
void 긴_방식_버전이_와도_결과가_저장된다() {
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java
index 34a41df..5a84902 100644
--- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java
+++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java
@@ -111,7 +111,7 @@ private FeedbackCallbackRequest callback() {
FeedbackCallbackRequest.Item item = new FeedbackCallbackRequest.Item(
"2", 11L, "Redis를 캐시로 두신 이유는?", "기술 선택의 근거",
"조회가 쓰기보다 많아서요.", "지연을 수치로 제시한다.",
- List.of("p99 지연을 근거로 든 점"), List.of("무효화 전략 미언급"), "부작용까지는 못 짚었습니다.", null, null);
+ List.of("p99 지연을 근거로 든 점"), List.of("무효화 전략 미언급"), "부작용까지는 못 짚었습니다.", null, null, null, null);
return new FeedbackCallbackRequest("job-1", "sess-1", "succeeded",
new FeedbackCallbackRequest.Result(overall, List.of(tech, hr, ceo), List.of(item), null), null);
@@ -253,7 +253,7 @@ private FeedbackEntity acceptedFeedback() {
FeedbackCallbackRequest request = callback();
FeedbackCallbackRequest.Item stray = new FeedbackCallbackRequest.Item(
"903", 99L, "팀에서 갈등이 있었다면?", "협업 태도",
- null, "사실과 대응을 나눠 말한다.", List.of(), List.of(), "답하지 않았습니다.", null, null);
+ null, "사실과 대응을 나눠 말한다.", List.of(), List.of(), "답하지 않았습니다.", null, null, null, null);
FeedbackCallbackRequest withStray = new FeedbackCallbackRequest("job-1", "sess-1", "succeeded",
new FeedbackCallbackRequest.Result(request.getResult().getOverall(),
request.getResult().getPersonas(), List.of(stray), null),
@@ -298,7 +298,7 @@ private FeedbackCallbackRequest callbackWith(List axisScores = new LinkedHashMap<>();
axisScores.put("DEPTH", -5);
FeedbackCallbackRequest.Item item = new FeedbackCallbackRequest.Item("2", 11L, "질문", "의도",
- "답변", "모범답변", List.of(), List.of(), "총평", json(axisScores), null);
+ "답변", "모범답변", List.of(), List.of(), "총평", json(axisScores), null, null, null);
service.handleCallback(new FeedbackCallbackRequest("job-1", "sess-1", "succeeded",
new FeedbackCallbackRequest.Result(overall, List.of(persona(11L, 78)), List.of(item), null), null));
@@ -532,6 +532,6 @@ private JsonNode json(Object value) {
private FeedbackCallbackRequest.Item followUp(String questionId, JsonNode followUpQuestion) {
return new FeedbackCallbackRequest.Item(questionId, 11L, "질문", "의도",
- "답변", "모범답변", List.of(), List.of(), "총평", null, followUpQuestion);
+ "답변", "모범답변", List.of(), List.of(), "총평", null, followUpQuestion, null, null);
}
}
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java
index 3b948d3..3e15ab7 100644
--- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java
+++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java
@@ -254,7 +254,7 @@ private PersonaEntity persona(Type type) {
.build();
}
- /** 저장된 면접 기록. 901은 최초 질문, 902는 901에 달린 꼬리질문이다. */
+ /** 저장된 면접 기록. 901은 최초 질문, 902는 901에 달린 꼬리질문이다. 902는 카테고리 없이 저장된 옛 꼬리질문이다. */
private List questions() {
return List.of(
QuestionEntity.builder()
@@ -263,6 +263,7 @@ private List questions() {
.chatQuestionId(1L)
.personaId(5L)
.type(repit.repit_api_server.domain.userdata.question.entity.enums.Type.ORIGINAL)
+ .category("tech_choice")
.intention("도입 근거 확인")
.content("WebFlux 를 도입한 이유가 무엇인가요?")
.createdAt(LocalDateTime.parse("2026-08-18T01:00:00"))
@@ -385,6 +386,30 @@ private FeedbackSoloRequest captureRequest() {
assertThat(questions.get(1).getParentId()).isEqualTo("901");
}
+ /** 분석 서버가 이 값으로 채점 역량을 고른다. 비어 있는 꼬리질문은 분석 서버가 부모 값을 물려 쓰므로 그대로 비워 보낸다. */
+ @Test
+ void 저장된_질문_카테고리를_그대로_싣는다() {
+ service.requestFeedback(USER_ID, 3L);
+
+ assertThat(captureRequest().getQuestions()).extracting(FeedbackSoloRequest.Question::getCategory)
+ .containsExactly("tech_choice", null);
+ }
+
+ @Test
+ void N대1_채점에도_질문_카테고리를_싣는다() {
+ when(interviewRepository.findById(3L)).thenReturn(Optional.of(interview(Status.COMPLETED, null)));
+ when(interviewPersonaRepository.findAllByInterviewIdOrderByPersonaOrderAsc(3L)).thenReturn(List.of(
+ InterviewPersonaEntity.builder().interviewId(3L).personaId(5L).personaOrder(0).build()));
+ when(personaRepository.findAllById(List.of(5L))).thenReturn(List.of(persona(Type.REALISTIC)));
+
+ service.requestFeedback(USER_ID, 3L);
+
+ ArgumentCaptor sent = ArgumentCaptor.forClass(FeedbackMultiRequest.class);
+ verify(aiServerClient).requestMultiFeedback(sent.capture());
+ assertThat(sent.getValue().getQuestions()).extracting(FeedbackMultiRequest.Question::getCategory)
+ .containsExactly("tech_choice", null);
+ }
+
@Test
void 답변에는_저장된_답변_번호를_싣는다() {
service.requestFeedback(USER_ID, 3L);
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/ScoreBreakdownReaderTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/ScoreBreakdownReaderTest.java
index a388e63..e4539a5 100644
--- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/ScoreBreakdownReaderTest.java
+++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/ScoreBreakdownReaderTest.java
@@ -2,10 +2,14 @@
import org.junit.jupiter.api.Test;
import repit.repit_api_server.domain.userdata.feedback.dto.response.AxisScoreResponse;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.CredibilityResponse;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.MotivationSummaryResponse;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.ResponseSummaryResponse;
import repit.repit_api_server.domain.userdata.feedback.dto.response.ScoreBreakdownResponse;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
+import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@@ -52,6 +56,154 @@ private static ScoreBreakdownResponse read(String text, Integer total) {
tuple("ACCURACY", null, null));
}
+ /** axis-v4 본문에는 판정·근거 문항 수·응답 적중·지원 동기·신뢰성이 없다. 지금과 똑같이 읽히고 새 칸은 빈다. */
+ @Test
+ void axis_v4_본문은_새_필드가_비어_있다() {
+ ScoreBreakdownResponse breakdown = read("""
+ {
+ "scoringVersion": "axis-v4",
+ "axes": [
+ { "axis": "INTENT", "score": 88, "weight": 35 },
+ { "axis": "DEPTH", "score": 38, "weight": 25 },
+ { "axis": "SPECIFICITY", "score": 63, "weight": 25 },
+ { "axis": "ACCURACY", "score": null, "weight": null }
+ ],
+ "consistencyScore": 75
+ }
+ """, 63);
+
+ assertThat(breakdown.getAxes()).extracting(AxisScoreResponse::getVerdict, AxisScoreResponse::getEvidenceCount)
+ .containsOnly(tuple(null, null));
+ assertThat(breakdown.getResponse()).isNull();
+ assertThat(breakdown.getMotivation()).isNull();
+ assertThat(breakdown.getCredibility()).isNull();
+ }
+
+ /** 연동 계약(01 문서) 4.2 예시. 종합 점수만 축 점수와 가중치로 다시 계산한 값(66)으로 둔다. */
+ @Test
+ void competency_v1_산출_근거를_읽는다() {
+ ScoreBreakdownResponse breakdown = read("""
+ {
+ "scoringVersion": "competency-v1",
+ "axes": [
+ { "axis": "PROBLEM_SOLVING", "score": 75, "weight": 30, "verdict": "SOLID", "evidenceCount": 3 },
+ { "axis": "KNOWLEDGE", "score": 58, "weight": 25, "verdict": "BORDERLINE", "evidenceCount": 2 },
+ { "axis": "OWNERSHIP", "score": 70, "weight": 25, "verdict": "SOLID", "evidenceCount": 3 },
+ { "axis": "COLLABORATION", "score": null, "weight": null, "verdict": null, "evidenceCount": 0 },
+ { "axis": "GROWTH", "score": 50, "weight": 10, "verdict": "BORDERLINE", "evidenceCount": 1 }
+ ],
+ "consistencyScore": 75,
+ "response": { "score": 88, "hitCount": 5, "gradedCount": 6 },
+ "motivation": { "score": 50, "verdict": "BORDERLINE", "evidenceCount": 1 },
+ "credibility": {
+ "score": 75,
+ "issues": [
+ { "kind": "ROLE", "questionIds": ["101", "105"],
+ "quotes": ["결제 모듈은 제가 전부 맡았습니다", "결제 쪽은 동료가 개발했고"] }
+ ]
+ }
+ }
+ """, 66);
+
+ assertThat(breakdown.getScoringVersion()).isEqualTo("competency-v1");
+ assertThat(breakdown.getAxes())
+ .extracting(AxisScoreResponse::getAxis, AxisScoreResponse::getScore, AxisScoreResponse::getWeight,
+ AxisScoreResponse::getVerdict, AxisScoreResponse::getEvidenceCount)
+ .containsExactly(
+ tuple("PROBLEM_SOLVING", 75, 30, "SOLID", 3),
+ tuple("KNOWLEDGE", 58, 25, "BORDERLINE", 2),
+ tuple("OWNERSHIP", 70, 25, "SOLID", 3),
+ tuple("COLLABORATION", null, null, null, 0),
+ tuple("GROWTH", 50, 10, "BORDERLINE", 1));
+ assertThat(breakdown.getResponse()).isEqualTo(new ResponseSummaryResponse(88, 5, 6));
+ assertThat(breakdown.getMotivation()).isEqualTo(new MotivationSummaryResponse(50, "BORDERLINE", 1));
+ assertThat(breakdown.getCredibility()).isEqualTo(new CredibilityResponse(75, List.of(
+ new CredibilityResponse.Issue("ROLE", List.of("101", "105"),
+ List.of("결제 모듈은 제가 전부 맡았습니다", "결제 쪽은 동료가 개발했고")))));
+ }
+
+ /** 판정 오타나 음수 개수가 와도 그 칸만 비운다. 축 점수와 가중치는 그대로 쓸 수 있다. */
+ @Test
+ void 쓸_수_없는_판정과_근거_문항_수는_비운다() {
+ ScoreBreakdownResponse breakdown = read("""
+ { "axes": [
+ { "axis": "PROBLEM_SOLVING", "score": 75, "weight": 30, "verdict": "Solid", "evidenceCount": -1 },
+ { "axis": "KNOWLEDGE", "score": 58, "weight": 25, "verdict": 3, "evidenceCount": 1.5 },
+ { "axis": "OWNERSHIP", "score": 70, "weight": 25, "verdict": "STRONG", "evidenceCount": "3" },
+ { "axis": "GROWTH", "score": 50, "weight": 10, "verdict": "WEAK", "evidenceCount": 2.0 }
+ ] }
+ """, 66);
+
+ assertThat(breakdown.getAxes())
+ .extracting(AxisScoreResponse::getScore, AxisScoreResponse::getVerdict, AxisScoreResponse::getEvidenceCount)
+ .containsExactly(
+ tuple(75, null, null),
+ tuple(58, null, null),
+ tuple(70, "STRONG", null),
+ tuple(50, "WEAK", 2));
+ }
+
+ @Test
+ void 응답_적중과_지원_동기는_모양이_어긋난_칸만_비운다() {
+ ScoreBreakdownResponse breakdown = read("""
+ {
+ "axes": [],
+ "response": { "score": 140, "hitCount": -2, "gradedCount": "6" },
+ "motivation": { "score": null, "verdict": "GOOD", "evidenceCount": 0 },
+ "credibility": "없음"
+ }
+ """, 0);
+
+ assertThat(breakdown.getResponse()).isEqualTo(new ResponseSummaryResponse(100, null, null));
+ assertThat(breakdown.getMotivation()).isEqualTo(new MotivationSummaryResponse(null, null, 0));
+ assertThat(breakdown.getCredibility()).isNull();
+
+ assertThat(read("{ \"response\": [88, 5, 6], \"motivation\": 50 }", null))
+ .satisfies(notObjects -> {
+ assertThat(notObjects.getResponse()).isNull();
+ assertThat(notObjects.getMotivation()).isNull();
+ });
+ }
+
+ /** 문항 id와 인용이 둘씩 짝이 맞아야 화면이 "Q1 ↔ Q5"로 그린다. 짝이 안 맞는 항목만 버린다. */
+ @Test
+ void 짝이_맞지_않는_모순_항목은_버리고_모르는_종류는_OTHER로_둔다() {
+ String longQuote = "가".repeat(1_001);
+ ScoreBreakdownResponse breakdown = read("""
+ {
+ "credibility": {
+ "score": 50,
+ "issues": [
+ { "kind": "ROLE", "questionIds": ["101"], "quotes": ["가", "나"] },
+ { "kind": "ROLE", "questionIds": ["101", "102", "103"], "quotes": ["가", "나"] },
+ { "kind": "ROLE", "questionIds": [101, 102], "quotes": ["가", "나"] },
+ { "kind": "CAUSE", "questionIds": ["101", "102"], "quotes": ["가", " "] },
+ { "kind": "CAUSE", "questionIds": ["101", "102"], "quotes": ["가", "%s"] },
+ { "kind": "CAUSE", "questionIds": ["101", "102"], "quotes": "가, 나" },
+ "ROLE",
+ { "kind": "DOCUMENT", "questionIds": ["101", "102"], "quotes": ["가", "나"] },
+ { "questionIds": ["103", "104"], "quotes": ["다", "라"] },
+ { "kind": "DETAIL", "questionIds": ["105", "106"], "quotes": ["마", "바"] }
+ ]
+ }
+ }
+ """.formatted(longQuote), null);
+
+ assertThat(breakdown.getCredibility().score()).isEqualTo(50);
+ assertThat(breakdown.getCredibility().issues()).containsExactly(
+ new CredibilityResponse.Issue("OTHER", List.of("101", "102"), List.of("가", "나")),
+ new CredibilityResponse.Issue("OTHER", List.of("103", "104"), List.of("다", "라")),
+ new CredibilityResponse.Issue("DETAIL", List.of("105", "106"), List.of("마", "바")));
+ }
+
+ @Test
+ void 모순_목록이_없거나_배열이_아니면_빈_목록이다() {
+ assertThat(read("{ \"credibility\": { \"score\": 75 } }", null).getCredibility())
+ .isEqualTo(new CredibilityResponse(75, List.of()));
+ assertThat(read("{ \"credibility\": { \"score\": null, \"issues\": \"ROLE\" } }", null).getCredibility())
+ .isEqualTo(new CredibilityResponse(null, List.of()));
+ }
+
/** 정수로 받으면 0.35가 0으로 잘려 모든 축이 "× 0%"로 그려진다. */
@Test
void 비율로_온_가중치는_퍼센트로_바꾼다() {
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java
index 437ac86..ea2a2f4 100644
--- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java
+++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java
@@ -213,6 +213,47 @@ private List savedAnswers() {
.containsExactly("WebFlux를 고른 이유를 MVC와 비교해 설명할 수 있는지", "대안을 비교했는지");
}
+ private SaveInterviewRequest withCategories(String... categories) {
+ List qnAs = new java.util.ArrayList<>();
+ for (int i = 0; i < categories.length; i++) {
+ qnAs.add(new SaveInterviewRequest.QnA(new SaveInterviewRequest.Question((long) i + 1, 5L, categories[i],
+ "질문 " + i, "의도 " + i, null, null), null));
+ }
+ return new SaveInterviewRequest("sess-1", 3L, 7L, Status.COMPLETED,
+ LocalDateTime.parse("2026-08-18T01:00:00"), qnAs);
+ }
+
+ /** 분석 서버가 채점 역량을 고르는 열쇠다. 대소문자·공백만 다른 값은 같은 카테고리로 맞춘다. */
+ @Test
+ void 질문_카테고리를_다듬어_남긴다() {
+ service.saveInterview(withCategories("tech_choice", " Market_Analysis ", "q2", "a".repeat(64)));
+
+ assertThat(savedQuestions()).extracting(QuestionEntity::getCategory)
+ .containsExactly("tech_choice", "market_analysis", "q2", "a".repeat(64));
+ }
+
+ /** 옛 채팅 면접은 카테고리 자리에 질문 의도 문장을 보낸다. 분류처럼 보이지 않게 비운다. */
+ @Test
+ void 형식에_맞지_않는_카테고리는_비운다() {
+ service.saveInterview(withCategories("대안 검토 확인", "tech choice", "tech-choice", "2nd_round",
+ "_hidden", "a".repeat(65), "", null));
+
+ assertThat(savedQuestions()).extracting(QuestionEntity::getCategory)
+ .containsOnlyNulls()
+ .hasSize(8);
+ }
+
+ /** 카테고리를 비워도 의도가 없는 옛 꼬리질문은 그 문장을 의도로 쓴다. 채점 기준이 그것뿐이다. */
+ @Test
+ void 의도_문장이_온_카테고리는_비우되_의도로는_그대로_쓴다() {
+ service.saveInterview(request());
+
+ List questions = savedQuestions();
+ assertThat(questions).extracting(QuestionEntity::getCategory)
+ .containsExactly("tech_choice", null, "ops");
+ assertThat(questions.get(1).getIntention()).isEqualTo("대안 검토 확인");
+ }
+
@Test
void 답변을_우리_질문_PK에_매단다() {
service.saveInterview(request());