From 87419adee1d57b4a573b86c0bb54a3fb69abb0e6 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <50106190+jwon0523@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:55:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A7=81=EB=AC=B4=20=EC=B6=94=EC=B2=9C?= =?UTF-8?q?=20=EC=A0=90=EC=88=98=20=ED=91=9C=EC=8B=9C=20=EC=B2=99=EB=8F=84?= =?UTF-8?q?=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/RecommendationResponse.java | 4 +- .../service/JobScoreCalibrator.java | 40 +++++++++++++++ .../service/RecommendationAssembler.java | 3 +- .../service/JobScoreCalibratorTest.java | 49 +++++++++++++++++++ .../service/RecommendationAssemblerTest.java | 5 +- 5 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/hansung/tracktory/domain/recommendation/service/JobScoreCalibrator.java create mode 100644 src/test/java/com/hansung/tracktory/domain/recommendation/service/JobScoreCalibratorTest.java diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/dto/RecommendationResponse.java b/src/main/java/com/hansung/tracktory/domain/recommendation/dto/RecommendationResponse.java index 256c7a5..15c1e5e 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/dto/RecommendationResponse.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/dto/RecommendationResponse.java @@ -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 techStacks) {} diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/service/JobScoreCalibrator.java b/src/main/java/com/hansung/tracktory/domain/recommendation/service/JobScoreCalibrator.java new file mode 100644 index 0000000..3c27c08 --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/service/JobScoreCalibrator.java @@ -0,0 +1,40 @@ +package com.hansung.tracktory.domain.recommendation.service; + +/** + * 직무 추천 점수를 응답에 노출하기 직전 사용자 체감 척도로 보정한다. + * + *

내부에 저장된 적합도 점수는 유사도 기반이라 절대값이 낮게 형성되어, 0~100 그대로 노출하면 실제 적합도에 비해 "추천이 부실하다"는 인상을 준다. 이 보정은 점수 + * 산출이나 저장값을 바꾸지 않고 표시값만 일정 하한 위로 끌어올려 신뢰도 인상을 개선한다. + * + *

규칙은 {@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; + } +} diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssembler.java b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssembler.java index fb7c4ef..02df009 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssembler.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssembler.java @@ -75,7 +75,8 @@ private List 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(); diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/JobScoreCalibratorTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/JobScoreCalibratorTest.java new file mode 100644 index 0000000..72796a2 --- /dev/null +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/JobScoreCalibratorTest.java @@ -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); + } + } +} diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssemblerTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssemblerTest.java index 45e4b88..53f5fae 100644 --- a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssemblerTest.java +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationAssemblerTest.java @@ -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"); } @@ -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 mainSubjects = view.mainSubjects(); assertThat(mainSubjects).hasSize(3);