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 @@ -90,6 +90,9 @@ public static class Item {
// 이 문항의 축별 점수(0..100). 해당 없는 축은 비어 있다. 예: {"INTENT": 88, "ACCURACY": null}
// 종합처럼 [{axis, score}]로 올 수도 있어 모양을 가리지 않고 받는다.
private JsonNode axisScores;
// 이 답변에 이어질 예상 꼬리질문 한 문장. 새로 생긴 필드라 구버전 분석 서버는 보내지 않는다.
// 문자열이 아닌 값이 와도 본문 전체를 잃지 않도록 모양을 가리지 않고 받는다. 푸는 것은 FeedbackService가 한다.
private JsonNode followUpQuestion;
}

@Getter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public class FeedbackItemResponse {
private String comment;
// 이 문항의 축별 점수(0..100). 해당 없는 축은 값이 비어 있다.
private Map<String, Integer> axisScores;
// 이 답변에 이어질 예상 꼬리질문. 이전 피드백이나 만들지 못한 문항은 null이다.
private String followUpQuestion;

public static FeedbackItemResponse from(FeedbackItemEntity item) {
return FeedbackItemResponse.builder()
Expand All @@ -38,6 +40,7 @@ public static FeedbackItemResponse from(FeedbackItemEntity item) {
.improvements(item.getImprovements())
.comment(item.getComment())
.axisScores(item.getAxisScores())
.followUpQuestion(item.getFollowUpQuestion())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,8 @@ public class FeedbackItemEntity {
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Integer> axisScores;

// 이 답변에 이어질 예상 꼬리질문. 이전 피드백이나 만들지 못한 문항은 값이 없다.
@Column(columnDefinition = "TEXT")
private String followUpQuestion;
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ static CompanyFitResponse read(JsonNode node, CompanySnapshot snapshot, Set<Stri
Long interviewId) {
// 기업을 고르지 않은 면접이거나, 분석 서버가 만들지 못했으면 null로 온다.
if (node == null || node.isNull() || node.isMissingNode()) {
if (snapshot != null) {
// 기업을 골랐는데 오지 않았다. 우리 쪽 검증에서 버린 것과 가려야 분석 서버 로그로 넘어갈지 정할 수 있다.
log.warn("기업을 고른 면접인데 기업 관점 코멘트가 오지 않았습니다. interviewId={}, 사유=FIT_NOT_RECEIVED, 기업 fact={}건",
interviewId, snapshot.facts() == null ? 0 : snapshot.facts().size());
}
return null;
}
if (snapshot == null) {
Expand Down Expand Up @@ -90,15 +95,16 @@ static CompanyFitResponse read(JsonNode node, CompanySnapshot snapshot, Set<Stri
List<CompanyFitResponse.Gap> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -793,11 +812,41 @@ private List<FeedbackItemEntity> 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자 문자열만 받는다. 그 밖의 값은 비워 둔다.
*
* <p>없어도 피드백이 성립하는 부가 정보라, 받아들일 수 없어도 그 문항의 꼬리질문만 비우고 점수와 나머지 평가는 저장한다.
* 길다고 잘라 넣지 않는다 — 잘린 질문은 뜻이 바뀐다. 로그에는 사유와 길이만 남기고 모델이 쓴 문장은 남기지 않는다.
*/
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;
}

/**
* 문항이 가리킬 면접관. 결과에 실린 면접관 명단 안의 값이어야 한다.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 문항별 예상 꼬리질문. 분석 서버가 그 질문과 실제 답변을 보고 만든 한 문장이다.
-- 이전 피드백과 이 필드를 보내지 않는 분석 서버의 콜백은 값이 없다.
ALTER TABLE feedback_item ADD COLUMN follow_up_question TEXT;
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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<FeedbackCallbackRequest.Item> 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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<FeedbackItemEntity> items =
feedbackItemRepository.findAllByFeedbackIdOrderBySortOrderAsc(accepted.getFeedbackId());
assertThat(items).hasSize(1);
assertThat(items.getFirst().getFollowUpQuestion()).isNull();
}

@Test
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -274,8 +299,10 @@ private void flushAndClear() {
flushAndClear();

// 지우고 다시 넣는다. 지운 것이 새로 넣은 것보다 늦게 반영되면 방금 넣은 행까지 사라진다.
assertThat(feedbackItemRepository.findAllByFeedbackIdOrderBySortOrderAsc(accepted.getFeedbackId()))
.hasSize(1);
List<FeedbackItemEntity> items =
feedbackItemRepository.findAllByFeedbackIdOrderBySortOrderAsc(accepted.getFeedbackId());
assertThat(items).hasSize(1);
assertThat(items.getFirst().getFollowUpQuestion()).isEqualTo(FOLLOW_UP_QUESTION);
assertThat(feedbackPersonaRepository.findAllByFeedbackIdOrderBySortOrderAsc(accepted.getFeedbackId()))
.hasSize(2);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -299,6 +302,7 @@ private FeedbackItemEntity item(Long feedbackId, String questionId) {
.feedbackId(feedbackId)
.questionId(questionId)
.sortOrder(0)
.followUpQuestion(questionId + "의 꼬리질문")
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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();
}
}
Loading
Loading