From 10e757246f57011189b27b42b28555e3b63029cc Mon Sep 17 00:00:00 2001 From: kimwuju Date: Wed, 21 Oct 2026 23:28:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(feedback):=20=EC=97=AD=EB=9F=89=20?= =?UTF-8?q?=EC=B6=95=20=EC=B1=84=EC=A0=90(competency-v1)=EC=9A=A9=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=C2=B7=EC=86=A1=EC=8B=A0=EA=B3=BC=20=EC=BD=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=83=88=20=ED=95=84=EB=93=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V42: interview_question.category, feedback_item.response_level(SMALLINT)·competencies(jsonb)를 추가한다. 기존 행은 채우지 않는다. - 면접 결과 저장 때 질문 category를 소문자 스네이크케이스 1~64자일 때만 저장한다. 옛 채팅 면접이 보내는 의도 문장은 비우고, 의도가 비었을 때 그 문장을 의도로 쓰는 기존 동작은 둔다. - 1:1·N:1 채점 요청에 category를 싣는다. 값이 없으면 키를 보내지 않는다. - 콜백 문항의 responseLevel·competencies를 CompetencyReader가 읽어 저장한다. 산출 근거의 verdict·evidenceCount·response·motivation·credibility는 ScoreBreakdownReader가 읽는다. 모양이 어긋난 항목·인용만 버리고, 인용 원문은 로그에 남기지 않는다. - 조회 응답에 새 필드를 그대로 내보낸다. 산출 근거의 새 필드는 NON_NULL이라 axis-v4 결과의 응답은 바뀌지 않는다. Co-Authored-By: Claude Opus 5.5 --- .../dto/request/FeedbackCallbackRequest.java | 4 + .../dto/request/FeedbackMultiRequest.java | 3 + .../dto/request/FeedbackSoloRequest.java | 4 + .../dto/response/AxisScoreResponse.java | 20 +- .../dto/response/CompetencyScoreResponse.java | 28 +++ .../dto/response/CredibilityResponse.java | 26 +++ .../dto/response/FeedbackItemResponse.java | 6 + .../response/MotivationSummaryResponse.java | 12 ++ .../dto/response/ResponseSummaryResponse.java | 11 + .../dto/response/ScoreBreakdownResponse.java | 23 +- .../feedback/entity/FeedbackItemEntity.java | 11 + .../feedback/service/CompetencyReader.java | 150 +++++++++++++ .../feedback/service/FeedbackService.java | 15 +- .../service/ScoreBreakdownReader.java | 200 +++++++++++++++++- .../dto/request/SaveInterviewRequest.java | 3 +- .../interview/service/InterviewService.java | 16 ++ .../question/entity/QuestionEntity.java | 5 + .../db/migration/V42__competency_scoring.sql | 8 + ...FeedbackMultiRequestSerializationTest.java | 17 ++ .../FeedbackSoloRequestSerializationTest.java | 18 ++ .../service/CompetencyReaderTest.java | 158 ++++++++++++++ .../FeedbackCallbackPersistenceTest.java | 166 ++++++++++++++- .../FeedbackServiceMultiCallbackTest.java | 12 +- .../FeedbackServiceSoloRequestTest.java | 27 ++- .../service/ScoreBreakdownReaderTest.java | 152 +++++++++++++ .../InterviewServiceSaveResultTest.java | 41 ++++ 26 files changed, 1110 insertions(+), 26 deletions(-) create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CompetencyScoreResponse.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CredibilityResponse.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/MotivationSummaryResponse.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/ResponseSummaryResponse.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompetencyReader.java create mode 100644 src/main/resources/db/migration/V42__competency_scoring.sql create mode 100644 src/test/java/repit/repit_api_server/domain/userdata/feedback/service/CompetencyReaderTest.java 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 5f49b753..a8438ada 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 277b9565..c6c8a48d 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 1d715bfc..ca81f141 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 c3011b19..07bc4579 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 00000000..e8ca7904 --- /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 00000000..10a47f0a --- /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 d88e6829..aad6215a 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 00000000..580ef255 --- /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 00000000..3d18b1e5 --- /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 11dd10e5..66b010f9 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 82db5f24..54794833 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 00000000..65c7cf91 --- /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 91f8bf46..5f866305 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 8120ae22..cadb6185 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 322c4404..5bf9210e 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 e7dad243..81bc102d 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 a692a588..687a0652 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 00000000..82e30a31 --- /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 c83b5915..7a8eb218 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 2636e3eb..5d20ac33 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 00000000..a2d6f7a7 --- /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 d235176b..bcd546a0 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 34a41df2..5a849027 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 3b948d3f..3e15ab7a 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 a388e635..e4539a54 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 437ac868..ea2a2f49 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());