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 @@ -15,8 +15,8 @@ public record RecommendationResponse(
RoadmapView roadmap) {

/**
* 추천 직무 한 건. {@code score} 는 0~100 적합도, {@code techStacks} 는 카탈로그가 보유한 직무 요구 기술 스택 이름 목록(없으면 빈
* 리스트).
* 추천 직무 한 건. {@code score} 는 사용자 체감 척도로 보정한 표시 점수(0~100, 하한 위로 끌어올린 값이며 내부 저장 점수와는 다름), {@code
* techStacks} 는 카탈로그가 보유한 직무 요구 기술 스택 이름 목록(없으면 빈 리스트).
*/
public record JobView(
String code, String name, Integer score, String reasoning, List<String> techStacks) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.hansung.tracktory.domain.recommendation.service;

/**
* 직무 추천 점수를 응답에 노출하기 직전 사용자 체감 척도로 보정한다.
*
* <p>내부에 저장된 적합도 점수는 유사도 기반이라 절대값이 낮게 형성되어, 0~100 그대로 노출하면 실제 적합도에 비해 "추천이 부실하다"는 인상을 준다. 이 보정은 점수
* 산출이나 저장값을 바꾸지 않고 <b>표시값만</b> 일정 하한 위로 끌어올려 신뢰도 인상을 개선한다.
*
* <p>규칙은 {@code 표시값 = 하한 + 내부값 × (100 − 하한) / 100} 의 선형 사상이다. 하한({@code DISPLAY_FLOOR})을 바닥으로 두어 가장
* 낮은 점수도 일정 수준 이상으로 보이게 하되, 단조 증가라 직무 간 순위는 보존한다(더 높은 내부 점수는 항상 더 높은 표시값). 내부값 0→하한, 100→100 으로
* 사상되어 출력은 항상 {@code [하한, 100]} 구간에 든다.
*/
final class JobScoreCalibrator {

/** 표시 점수 하한 — 보정 강도를 조절하는 유일한 파라미터. 높일수록 낮은 점수가 더 끌어올려진다. */
private static final int DISPLAY_FLOOR = 60;

/** 내부·표시 점수의 공통 상한. */
private static final int SCORE_MAX = 100;

private JobScoreCalibrator() {
// 유틸리티 클래스 — 인스턴스화 방지
}

/**
* 내부 적합도 점수를 표시용 점수로 변환한다.
*
* @param internalScore 저장된 0~100 적합도(범위를 벗어나면 안전하게 clamp). {@code null} 이면 {@code null} 반환.
* @return {@code [DISPLAY_FLOOR, 100]} 구간의 표시 점수(반올림). 입력이 {@code null} 이면 {@code null}.
*/
static Integer toDisplayScore(Integer internalScore) {
if (internalScore == null) {
return null;
}
int clamped = Math.min(Math.max(internalScore, 0), SCORE_MAX);
int span = SCORE_MAX - DISPLAY_FLOOR;
// 정수 산술로 반올림(+ SCORE_MAX/2)하여 부동소수 drift 없이 결정적으로 계산한다.
return DISPLAY_FLOOR + (clamped * span + SCORE_MAX / 2) / SCORE_MAX;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ private List<JobView> jobs(Recommendation recommendation) {
new JobView(
j.getJob().getCode(),
j.getJob().getName(),
j.getScore(),
// 저장된 내부 점수는 그대로 두고 응답 노출값만 체감 척도로 보정한다.
JobScoreCalibrator.toDisplayScore(j.getScore()),
j.getReasoning(),
techStacks.getOrDefault(j.getJob().getId(), List.of())))
.toList();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.hansung.tracktory.domain.recommendation.service;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;

class JobScoreCalibratorTest {

@Test
void toDisplayScore_appliesLinearFloorLiftAtRepresentativePoints() {
assertThat(JobScoreCalibrator.toDisplayScore(0)).isEqualTo(60); // 하한 = 가장 낮은 점수도 60
assertThat(JobScoreCalibrator.toDisplayScore(25)).isEqualTo(70);
assertThat(JobScoreCalibrator.toDisplayScore(40)).isEqualTo(76);
assertThat(JobScoreCalibrator.toDisplayScore(50)).isEqualTo(80);
assertThat(JobScoreCalibrator.toDisplayScore(55)).isEqualTo(82); // 2250/100 = 22 → 82
assertThat(JobScoreCalibrator.toDisplayScore(70)).isEqualTo(88);
assertThat(JobScoreCalibrator.toDisplayScore(85)).isEqualTo(94);
assertThat(JobScoreCalibrator.toDisplayScore(90)).isEqualTo(96);
assertThat(JobScoreCalibrator.toDisplayScore(100)).isEqualTo(100); // 상한은 그대로
}

@Test
void toDisplayScore_returnsNullForNullInput() {
assertThat(JobScoreCalibrator.toDisplayScore(null)).isNull();
}

@Test
void toDisplayScore_clampsOutOfRangeInput() {
assertThat(JobScoreCalibrator.toDisplayScore(-5)).isEqualTo(60);
assertThat(JobScoreCalibrator.toDisplayScore(150)).isEqualTo(100);
}

@Test
void toDisplayScore_isMonotonicSoRankingIsPreserved() {
int previous = JobScoreCalibrator.toDisplayScore(0);
for (int internal = 1; internal <= 100; internal++) {
int current = JobScoreCalibrator.toDisplayScore(internal);
assertThat(current).isGreaterThanOrEqualTo(previous);
previous = current;
}
}

@Test
void toDisplayScore_alwaysWithinFloorAndCeiling() {
for (int internal = 0; internal <= 100; internal++) {
assertThat(JobScoreCalibrator.toDisplayScore(internal)).isBetween(60, 100);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ void assemble_populatesJobTechStacksFromCatalogSortedByName() {
JobView view = response.jobs().get(0);
assertThat(view.code()).isEqualTo("DE");
assertThat(view.name()).isEqualTo("데이터 엔지니어");
assertThat(view.score()).isEqualTo(90);
// 내부 저장 점수 90 은 응답 노출 시 체감 척도로 보정된다: round(60 + 90×0.40) = 96.
assertThat(view.score()).isEqualTo(96);
assertThat(view.reasoning()).isEqualTo("이유");
assertThat(view.techStacks()).containsExactly("Airflow", "Spark");
}
Expand Down Expand Up @@ -201,6 +202,8 @@ void assemble_populatesMainSubjectsForRecommendedTracksCappedAtThree() {
assertThat(view.code()).isEqualTo("BIGDATA");
assertThat(view.primary()).isTrue();
assertThat(view.reasoning()).isEqualTo("이유");
// 직무 점수 보정(이슈 #45)은 트랙 점수에 적용되지 않는다: 트랙 점수는 원본 그대로 노출된다(보정 시 95→98).
assertThat(view.score()).isEqualTo(95);

List<MainSubjectView> mainSubjects = view.mainSubjects();
assertThat(mainSubjects).hasSize(3);
Expand Down
Loading