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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)으로 직렬화된다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)으로 직렬화된다.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,17 +9,30 @@
/**
* 채점 축 하나의 점수와 가중치.
*
* <p>면접 전체가 해당 없는 축(주로 ACCURACY)은 점수와 가중치가 모두 비어 있다. 그때 나머지 가중치의
* 합은 100이 아니다.
* <p>면접 전체가 해당 없는 축(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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package repit.repit_api_server.domain.userdata.feedback.dto.response;

import java.util.List;

/**
* 문항 하나에서 점수에 들어간 역량 하나(competency-v1). 주 역량이 앞에 온다.
*
* <p>등급은 분석 서버가 근거 인용 조합으로 계산하고 응답 적중 상한까지 적용한 최종값이다. 인용은 사용자 답변(꼬리질문이면
* 부모 답변 포함)의 원문 조각이라, 화면은 그대로 "이 문장 때문에"로 보여 준다. 콜백을 저장할 때 모양이 어긋난 항목과
* 인용은 이미 버렸다({@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> evidence) {

public CompetencyScoreResponse {
evidence = evidence == null ? List.of() : evidence;
}

/** @param slot 근거 종류(reason, mechanism 등). 화면에는 쓰지 않는다. */
public record Evidence(String slot, String quote) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package repit.repit_api_server.domain.userdata.feedback.dto.response;

import java.util.List;

/**
* 신뢰성 — 답변 사이의 모순(competency-v1). 표시만 하고 종합 점수에 들어가지 않는다.
*
* <p>꼬리질문에서 설명하지 못한 것은 모순이 아니다. 앞 답변과 뒤집힐 때만 여기에 실린다.
*
* @param score 일관성 점수(0..100). 답변이 1개면 비어 있다
* @param issues 서로 어긋난 답변 쌍. 없으면 빈 목록
*/
public record CredibilityResponse(Integer score, List<Issue> 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<String> questionIds, List<String> quotes) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public class FeedbackItemResponse {
private Map<String, Integer> axisScores;
// 이 답변에 이어질 예상 꼬리질문. 이전 피드백이나 만들지 못한 문항은 null이다.
private String followUpQuestion;
// 응답 적중 등급(0..4). competency-v1 이전 결과는 null이다.
private Integer responseLevel;
// 점수에 들어간 역량별 등급과 근거 인용. 주 역량이 앞에 온다. competency-v1 이전 결과는 null이다.
private List<CompetencyScoreResponse> competencies;

public static FeedbackItemResponse from(FeedbackItemEntity item) {
return FeedbackItemResponse.builder()
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,19 +12,35 @@
* 종합 점수가 어떻게 나왔는지. 축마다 점수와 실제 적용된 가중치가 실린다.
*
* <p>분석 서버는 문항마다 축 등급만 매기고 점수는 직접 계산한다(axis-v1). 그래서 표시된 축 점수와
* 가중치로 {@code Σ score×weight / Σ weight}를 반올림하면 종합 점수와 같아야 한다.
* 가중치로 {@code Σ score×weight / Σ weight}를 반올림하면 종합 점수와 같아야 한다. 응답 적중·지원 동기·신뢰성은
* 이 식에 들어가지 않는다.
*
* <p>콜백으로 받은 값을 그대로 jsonb에 두고 그대로 내보낸다. 방식이 바뀌면 축이 늘거나 이름이 바뀔 수
* 있어 축 이름은 문자열로 둔다.
* 있어 축 이름은 문자열로 둔다. 버전은 키가 있는지가 아니라 scoringVersion으로 가린다.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ScoreBreakdownResponse {
// 채점 방식 버전(예: axis-v1). 버전이 다른 점수끼리는 같은 기준으로 비교하면 안 된다.
// 채점 방식 버전(예: axis-v4, competency-v1). 버전이 다른 점수끼리는 같은 기준으로 비교하면 안 된다.
private String scoringVersion;
private List<AxisScoreResponse> 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<AxisScoreResponse> axes, Integer consistencyScore) {
this(scoringVersion, axes, consistencyScore, null, null, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<CompetencyScoreResponse> competencies;
}
Original file line number Diff line number Diff line change
@@ -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)를 읽는다.
*
* <p>{@link ScoreBreakdownReader}와 같은 이유로 요청 본문에서는 모양을 가리지 않고 받아 여기서 푼다. 한 필드 모양이
* 어긋나 본문 전체가 400이 되면 분석 서버가 재시도 뒤 결과를 영구 폐기한다. 어긋난 항목과 인용만 버리고, 문항의 나머지
* 평가와 점수는 그대로 저장한다.
*
* <p>인용은 사용자 답변의 원문이다. 로그에는 사유와 길이만 남기고 인용 자체는 남기지 않는다.
*/
final class CompetencyReader {

private static final Logger log = LoggerFactory.getLogger(CompetencyReader.class);

private static final Set<String> COMPETENCIES = Set.of(
"PROBLEM_SOLVING", "KNOWLEDGE", "OWNERSHIP", "COLLABORATION", "GROWTH", "MOTIVATION");
private static final Set<String> 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;
}

/**
* 점수에 들어간 역량 목록. 배열이 아니면 비운다.
*
* <p>역량 코드·역할·등급·점수 중 하나라도 어긋난 항목은 버린다. 무엇을 몇 점으로 매겼는지 모르는 항목은 화면에 그릴 수
* 없다. 근거 인용은 어긋난 것만 버리고 항목은 남긴다 — 인용이 없어도 등급은 보여 줄 수 있다.
*/
static List<CompetencyScoreResponse> 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<CompetencyScoreResponse> 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<CompetencyScoreResponse.Evidence> 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<CompetencyScoreResponse.Evidence> 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<String> 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;
}
}
Loading
Loading