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
@@ -1,5 +1,6 @@
package com.hansung.tracktory.domain.recommendation.dto;

import java.math.BigDecimal;
import java.util.List;

/**
Expand All @@ -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<String> techStacks) {}
String code,
String name,
Integer score,
String reasoning,
List<String> techStacks,
List<String> competencyTags) {}

/** 트랙 추천 — 주 추천 2개 + 보조 추천 다수, 최상위 조합의 시너지 요약을 포함한다. */
public record TrackRecommendationView(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@

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;
import jakarta.persistence.GenerationType;
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;
Expand All @@ -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<String> competencyTags = new ArrayList<>();

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recommendation_id", nullable = false)
private Recommendation recommendation;
Expand All @@ -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<String> competencyTags, Job job) {
this.score = score;
this.reasoning = reasoning;
this.competencyTags =
competencyTags == null ? new ArrayList<>() : new ArrayList<>(competencyTags);
this.job = job;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -78,10 +81,15 @@ private List<JobView> jobs(Recommendation recommendation) {
// 저장된 내부 점수는 그대로 두고 응답 노출값만 체감 척도로 보정한다.
JobScoreCalibrator.toDisplayScore(j.getScore()),
j.getReasoning(),
techStacks.getOrDefault(j.getJob().getId(), List.of())))
techStacks.getOrDefault(j.getJob().getId(), List.of()),
topCompetencyTags(j.getCompetencyTags())))
.toList();
}

private List<String> topCompetencyTags(List<String> competencyTags) {
return competencyTags.stream().limit(COMPETENCY_TAG_LIMIT).toList();
}

private Map<Long, List<String>> techStackIndex(List<Job> jobs) {
Map<Long, List<String>> index = new LinkedHashMap<>();
if (jobs.isEmpty()) {
Expand Down Expand Up @@ -198,6 +206,8 @@ private SemesterView pastSemester(
new CourseView(
s.getCode(),
s.getName(),
s.getCredit(),
s.getDescription(),
timing,
true,
null,
Expand All @@ -219,6 +229,8 @@ private SemesterView futureSemester(
new CourseView(
item.getSubject().getCode(),
item.getSubject().getName(),
item.getSubject().getCredit(),
item.getSubject().getDescription(),
timing,
false,
item.getScore(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand All @@ -114,21 +117,42 @@ 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);
}

@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<String> 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();
recommendation.addRecommendedJob(
RecommendedJob.builder().score(90).reasoning("이유").job(job).build());
RecommendedJob.builder()
.score(90)
.reasoning("이유")
.competencyTags(competencyTags)
.job(job)
.build());

JobTechStack spark = jobTechStack(job, "Spark");
JobTechStack airflow = jobTechStack(job, "Airflow");
Expand Down Expand Up @@ -159,6 +183,18 @@ void assemble_populatesJobTechStacksFromCatalogSortedByName() {
assertThat(view.score()).isEqualTo(96);
assertThat(view.reasoning()).isEqualTo("이유");
assertThat(view.techStacks()).containsExactly("Airflow", "Spark");
assertThat(view.competencyTags())
.containsExactly(
"Docker",
"S3",
"Grafana",
"Flink",
"Prometheus",
"dbt",
"BigQuery",
"PostgreSQL",
"Vue.js",
"Apache");
}

@Test
Expand Down Expand Up @@ -284,6 +320,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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecommendedTrack> primaries =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading