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 be45567..5f49b75 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 @@ -90,6 +90,9 @@ public static class Item { // 이 문항의 축별 점수(0..100). 해당 없는 축은 비어 있다. 예: {"INTENT": 88, "ACCURACY": null} // 종합처럼 [{axis, score}]로 올 수도 있어 모양을 가리지 않고 받는다. private JsonNode axisScores; + // 이 답변에 이어질 예상 꼬리질문 한 문장. 새로 생긴 필드라 구버전 분석 서버는 보내지 않는다. + // 문자열이 아닌 값이 와도 본문 전체를 잃지 않도록 모양을 가리지 않고 받는다. 푸는 것은 FeedbackService가 한다. + private JsonNode followUpQuestion; } @Getter 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 e9877da..d88e682 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 @@ -25,6 +25,8 @@ public class FeedbackItemResponse { private String comment; // 이 문항의 축별 점수(0..100). 해당 없는 축은 값이 비어 있다. private Map axisScores; + // 이 답변에 이어질 예상 꼬리질문. 이전 피드백이나 만들지 못한 문항은 null이다. + private String followUpQuestion; public static FeedbackItemResponse from(FeedbackItemEntity item) { return FeedbackItemResponse.builder() @@ -38,6 +40,7 @@ public static FeedbackItemResponse from(FeedbackItemEntity item) { .improvements(item.getImprovements()) .comment(item.getComment()) .axisScores(item.getAxisScores()) + .followUpQuestion(item.getFollowUpQuestion()) .build(); } } 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 19114c2..82db5f2 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 @@ -62,4 +62,8 @@ public class FeedbackItemEntity { @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") private Map axisScores; + + // 이 답변에 이어질 예상 꼬리질문. 이전 피드백이나 만들지 못한 문항은 값이 없다. + @Column(columnDefinition = "TEXT") + private String followUpQuestion; } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReader.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReader.java index cb151cd..aa8258c 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReader.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReader.java @@ -58,6 +58,11 @@ static CompanyFitResponse read(JsonNode node, CompanySnapshot snapshot, Set gaps = readItems(gapsNode, "suggestion", facts, answeredQuestionIds, dropped, (fact, detail, ids) -> new CompanyFitResponse.Gap(fact.factId(), fact.kind(), fact.title(), detail, ids)); + // 대상 문항이 0건이면 원시 코멘트가 맞아도 모든 항목이 NO_ANSWERED_QUESTION으로 빠진다. 그 경우를 가리려고 함께 남긴다. int received = size(matchedNode) + size(gapsNode); if (received > 0 && matched.isEmpty() && gaps.isEmpty()) { - log.warn("기업 관점 코멘트의 항목이 모두 근거가 맞지 않아 코멘트 전체를 비워 둡니다. interviewId={}, 받은 항목={}, 사유={}", - interviewId, received, dropped); + log.warn("기업 관점 코멘트의 항목이 모두 근거가 맞지 않아 코멘트 전체를 비워 둡니다. interviewId={}, 받은 항목={}, 대상 문항={}건, 사유={}", + interviewId, received, answeredQuestionIds.size(), dropped); return null; } if (!dropped.isEmpty()) { - log.warn("기업 관점 코멘트에서 근거가 맞지 않는 항목을 뺐습니다. interviewId={}, 받은 항목={}, 남은 항목={}, 사유={}", - interviewId, received, matched.size() + gaps.size(), dropped); + log.warn("기업 관점 코멘트에서 근거가 맞지 않는 항목을 뺐습니다. interviewId={}, 받은 항목={}, 남은 항목={}, 대상 문항={}건, 사유={}", + interviewId, received, matched.size() + gaps.size(), answeredQuestionIds.size(), dropped); } return new CompanyFitResponse(comment, matched, gaps); } 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 78b24b8..03fa242 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 @@ -39,6 +39,7 @@ import repit.repit_api_server.global.exception.BusinessException; import tools.jackson.core.JacksonException; import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; import java.time.Duration; @@ -66,6 +67,7 @@ public class FeedbackService { private static final int MAX_QUESTIONS = 50; private static final String STATUS_SUCCEEDED = "succeeded"; private static final String INTENTION_MISSING = "질문 의도가 기록되지 않았습니다."; + private static final int FOLLOW_UP_QUESTION_MAX = 200; private final FeedbackRepository feedbackRepository; private final FeedbackItemRepository feedbackItemRepository; @@ -204,6 +206,13 @@ private FeedbackAcceptedResponse startFeedback(InterviewEntity interview) { : aiServerClient.requestSoloFeedback(solo); saveAccepted(interview, accepted == null ? null : accepted.getJobId(), requestedQuestionIds); + + // 기업 관점 코멘트가 비면 요청에 기업과 근거 문항이 실렸는지부터 본다. 기업 자료와 답변 내용은 남기지 않는다. + CompanySnapshot company = interview.getCompanySnapshot(); + log.info("채점을 요청했습니다. interviewId={}, jobId={}, sessionId={}, 기업={}, 기업 fact={}건, 근거 문항={}건", + interview.getInterviewId(), accepted == null ? null : accepted.getJobId(), interview.getSessionId(), + company != null, company == null || company.facts() == null ? 0 : company.facts().size(), + requestedQuestionIds.size()); return accepted; } @@ -696,6 +705,16 @@ private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Resul feedbackPersonaRepository.saveAll(personaRows); feedbackItemRepository.saveAll(itemRows); + + // 화면에서 기업 관점 코멘트나 예상 꼬리질문이 비면, 콜백에 실려 왔는지와 저장됐는지를 이 한 줄로 가린다. + log.info("채점 결과를 저장했습니다. interviewId={}, jobId={}, 문항={}건, 예상 꼬리질문={}건, 기업 관점 코멘트 수신={}, 저장={}", + interviewId, callbackJobId, itemRows.size(), + itemRows.stream().filter(item -> item.getFollowUpQuestion() != null).count(), + isPresent(result.getCompanyFit()), feedback.getCompanyFit() != null); + } + + private static boolean isPresent(JsonNode node) { + return node != null && !node.isNull() && !node.isMissingNode(); } /** @@ -793,11 +812,41 @@ private List verifiedItems(FeedbackEntity feedback, .comment(item.getComment()) .axisScores(ScoreBreakdownReader.readAxisScores(item.getAxisScores(), "문항 " + questionId, feedback.getInterviewId())) + .followUpQuestion(followUpQuestionOf(feedback, questionId, item.getFollowUpQuestion())) .build()); } return entities; } + /** + * 예상 꼬리질문. 앞뒤 공백을 뺀 1~200자 문자열만 받는다. 그 밖의 값은 비워 둔다. + * + *

없어도 피드백이 성립하는 부가 정보라, 받아들일 수 없어도 그 문항의 꼬리질문만 비우고 점수와 나머지 평가는 저장한다. + * 길다고 잘라 넣지 않는다 — 잘린 질문은 뜻이 바뀐다. 로그에는 사유와 길이만 남기고 모델이 쓴 문장은 남기지 않는다. + */ + private String followUpQuestionOf(FeedbackEntity feedback, String questionId, JsonNode node) { + if (!isPresent(node)) { + return null; + } + if (!node.isString()) { + log.warn("예상 꼬리질문이 문자열이 아니라 비워 둡니다. feedbackId={}, questionId={}, 받은 모양={}", + feedback.getFeedbackId(), questionId, node.getNodeType()); + return null; + } + String question = node.stringValue().strip(); + if (question.isEmpty()) { + return null; + } + // 분석 서버(Python)가 세는 글자 수와 맞춘다. String.length()는 이모지 하나를 둘로 센다. + int length = question.codePointCount(0, question.length()); + if (length > FOLLOW_UP_QUESTION_MAX) { + log.warn("예상 꼬리질문이 {}자를 넘어 비워 둡니다. feedbackId={}, questionId={}, 길이={}", + FOLLOW_UP_QUESTION_MAX, feedback.getFeedbackId(), questionId, length); + return null; + } + return question; + } + /** * 문항이 가리킬 면접관. 결과에 실린 면접관 명단 안의 값이어야 한다. * diff --git a/src/main/resources/db/migration/V41__add_feedback_item_follow_up_question.sql b/src/main/resources/db/migration/V41__add_feedback_item_follow_up_question.sql new file mode 100644 index 0000000..13679da --- /dev/null +++ b/src/main/resources/db/migration/V41__add_feedback_item_follow_up_question.sql @@ -0,0 +1,3 @@ +-- 문항별 예상 꼬리질문. 분석 서버가 그 질문과 실제 답변을 보고 만든 한 문장이다. +-- 이전 피드백과 이 필드를 보내지 않는 분석 서버의 콜백은 값이 없다. +ALTER TABLE feedback_item ADD COLUMN follow_up_question TEXT; diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequestDeserializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequestDeserializationTest.java index 75f6106..26de856 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequestDeserializationTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequestDeserializationTest.java @@ -7,6 +7,8 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -163,6 +165,41 @@ class FeedbackCallbackRequestDeserializationTest { } """; + // 예상 꼬리질문은 새로 생긴 필드다. 문항마다 문자열로 오거나, 구버전 분석 서버라 빠지거나, 계약과 다른 모양으로 올 수 있다. + private static final String FOLLOW_UP_CALLBACK = """ + { + "jobId": "job-6", + "sessionId": "sess-6", + "status": "succeeded", + "result": { + "overall": { "totalScore": 70 }, + "feedbacks": [ + { "questionId": "62", "followUpQuestion": "429 응답의 Retry-After와 전체 시간 예산을 어떻게 함께 적용하겠습니까?" }, + { "questionId": "63" }, + { "questionId": "64", "followUpQuestion": { "text": "질문" } } + ] + } + } + """; + + @Test + void 예상_꼬리질문을_읽고_빠지거나_모양이_달라도_본문은_읽힌다() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + FeedbackCallbackRequest request = + objectMapper.readValue(FOLLOW_UP_CALLBACK, FeedbackCallbackRequest.class); + + List feedbacks = request.getResult().getFeedbacks(); + assertThat(feedbacks).hasSize(3); + assertThat(feedbacks.getFirst().getQuestionId()).isEqualTo("62"); + assertThat(feedbacks.getFirst().getFollowUpQuestion().stringValue()) + .isEqualTo("429 응답의 Retry-After와 전체 시간 예산을 어떻게 함께 적용하겠습니까?"); + assertThat(feedbacks.get(1).getFollowUpQuestion()).isNull(); + assertThat(feedbacks.get(2).getFollowUpQuestion().isObject()).isTrue(); + }); + } + @Test void 점수_산출_근거를_읽는다() { contextRunner.run(context -> { 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 9d85694..d235176 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 @@ -76,6 +76,8 @@ class FeedbackCallbackPersistenceTest { @PersistenceContext private EntityManager entityManager; + private static final String FOLLOW_UP_QUESTION = "캐시와 DB가 어긋나면 어느 쪽을 기준으로 맞추시겠습니까?"; + private final String sessionId = UUID.randomUUID().toString(); private final String jobId = UUID.randomUUID().toString(); @@ -107,7 +109,8 @@ private FeedbackCallbackRequest multiCallback() { "901", 11L, "Redis를 캐시로 두신 이유는?", "기술 선택의 근거", "조회가 쓰기보다 많아서요.", "지연을 수치로 제시한다.", List.of("p99 지연을 근거로 든 점"), List.of("무효화 전략 미언급"), - "부작용까지는 못 짚었습니다.", null); + "부작용까지는 못 짚었습니다.", null, + JsonMapper.shared().valueToTree(FOLLOW_UP_QUESTION)); return new FeedbackCallbackRequest(jobId, sessionId, "succeeded", new FeedbackCallbackRequest.Result(overall, List.of(tech, hr), List.of(item), null), null); @@ -167,6 +170,28 @@ private void flushAndClear() { assertThat(item.getStrengths()).containsExactly("p99 지연을 근거로 든 점"); assertThat(item.getImprovements()).containsExactly("무효화 전략 미언급"); assertThat(item.getComment()).isEqualTo("부작용까지는 못 짚었습니다."); + assertThat(item.getFollowUpQuestion()).isEqualTo(FOLLOW_UP_QUESTION); + } + + /** 구버전 분석 서버는 예상 꼬리질문을 보내지 않는다. 그래도 문항은 저장되고 그 칸만 빈다. */ + @Test + void 예상_꼬리질문이_없는_콜백도_문항이_저장된다() { + FeedbackEntity accepted = givenAccepted(); + FeedbackCallbackRequest.Item item = new FeedbackCallbackRequest.Item( + "901", 11L, "질문", "의도", "답변", "모범답변", List.of(), List.of(), "총평", null, null); + FeedbackCallbackRequest.Overall overall = new FeedbackCallbackRequest.Overall( + 70, 70, 70, "요약", List.of(), List.of(), List.of(), 1, 1, null); + + feedbackService.handleCallback(new FeedbackCallbackRequest(jobId, sessionId, "succeeded", + new FeedbackCallbackRequest.Result(overall, null, List.of(item), null), null)); + flushAndClear(); + + assertThat(feedbackRepository.findById(accepted.getFeedbackId()).orElseThrow().getStatus()) + .isEqualTo(FeedbackStatus.SUCCEEDED); + List items = + feedbackItemRepository.findAllByFeedbackIdOrderBySortOrderAsc(accepted.getFeedbackId()); + assertThat(items).hasSize(1); + assertThat(items.getFirst().getFollowUpQuestion()).isNull(); } @Test @@ -216,7 +241,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)); + JsonMapper.shared().valueToTree(axisScores), null); feedbackService.handleCallback(new FeedbackCallbackRequest(jobId, sessionId, "succeeded", new FeedbackCallbackRequest.Result(overall, List.of(tech, hr), List.of(item), null), null)); @@ -274,8 +299,10 @@ private void flushAndClear() { flushAndClear(); // 지우고 다시 넣는다. 지운 것이 새로 넣은 것보다 늦게 반영되면 방금 넣은 행까지 사라진다. - assertThat(feedbackItemRepository.findAllByFeedbackIdOrderBySortOrderAsc(accepted.getFeedbackId())) - .hasSize(1); + List items = + feedbackItemRepository.findAllByFeedbackIdOrderBySortOrderAsc(accepted.getFeedbackId()); + assertThat(items).hasSize(1); + assertThat(items.getFirst().getFollowUpQuestion()).isEqualTo(FOLLOW_UP_QUESTION); assertThat(feedbackPersonaRepository.findAllByFeedbackIdOrderBySortOrderAsc(accepted.getFeedbackId())) .hasSize(2); } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetAllTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetAllTest.java index 566d73c..4b51285 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetAllTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetAllTest.java @@ -120,6 +120,9 @@ void setUp() { assertThat(responses.get(0).getFeedbacks()).extracting("questionId").containsExactly("q-1", "q-2"); assertThat(responses.get(1).getPersonas()).extracting("personaRole").containsExactly("인사 담당"); assertThat(responses.get(1).getFeedbacks()).extracting("questionId").containsExactly("q-3"); + // 단건 조회와 같은 변환을 거쳐 예상 꼬리질문도 문항마다 그대로 나간다. + assertThat(responses.get(0).getFeedbacks()).extracting("followUpQuestion") + .containsExactly("q-1의 꼬리질문", "q-2의 꼬리질문"); } @Test @@ -299,6 +302,7 @@ private FeedbackItemEntity item(Long feedbackId, String questionId) { .feedbackId(feedbackId) .questionId(questionId) .sortOrder(0) + .followUpQuestion(questionId + "의 꼬리질문") .build(); } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetOneTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetOneTest.java index da731dc..4993f44 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetOneTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetOneTest.java @@ -12,6 +12,7 @@ import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository; import repit.repit_api_server.domain.userdata.feedback.dto.response.FeedbackResponse; import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackEntity; +import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackItemEntity; import repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackStatus; import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackItemRepository; import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackPersonaRepository; @@ -118,6 +119,21 @@ void setUp() { assertThat(response.getMode()).isEqualTo(InterviewMode.SOLO); } + /** 문항마다 저장된 예상 꼬리질문이 그대로 나간다. 만들지 못한 문항은 비어 있고, 웹은 그때 안내 문구를 보여준다. */ + @Test + void 문항마다_예상_꼬리질문이_나간다() { + when(interviewRepository.findById(100L)).thenReturn(Optional.of(interview(InterviewMode.SOLO))); + when(feedbackItemRepository.findAllByFeedbackIdOrderBySortOrderAsc(10L)).thenReturn(List.of( + item("62", 0, "429 응답의 Retry-After를 어떻게 적용하겠습니까?"), + item("63", 1, null))); + + FeedbackResponse response = service.getFeedback(USER_ID, 100L); + + assertThat(response.getFeedbacks()).extracting("questionId", "followUpQuestion").containsExactly( + org.assertj.core.groups.Tuple.tuple("62", "429 응답의 Retry-After를 어떻게 적용하겠습니까?"), + org.assertj.core.groups.Tuple.tuple("63", null)); + } + @Test void 면접이_남아_있지_않아도_채점_결과는_그대로_준다() { when(interviewRepository.findById(100L)).thenReturn(Optional.empty()); @@ -298,4 +314,13 @@ private PersonaEntity persona(Long personaId, Type type, Level level) { .gender(Gender.MALE) .build(); } + + private FeedbackItemEntity item(String questionId, int sortOrder, String followUpQuestion) { + return FeedbackItemEntity.builder() + .feedbackId(10L) + .questionId(questionId) + .sortOrder(sortOrder) + .followUpQuestion(followUpQuestion) + .build(); + } } 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 60e990c..34a41df 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); + List.of("p99 지연을 근거로 든 점"), List.of("무효화 전략 미언급"), "부작용까지는 못 짚었습니다.", 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, "사실과 대응을 나눠 말한다.", List.of(), List.of(), "답하지 않았습니다.", 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)); + "답변", "모범답변", List.of(), List.of(), "총평", json(axisScores), null); service.handleCallback(new FeedbackCallbackRequest("job-1", "sess-1", "succeeded", new FeedbackCallbackRequest.Result(overall, List.of(persona(11L, 78)), List.of(item), null), null)); @@ -501,4 +501,37 @@ private JsonNode json(Object value) { verify(feedbackPersonaRepository).saveAll(savedPersonas.capture()); assertThat(savedPersonas.getValue().get(1).getScore()).isNull(); } + + /** + * 예상 꼬리질문은 앞뒤 공백을 뺀 1~200자 문자열만 남긴다. 글자 수는 분석 서버처럼 code point로 센다. + * 받아들일 수 없는 값은 그 문항의 꼬리질문만 비우고 문항 평가는 그대로 저장한다. 길다고 잘라 넣지 않는다. + */ + @Test + void 예상_꼬리질문은_1에서_200자_문자열만_남기고_나머지는_비운다() { + when(feedbackRepository.findByJobId("job-1")).thenReturn(Optional.of(acceptedFeedback())); + givenInterviewMembers(); + String emoji200 = "🙂".repeat(200); + + service.handleCallback(callbackWith(List.of(persona(11L, 78)), List.of( + followUp("1", json(" 재시도 간격은 어떻게 정하시겠습니까? ")), + followUp("2", json(" ")), + followUp("3", json("가".repeat(201))), + followUp("4", json(emoji200)), + followUp("5", json(Map.of("text", "질문"))), + followUp("6", json(42)), + followUp("7", null)))); + + verify(feedbackItemRepository).saveAll(savedItems.capture()); + assertThat(savedItems.getValue()) + .extracting(FeedbackItemEntity::getFollowUpQuestion) + .containsExactly("재시도 간격은 어떻게 정하시겠습니까?", null, null, emoji200, null, null, null); + assertThat(savedItems.getValue()) + .extracting(FeedbackItemEntity::getComment) + .containsOnly("총평"); + } + + private FeedbackCallbackRequest.Item followUp(String questionId, JsonNode followUpQuestion) { + return new FeedbackCallbackRequest.Item(questionId, 11L, "질문", "의도", + "답변", "모범답변", List.of(), List.of(), "총평", null, followUpQuestion); + } }