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 @@ -29,13 +29,17 @@ public record TrackRecommendationView(
List<TrackView> primary,
List<TrackView> secondary) {}

/** 추천 트랙 한 건. {@code primary} 가 true 면 주 추천, {@code mainSubjects} 는 트랙의 주요 과목(전공필수) 목록. */
/**
* 추천 트랙 한 건. {@code primary} 가 true 면 주 추천, {@code crossCombination} 이 true 면 학과 경계를 넘는 이색 조합(보조
* 추천 중 별도 배지로 구분 노출 대상), {@code mainSubjects} 는 트랙의 주요 과목(전공필수) 목록.
*/
public record TrackView(
String code,
String name,
Integer score,
String reasoning,
boolean primary,
boolean crossCombination,
List<MainSubjectView> mainSubjects) {}

/** 트랙의 주요 과목(전공필수) 한 건. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ public class RecommendedTrack extends BaseEntity {
@Column(name = "is_primary", nullable = false)
private boolean primary;

/** 학과 경계를 넘는 이색 조합 슬롯에서 나온 트랙인지 — AI 의 cross_college 슬롯 분류를 보존한다. */
@Column(name = "is_cross_combination", nullable = false)
private boolean crossCombination;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recommendation_id", nullable = false)
private Recommendation recommendation;
Expand All @@ -46,10 +50,12 @@ public class RecommendedTrack extends BaseEntity {
private Track track;

@Builder
public RecommendedTrack(Integer score, String reasoning, boolean primary, Track track) {
public RecommendedTrack(
Integer score, String reasoning, boolean primary, boolean crossCombination, Track track) {
this.score = score;
this.reasoning = reasoning;
this.primary = primary;
this.crossCombination = crossCombination;
this.track = track;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ private TrackRecommendationView tracks(Recommendation recommendation) {
rt.getScore(),
rt.getReasoning(),
rt.isPrimary(),
rt.isCrossCombination(),
mainSubjects.getOrDefault(rt.getTrack().getId(), List.of()));
(rt.isPrimary() ? primary : secondary).add(view);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public class RecommendationService {

private static final int MAX_SECONDARY_TRACKS = 5;

/** AI 가 학과 경계를 넘는 이색 조합 슬롯에 부여하는 slot_type 값. */
private static final String SLOT_CROSS_COLLEGE = "cross_college";

private final OnboardingProfileReader onboardingProfileReader;
private final AiRecommendClient aiRecommendClient;
private final RecommendationAssembler recommendationAssembler;
Expand Down Expand Up @@ -158,40 +161,77 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra
if (aiTrack == null || !seen.add(aiTrack.trackId())) {
continue;
}
Optional<Track> track = trackRepository.findByCode(aiTrack.trackId());
Optional<Track> track = findCatalogTrack(aiTrack.trackId());
track.ifPresent(
t -> addTrack(recommendation, t, percent(top.synergyScore()), true, reasoning));
t -> addTrack(recommendation, t, percent(top.synergyScore()), true, false, reasoning));
}
}

// AI 슬롯 예약 규칙상 한 트랙은 한 슬롯에만 속하고 이색 조합(cross_college) 슬롯이 일반 다양성(mmr) 슬롯보다
// 먼저 오므로, seen 중복 제거로 트랙이 한 번만 저장돼도 이색 조합 분류가 보존된다.
int secondaryCount = 0;
for (RankedCombo combo : nullSafe(ai.secondaryCombos())) {
if (combo == null || combo.combo() == null) {
continue;
}
boolean crossCombination = isCrossCollege(combo);
for (AiRecommendResponse.Track aiTrack : pair(combo)) {
if (secondaryCount >= MAX_SECONDARY_TRACKS) {
break;
}
if (aiTrack == null || !seen.add(aiTrack.trackId())) {
continue;
}
Optional<Track> track = trackRepository.findByCode(aiTrack.trackId());
Optional<Track> track = findCatalogTrack(aiTrack.trackId());
if (track.isEmpty()) {
continue;
}
addTrack(recommendation, track.get(), percent(combo.synergyScore()), false, reasoning);
addTrack(
recommendation,
track.get(),
percent(combo.synergyScore()),
false,
crossCombination,
reasoning);
secondaryCount++;
}
}
}

// AI 카탈로그와 본 백엔드 카탈로그가 가운뎃점을 서로 다른 유니코드로 적재해(U+00B7 vs U+318D) 가운뎃점을 포함한
// 트랙 code 의 동등 비교가 실패한다. 원본 code 로 먼저 조회하고, 못 찾으면 가운뎃점을 카탈로그 정규형으로 맞춘 code 로 한 번 더 조회한다.
// 원본이 이미 매칭되는 트랙은 동작이 바뀌지 않고, 가운뎃점 불일치로 누락되던 트랙만 구제된다.
private Optional<Track> findCatalogTrack(String aiTrackId) {
if (aiTrackId == null) {
return Optional.empty();
}
Optional<Track> exact = trackRepository.findByCode(aiTrackId);
if (exact.isPresent()) {
return exact;
}
String normalized = TrackCodeNormalizer.toCatalogForm(aiTrackId);
if (normalized.equals(aiTrackId)) {
return exact;
}
return trackRepository.findByCode(normalized);
}

private static boolean isCrossCollege(RankedCombo combo) {
return SLOT_CROSS_COLLEGE.equalsIgnoreCase(combo.slotType());
}

private void addTrack(
Recommendation recommendation, Track track, int score, boolean primary, String reasoning) {
Recommendation recommendation,
Track track,
int score,
boolean primary,
boolean crossCombination,
String reasoning) {
recommendation.addRecommendedTrack(
RecommendedTrack.builder()
.score(score)
.primary(primary)
.crossCombination(crossCombination)
.reasoning(reasoning)
.track(track)
.build());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.hansung.tracktory.domain.recommendation.service;

/**
* AI 중계 서버가 보내는 트랙 식별자를 카탈로그가 저장한 정규 형태로 맞추는 anti-corruption 정규화.
*
* <p>두 저장소가 가운뎃점을 서로 다른 유니코드로 적재한다: AI 카탈로그는 MIDDLE DOT(U+00B7)을, 본 백엔드 카탈로그는 HANGUL LETTER
* ARAEA(U+318D)를 쓴다. 글자 모양이 사실상 같아 눈으로는 구분되지 않지만 코드 포인트가 달라 코드 동등 비교가 실패하고, 가운뎃점을 포함한 트랙이 매핑에서 조용히
* 누락된다. 외부 식별자의 가운뎃점 변형을 카탈로그 정규형으로 접어 매핑이 성립하게 한다.
*
* <p>시각적으로 구분되지 않는 코드 포인트들이라 가운뎃점은 문자 리터럴 대신 정수 코드 포인트로 다룬다.
*/
final class TrackCodeNormalizer {

/** 카탈로그가 트랙 code 에 쓰는 가운뎃점(HANGUL LETTER ARAEA, U+318D). */
private static final char CATALOG_MIDDLE_DOT = 0x318D;

private TrackCodeNormalizer() {}

/**
* 트랙 식별자의 가운뎃점 변형을 카탈로그 정규형(U+318D)으로 치환한다. 변형이 없으면 입력 인스턴스를 그대로 돌려준다.
*
* @param trackId AI 중계 서버가 보낸 트랙 식별자(널 허용)
* @return 카탈로그 정규형으로 맞춘 식별자, 입력이 널이면 널
*/
static String toCatalogForm(String trackId) {
if (trackId == null) {
return null;
}
StringBuilder normalized = null;
for (int i = 0; i < trackId.length(); i++) {
if (isMiddleDotVariant(trackId.charAt(i))) {
if (normalized == null) {
normalized = new StringBuilder(trackId);
}
normalized.setCharAt(i, CATALOG_MIDDLE_DOT);
}
}
return normalized == null ? trackId : normalized.toString();
}

/** 카탈로그 정규형과 같은 글리프로 렌더되는 가운뎃점 변형인지 정수 코드 포인트로 판정한다. */
private static boolean isMiddleDotVariant(char c) {
return c == 0x00B7 // MIDDLE DOT (AI 카탈로그가 실제 사용)
|| c == 0x0387 // GREEK ANO TELEIA
|| c == 0x2027 // HYPHENATION POINT
|| c == 0x30FB // KATAKANA MIDDLE DOT
|| c == 0xFF65; // HALFWIDTH KATAKANA MIDDLE DOT
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ void deserializesRealFastApiEnvelopeWithoutFieldLoss() {
assertThat(primary.combo().trackA().trackId()).isEqualTo("빅데이터트랙");
assertThat(primary.combo().trackB().trackId()).isEqualTo("AIㆍ소프트웨어학과");
assertThat(primary.synergyScore()).isEqualTo(0.0);
// slot_type 은 이색 조합(cross_college) 식별의 근거 필드이므로 역직렬화를 가드한다.
assertThat(primary.slotType()).isEqualTo("primary");

SemesterPlan plan = data.roadmap().semesters().get(0);
assertThat(plan.semester()).isEqualTo(3);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ void assemble_populatesMainSubjectsForRecommendedTracksCappedAtThree() {
TrackView view = response.tracks().primary().get(0);
assertThat(view.code()).isEqualTo("BIGDATA");
assertThat(view.primary()).isTrue();
assertThat(view.crossCombination()).isFalse();
assertThat(view.reasoning()).isEqualTo("이유");
// 직무 점수 보정(이슈 #45)은 트랙 점수에 적용되지 않는다: 트랙 점수는 원본 그대로 노출된다(보정 시 95→98).
assertThat(view.score()).isEqualTo(95);
Expand All @@ -212,6 +213,72 @@ void assemble_populatesMainSubjectsForRecommendedTracksCappedAtThree() {
assertThat(mainSubjects.get(2).name()).isEqualTo("데이터베이스");
}

@Test
void assemble_mapsCrossCombinationFlagOntoTrackViews() {
Track primaryTrack = mock(Track.class);
given(primaryTrack.getId()).willReturn(20L);
given(primaryTrack.getCode()).willReturn("BIGDATA");
given(primaryTrack.getName()).willReturn("빅데이터 트랙");
Track crossTrack = mock(Track.class);
given(crossTrack.getId()).willReturn(21L);
given(crossTrack.getCode()).willReturn("KOREAN_EDU");
given(crossTrack.getName()).willReturn("한국어교육 트랙");

Recommendation recommendation =
Recommendation.builder().status(RecommendationStatus.ACTIVE).build();
recommendation.addRecommendedTrack(
RecommendedTrack.builder()
.score(95)
.reasoning("주 추천")
.primary(true)
.crossCombination(false)
.track(primaryTrack)
.build());
recommendation.addRecommendedTrack(
RecommendedTrack.builder()
.score(60)
.reasoning("이색 조합")
.primary(false)
.crossCombination(true)
.track(crossTrack)
.build());

given(trackSubjectRepository.findByTrackInAndType(anyCollection(), eq(SubjectType.REQUIRED)))
.willReturn(List.of());

OnboardingProfileSnapshot profile =
new OnboardingProfileSnapshot(
1L,
2023,
"IT공과대학",
"컴퓨터공학부",
3,
List.of("BIGDATA"),
List.of("IT/인터넷"),
List.of("앱"),
List.of("성장성"),
List.of("대기업"),
List.of(),
List.of());

RecommendationResponse response = recommendationAssembler.assemble(recommendation, profile);

assertThat(response.tracks().primary())
.singleElement()
.satisfies(
v -> {
assertThat(v.code()).isEqualTo("BIGDATA");
assertThat(v.crossCombination()).isFalse();
});
assertThat(response.tracks().secondary())
.singleElement()
.satisfies(
v -> {
assertThat(v.code()).isEqualTo("KOREAN_EDU");
assertThat(v.crossCombination()).isTrue();
});
}

private static Subject subject(long id, String code, String name) {
Subject subject = mock(Subject.class);
given(subject.getId()).willReturn(id);
Expand Down
Loading
Loading