From 795ea8201e1c491c4bd279eefa8285fb30dab05c Mon Sep 17 00:00:00 2001 From: Jaewon Lee <50106190+jwon0523@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:50:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=B6=94=EC=B2=9C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=83=81=EC=84=B8=20=ED=95=84=EB=93=9C=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95=20(=EA=B3=BC=EB=AA=A9=20=ED=95=99=EC=A0=90=C2=B7?= =?UTF-8?q?=EC=84=A4=EB=AA=85,=20=EC=A7=81=EB=AC=B4=20=EC=97=AD=EB=9F=89?= =?UTF-8?q?=20=ED=83=9C=EA=B7=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/RecommendationResponse.java | 15 ++++++++++++--- .../recommendation/entity/RecommendedJob.java | 17 ++++++++++++++++- .../service/RecommendationAssembler.java | 7 ++++++- .../service/RecommendationService.java | 1 + .../ai/AiEnvelopeContractTest.java | 1 + .../service/RecommendationAssemblerTest.java | 15 ++++++++++++++- .../service/RecommendationServiceTest.java | 12 +++++++++--- 7 files changed, 59 insertions(+), 9 deletions(-) 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 7d19989..dba664d 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 @@ -1,5 +1,6 @@ package com.hansung.tracktory.domain.recommendation.dto; +import java.math.BigDecimal; import java.util.List; /** @@ -16,10 +17,16 @@ public record RecommendationResponse( /** * 추천 직무 한 건. {@code score} 는 사용자 체감 척도로 보정한 표시 점수(0~100, 하한 위로 끌어올린 값이며 내부 저장 점수와는 다름), {@code - * techStacks} 는 카탈로그가 보유한 직무 요구 기술 스택 이름 목록(없으면 빈 리스트). + * techStacks} 는 카탈로그가 보유한 직무 요구 기술 스택 이름 목록(없으면 빈 리스트), {@code competencyTags} 는 AI 가 직무 카드 표시용으로 + * 돌려준 역량 태그 목록. */ public record JobView( - String code, String name, Integer score, String reasoning, List techStacks) {} + String code, + String name, + Integer score, + String reasoning, + List techStacks, + List competencyTags) {} /** 트랙 추천 — 주 추천 2개 + 보조 추천 다수, 최상위 조합의 시너지 요약을 포함한다. */ public record TrackRecommendationView( @@ -54,11 +61,13 @@ public record SemesterView( /** * 학기 내 과목. {@code timing} 은 PAST/CURRENT/FUTURE, {@code completed} 는 이수 여부, {@code score} 는 미래 추천 - * 과목의 적합도(0~100, 과거 과목은 null). + * 과목의 적합도(0~100, 과거 과목은 null), {@code credits}/{@code description} 은 카탈로그 과목 상세 정보. */ public record CourseView( String code, String name, + BigDecimal credits, + String description, String timing, boolean completed, Integer score, diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendedJob.java b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendedJob.java index fc87b73..c86a4f6 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendedJob.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendedJob.java @@ -2,7 +2,9 @@ import com.hansung.tracktory.domain.catalog.career.entity.Job; import com.hansung.tracktory.global.entity.BaseEntity; +import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -10,8 +12,11 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OrderColumn; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import java.util.ArrayList; +import java.util.List; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -35,6 +40,14 @@ public class RecommendedJob extends BaseEntity { @Column(columnDefinition = "text") private String reasoning; + @ElementCollection + @CollectionTable( + name = "recommended_job_competency_tag", + joinColumns = @JoinColumn(name = "recommended_job_id")) + @OrderColumn(name = "tag_order") + @Column(name = "tag", nullable = false) + private List competencyTags = new ArrayList<>(); + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "recommendation_id", nullable = false) private Recommendation recommendation; @@ -44,9 +57,11 @@ public class RecommendedJob extends BaseEntity { private Job job; @Builder - public RecommendedJob(Integer score, String reasoning, Job job) { + public RecommendedJob(Integer score, String reasoning, List competencyTags, Job job) { this.score = score; this.reasoning = reasoning; + this.competencyTags = + competencyTags == null ? new ArrayList<>() : new ArrayList<>(competencyTags); this.job = job; } 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 47fe524..b991546 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 @@ -78,7 +78,8 @@ private List jobs(Recommendation recommendation) { // 저장된 내부 점수는 그대로 두고 응답 노출값만 체감 척도로 보정한다. JobScoreCalibrator.toDisplayScore(j.getScore()), j.getReasoning(), - techStacks.getOrDefault(j.getJob().getId(), List.of()))) + techStacks.getOrDefault(j.getJob().getId(), List.of()), + List.copyOf(j.getCompetencyTags()))) .toList(); } @@ -198,6 +199,8 @@ private SemesterView pastSemester( new CourseView( s.getCode(), s.getName(), + s.getCredit(), + s.getDescription(), timing, true, null, @@ -219,6 +222,8 @@ private SemesterView futureSemester( new CourseView( item.getSubject().getCode(), item.getSubject().getName(), + item.getSubject().getCredit(), + item.getSubject().getDescription(), timing, false, item.getScore(), diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java index ed3170e..862628e 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java @@ -147,6 +147,7 @@ private void addJobs(Recommendation recommendation, AiRecommendResponse ai) { RecommendedJob.builder() .score(percent(job.matchScore())) .reasoning(reasoning) + .competencyTags(nullSafe(job.competencyTags())) .job(catalogJob) .build())); } diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/ai/AiEnvelopeContractTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/ai/AiEnvelopeContractTest.java index 35367b1..b0e7010 100644 --- a/src/test/java/com/hansung/tracktory/domain/recommendation/ai/AiEnvelopeContractTest.java +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/ai/AiEnvelopeContractTest.java @@ -76,6 +76,7 @@ void deserializesRealFastApiEnvelopeWithoutFieldLoss() { JobCandidate job = data.jobs().get(0); assertThat(job.jobId()).isEqualTo("ml_engineer"); + assertThat(job.competencyTags()).containsExactly("문제해결"); assertThat(job.matchScore()).isEqualTo(0.91); assertThat(job.fallbackUsed()).isFalse(); 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 2110174..881491e 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 @@ -33,6 +33,7 @@ import com.hansung.tracktory.domain.recommendation.entity.RoadmapSemester; import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot; import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot.CompletedCourse; +import java.math.BigDecimal; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -99,6 +100,8 @@ void assemble_prependsReconstructedPastSemestersBeforeFutureWithTiming() { assertThat(first.timing()).isEqualTo("PAST"); assertThat(first.courses()).hasSize(1); assertThat(first.courses().get(0).code()).isEqualTo("W1"); + assertThat(first.courses().get(0).credits()).isEqualByComparingTo("3.0"); + assertThat(first.courses().get(0).description()).isEqualTo("자료구조 설명"); assertThat(first.courses().get(0).completed()).isTrue(); assertThat(first.courses().get(0).score()).isNull(); @@ -114,6 +117,8 @@ void assemble_prependsReconstructedPastSemestersBeforeFutureWithTiming() { assertThat(third.stage()).isEqualTo(SubjectStage.APPLIED.name()); assertThat(third.timing()).isEqualTo("FUTURE"); assertThat(third.courses().get(0).code()).isEqualTo("W3"); + assertThat(third.courses().get(0).credits()).isEqualByComparingTo("3.0"); + assertThat(third.courses().get(0).description()).isEqualTo("캡스톤 설명"); assertThat(third.courses().get(0).completed()).isFalse(); assertThat(third.courses().get(0).score()).isEqualTo(2); } @@ -128,7 +133,12 @@ void assemble_populatesJobTechStacksFromCatalogSortedByName() { Recommendation recommendation = Recommendation.builder().status(RecommendationStatus.ACTIVE).build(); recommendation.addRecommendedJob( - RecommendedJob.builder().score(90).reasoning("이유").job(job).build()); + RecommendedJob.builder() + .score(90) + .reasoning("이유") + .competencyTags(List.of("API 설계", "데이터 모델링")) + .job(job) + .build()); JobTechStack spark = jobTechStack(job, "Spark"); JobTechStack airflow = jobTechStack(job, "Airflow"); @@ -159,6 +169,7 @@ void assemble_populatesJobTechStacksFromCatalogSortedByName() { assertThat(view.score()).isEqualTo(96); assertThat(view.reasoning()).isEqualTo("이유"); assertThat(view.techStacks()).containsExactly("Airflow", "Spark"); + assertThat(view.competencyTags()).containsExactly("API 설계", "데이터 모델링"); } @Test @@ -284,6 +295,8 @@ private static Subject subject(long id, String code, String name) { given(subject.getId()).willReturn(id); given(subject.getCode()).willReturn(code); given(subject.getName()).willReturn(name); + given(subject.getCredit()).willReturn(new BigDecimal("3.0")); + given(subject.getDescription()).willReturn(name + " 설명"); return subject; } diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java index 9b98bbb..8b3051a 100644 --- a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java @@ -146,6 +146,8 @@ void generate_freshWhenNoActive_callsAiSupersedesSavesAndMaps() { assertThat(saved.getRecommendedJobs().get(0).getScore()).isEqualTo(90); assertThat(saved.getRecommendedJobs().get(0).getJob().getCode()).isEqualTo("be_dev"); assertThat(saved.getRecommendedJobs().get(0).getReasoning()).isEqualTo("직무 설명"); + assertThat(saved.getRecommendedJobs().get(0).getCompetencyTags()) + .containsExactly("API 설계", "트랜잭션"); assertThat(saved.getRecommendedTracks()).hasSize(4); List primaries = @@ -219,8 +221,9 @@ void generate_jobsFoldingToSameCatalogCode_dedupedToSingleRecommendedJob() { AiRecommendResponse ai = new AiRecommendResponse( List.of( - new JobCandidate("DE", "데이터 엔지니어", List.of(), List.of(), 0.9, 0.8, false), - new JobCandidate("DE", "데이터 분석가", List.of(), List.of(), 0.5, 0.4, false)), + new JobCandidate( + "DE", "데이터 엔지니어", List.of(), List.of("데이터 파이프라인"), 0.9, 0.8, false), + new JobCandidate("DE", "데이터 분석가", List.of(), List.of("분석 모델링"), 0.5, 0.4, false)), List.of(), List.of(), null, @@ -251,6 +254,7 @@ void generate_jobsFoldingToSameCatalogCode_dedupedToSingleRecommendedJob() { assertThat(saved.getRecommendedJobs()).hasSize(1); assertThat(saved.getRecommendedJobs().get(0).getJob().getCode()).isEqualTo("DE"); assertThat(saved.getRecommendedJobs().get(0).getScore()).isEqualTo(90); + assertThat(saved.getRecommendedJobs().get(0).getCompetencyTags()).containsExactly("데이터 파이프라인"); } @Test @@ -397,7 +401,9 @@ private static AiRecommendResponse sampleAiResponse() { List.of()); return new AiRecommendResponse( - List.of(new JobCandidate("be_dev", "백엔드 개발자", List.of(), List.of(), 0.9, 0.8, false)), + List.of( + new JobCandidate( + "be_dev", "백엔드 개발자", List.of(), List.of("API 설계", "트랜잭션"), 0.9, 0.8, false)), List.of(primary), List.of(secondaryCross, secondaryMmr), roadmap, From 87a1e572d702c4d79b9631be5271f53b79a67971 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <50106190+jwon0523@users.noreply.github.com> Date: Thu, 4 Jun 2026 02:12:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=A7=81=EB=AC=B4=20=EC=97=AD?= =?UTF-8?q?=EB=9F=89=20=ED=83=9C=EA=B7=B8=20=EC=9D=91=EB=8B=B5=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RecommendationAssembler.java | 9 +++++- .../service/RecommendationAssemblerTest.java | 31 +++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) 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 b991546..93cfafc 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 @@ -52,6 +52,9 @@ public class RecommendationAssembler { /** 트랙별 주요 과목으로 노출할 전공필수 과목 수. */ private static final int MAIN_SUBJECT_LIMIT = 3; + /** 직무 카드가 과도하게 커지지 않도록 AI 가 정렬해 준 역량 태그 중 상위 N 개만 노출한다. */ + private static final int COMPETENCY_TAG_LIMIT = 10; + private final SubjectRepository subjectRepository; private final SubjectPrerequisiteRepository subjectPrerequisiteRepository; private final TrackSubjectRepository trackSubjectRepository; @@ -79,10 +82,14 @@ private List jobs(Recommendation recommendation) { JobScoreCalibrator.toDisplayScore(j.getScore()), j.getReasoning(), techStacks.getOrDefault(j.getJob().getId(), List.of()), - List.copyOf(j.getCompetencyTags()))) + topCompetencyTags(j.getCompetencyTags()))) .toList(); } + private List topCompetencyTags(List competencyTags) { + return competencyTags.stream().limit(COMPETENCY_TAG_LIMIT).toList(); + } + private Map> techStackIndex(List jobs) { Map> index = new LinkedHashMap<>(); if (jobs.isEmpty()) { 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 881491e..99216e7 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 @@ -124,11 +124,25 @@ void assemble_prependsReconstructedPastSemestersBeforeFutureWithTiming() { } @Test - void assemble_populatesJobTechStacksFromCatalogSortedByName() { + void assemble_populatesJobTechStacksFromCatalogSortedByNameAndLimitsCompetencyTags() { Job job = mock(Job.class); given(job.getId()).willReturn(10L); given(job.getCode()).willReturn("DE"); given(job.getName()).willReturn("데이터 엔지니어"); + List competencyTags = + List.of( + "Docker", + "S3", + "Grafana", + "Flink", + "Prometheus", + "dbt", + "BigQuery", + "PostgreSQL", + "Vue.js", + "Apache", + "CI/CD", + "Hadoop"); Recommendation recommendation = Recommendation.builder().status(RecommendationStatus.ACTIVE).build(); @@ -136,7 +150,7 @@ void assemble_populatesJobTechStacksFromCatalogSortedByName() { RecommendedJob.builder() .score(90) .reasoning("이유") - .competencyTags(List.of("API 설계", "데이터 모델링")) + .competencyTags(competencyTags) .job(job) .build()); @@ -169,7 +183,18 @@ void assemble_populatesJobTechStacksFromCatalogSortedByName() { assertThat(view.score()).isEqualTo(96); assertThat(view.reasoning()).isEqualTo("이유"); assertThat(view.techStacks()).containsExactly("Airflow", "Spark"); - assertThat(view.competencyTags()).containsExactly("API 설계", "데이터 모델링"); + assertThat(view.competencyTags()) + .containsExactly( + "Docker", + "S3", + "Grafana", + "Flink", + "Prometheus", + "dbt", + "BigQuery", + "PostgreSQL", + "Vue.js", + "Apache"); } @Test