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

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.annotation.JsonNaming;
Expand Down Expand Up @@ -133,12 +134,35 @@ public record SemesterSubtitle(int semester, String subtitle) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record CourseFlow(String courseId, String flow) {}

/** LLM 자연어 설명 전체. */
/** 추천 직무 한 건의 개별 근거 — job_id 로 해당 직무 항목에 바인딩한다. */
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record JobRationale(String jobId, String rationale) {}

/** 추천 트랙 조합 한 건의 개별 근거 — combo_key 로 해당 조합 항목에 바인딩한다. 조합 전체 근거(시너지)와 각 트랙 자체 근거를 별도 필드로 구분한다. */
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record TrackRationale(
String comboKey,
String comboRationale,
// SnakeCaseStrategy 는 연속 대문자(A·R)를 합쳐 track_a_rationale 가 아닌 track_arationale 로 매핑하므로
// AI 와이어 필드명을 명시 고정한다(track_b 동일).
@JsonProperty("track_a_rationale") String trackARationale,
@JsonProperty("track_b_rationale") String trackBRationale) {}

/**
* LLM 자연어 설명 전체.
*
* <p>영역별 단락({@code sections})은 직무/트랙/로드맵 영역의 요약 근거를, 항목별 근거({@code jobRationales}/{@code
* trackRationales})는 직무 한 건·트랙 조합 한 건 단위의 개별 근거를 담는다. 항목별 리스트가 비면 영역 단락으로 폴백한다.
*/
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record Explanation(
String text,
List<ExplanationSection> sections,
List<JobRationale> jobRationales,
List<TrackRationale> trackRationales,
List<SemesterSubtitle> semesterSubtitles,
List<CourseFlow> courseFlows) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.CourseCoverageContribution;
import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.Explanation;
import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.JobCoverage;
import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.JobRationale;
import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.NextActionSuggestion;
import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.RankedCombo;
import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.RoadmapCourse;
import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.RoadmapStage;
import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.SemesterPlan;
import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.TrackRationale;
import com.hansung.tracktory.domain.recommendation.dto.RecommendationResponse;
import com.hansung.tracktory.domain.recommendation.entity.Recommendation;
import com.hansung.tracktory.domain.recommendation.entity.RecommendationCourseContribution;
Expand Down Expand Up @@ -127,7 +129,7 @@ private Recommendation buildRecommendation(User user, AiRecommendResponse ai) {
.triggerSource(RecommendationTriggerSource.MANUAL)
.trackCombinationScore(topCombo == null ? null : percent(topCombo.synergyScore()))
.trackCombinationSummary(comboSummary(topCombo))
.trackCombinationReasoning(explanationBody(ai.explanation(), "tracks"))
.trackCombinationReasoning(comboReasoning(ai.explanation(), topCombo))
.build();

addJobs(recommendation, ai);
Expand Down Expand Up @@ -207,13 +209,15 @@ private static String joinTokens(List<String> tokens) {
private void addJobs(Recommendation recommendation, AiRecommendResponse ai) {
// AI 서버가 세분화 직무를 카탈로그 코드로 fold 하면 서로 다른 직무가 같은 코드로 겹칠 수 있다.
// (recommendation_id, job_id) 유니크 제약을 지키도록 코드 기준으로 중복을 제거한다(match_score 내림차순 가정 → 첫 건 채택).
// AI 는 영역(jobs) 단위 설명만 주므로 같은 영역 문구를 각 직무 항목 근거로 채운다.
String reasoning = explanationBody(ai.explanation(), "jobs");
// AI 가 직무 한 건 단위 개별 근거(job_rationales)를 주면 그것을, 없으면 영역(jobs) 단락으로 폴백한다.
Map<String, String> rationaleByJobId = jobRationaleIndex(ai.explanation());
String areaReasoning = explanationBody(ai.explanation(), "jobs");
Set<String> seen = new HashSet<>();
for (AiRecommendResponse.JobCandidate job : nullSafe(ai.jobs())) {
if (job == null || job.jobId() == null || !seen.add(job.jobId())) {
continue;
}
String reasoning = firstNonBlank(rationaleByJobId.get(job.jobId()), areaReasoning);
jobRepository
.findByCode(job.jobId())
.ifPresent(
Expand All @@ -229,17 +233,24 @@ private void addJobs(Recommendation recommendation, AiRecommendResponse ai) {
}

private void addTracks(Recommendation recommendation, AiRecommendResponse ai, RankedCombo top) {
// AI 는 영역(tracks) 단위 설명만 주므로 같은 영역 문구를 각 트랙 항목 근거로 채운다.
String reasoning = explanationBody(ai.explanation(), "tracks");
// AI 가 트랙 조합 한 건 단위 개별 근거(track_rationales)를 주면 조합 안에서 트랙별(A/B)로 바인딩하고,
// 없으면 영역(tracks) 단락으로 폴백한다.
Map<String, TrackRationale> rationaleByComboKey = trackRationaleIndex(ai.explanation());
String areaReasoning = explanationBody(ai.explanation(), "tracks");
Set<String> seen = new HashSet<>();
if (top != null && top.combo() != null) {
for (AiRecommendResponse.Track aiTrack : pair(top)) {
List<AiRecommendResponse.Track> pair = pair(top);
for (int i = 0; i < pair.size(); i++) {
AiRecommendResponse.Track aiTrack = pair.get(i);
if (aiTrack == null || !seen.add(aiTrack.trackId())) {
continue;
}
Optional<Track> track = findCatalogTrack(aiTrack.trackId());
track.ifPresent(
t -> addTrack(recommendation, t, percent(top.synergyScore()), true, false, reasoning));
String reasoning = trackReasoning(rationaleByComboKey, top, i, areaReasoning);
findCatalogTrack(aiTrack.trackId())
.ifPresent(
t ->
addTrack(
recommendation, t, percent(top.synergyScore()), true, false, reasoning));
}
}

Expand All @@ -251,7 +262,9 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra
continue;
}
boolean crossCombination = isCrossCollege(combo);
for (AiRecommendResponse.Track aiTrack : pair(combo)) {
List<AiRecommendResponse.Track> pair = pair(combo);
for (int i = 0; i < pair.size(); i++) {
AiRecommendResponse.Track aiTrack = pair.get(i);
if (secondaryCount >= MAX_SECONDARY_TRACKS) {
break;
}
Expand All @@ -262,6 +275,7 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra
if (track.isEmpty()) {
continue;
}
String reasoning = trackReasoning(rationaleByComboKey, combo, i, areaReasoning);
addTrack(
recommendation,
track.get(),
Expand All @@ -274,6 +288,66 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra
}
}

// 조합 한 건의 개별 근거에서 트랙 위치(0=트랙A, 1=트랙B)에 해당하는 근거를 고른다. 항목별 근거가 없거나 비면 영역 단락으로 폴백한다.
private static String trackReasoning(
Map<String, TrackRationale> rationaleByComboKey,
RankedCombo combo,
int trackIndex,
String areaReasoning) {
TrackRationale rationale = rationaleByComboKey.get(comboKey(combo));
if (rationale == null) {
return areaReasoning;
}
String perTrack = trackIndex == 0 ? rationale.trackARationale() : rationale.trackBRationale();
return firstNonBlank(perTrack, areaReasoning);
}

// 최상위 조합 단위 근거(시너지)를 고른다 — 개별 트랙 근거와 구분되는 조합 전체 근거. 없으면 영역(tracks) 단락으로 폴백한다.
private static String comboReasoning(Explanation explanation, RankedCombo top) {
String areaReasoning = explanationBody(explanation, "tracks");
if (top == null || top.combo() == null) {
return areaReasoning;
}
TrackRationale rationale = trackRationaleIndex(explanation).get(comboKey(top));
return rationale == null
? areaReasoning
: firstNonBlank(rationale.comboRationale(), areaReasoning);
}

private static String comboKey(RankedCombo combo) {
return combo == null || combo.combo() == null ? null : combo.combo().comboKey();
}

private static Map<String, String> jobRationaleIndex(Explanation explanation) {
Map<String, String> index = new HashMap<>();
if (explanation == null) {
return index;
}
for (JobRationale rationale : nullSafe(explanation.jobRationales())) {
if (rationale != null && rationale.jobId() != null) {
index.putIfAbsent(rationale.jobId(), rationale.rationale());
}
}
return index;
}

private static Map<String, TrackRationale> trackRationaleIndex(Explanation explanation) {
Map<String, TrackRationale> index = new HashMap<>();
if (explanation == null) {
return index;
}
for (TrackRationale rationale : nullSafe(explanation.trackRationales())) {
if (rationale != null && rationale.comboKey() != null) {
index.putIfAbsent(rationale.comboKey(), rationale);
}
}
return index;
}

private static String firstNonBlank(String preferred, String fallback) {
return preferred != null && !preferred.isBlank() ? preferred : fallback;
}

// AI 카탈로그와 본 백엔드 카탈로그가 가운뎃점을 서로 다른 유니코드로 적재해(U+00B7 vs U+318D) 가운뎃점을 포함한
// 트랙 code 의 동등 비교가 실패한다. 원본 code 로 먼저 조회하고, 못 찾으면 가운뎃점을 카탈로그 정규형으로 맞춘 code 로 한 번 더 조회한다.
// 원본이 이미 매칭되는 트랙은 동작이 바뀌지 않고, 가운뎃점 불일치로 누락되던 트랙만 구제된다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.hansung.tracktory.domain.recommendation;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import com.hansung.tracktory.domain.recommendation.dto.RecommendationResponse;
import com.hansung.tracktory.domain.recommendation.dto.RecommendationResponse.JobView;
import com.hansung.tracktory.domain.recommendation.dto.RecommendationResponse.TrackView;
import com.hansung.tracktory.domain.recommendation.entity.RecommendationStatus;
import com.hansung.tracktory.domain.recommendation.repository.RecommendationRepository;
import com.hansung.tracktory.domain.user.entity.User;
import com.hansung.tracktory.domain.user.repository.UserRepository;
import com.hansung.tracktory.domain.user.service.UserPrincipal;
import com.hansung.tracktory.global.jwt.JwtUtil;
import com.hansung.tracktory.global.response.ApiResponse;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.web.client.RestClient;

/**
* 추천 근거 항목별 매핑 e2e — 실제 AI 중계 서버(tracktory-ai feat/175, 항목별 근거 제공)를 호출해 직무·트랙 항목마다 서로 다른 근거가 응답에
* 바인딩되는지 검증한다.
*
* <p>실제 외부 의존성(로컬 AI 서버 + LLM 호출)을 사용하므로 {@code @Tag("e2e")} 로 분리하고, AI 서버가 떠 있지 않으면 {@code
* assumeTrue} 로 스킵한다(CI 처럼 AI 서버가 없는 환경에서는 실패가 아니라 스킵). 로컬에서 {@code AI_E2E_PORT} 포트(기본 8001)에
* feat/175 서버가 떠 있을 때만 실행된다. {@code webEnvironment=RANDOM_PORT} 라 데이터가 실제 커밋되므로 생성물은
* {@code @AfterEach} 에서 정리한다.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Tag("e2e")
class RecommendationReasoningE2ETest {

private static final int AI_PORT =
Integer.parseInt(System.getenv().getOrDefault("AI_E2E_PORT", "8001"));

@DynamicPropertySource
static void aiRelayBaseUrl(DynamicPropertyRegistry registry) {
registry.add("ai-relay.base-url", () -> "http://localhost:" + AI_PORT);
}

@LocalServerPort private int port;

@Autowired private UserRepository userRepository;
@Autowired private RecommendationRepository recommendationRepository;
@Autowired private JwtUtil jwtUtil;

private Long createdUserId;

@AfterEach
void cleanup() {
if (createdUserId == null) {
return;
}
try {
recommendationRepository.deleteAll(
recommendationRepository.findByUser_IdAndStatus(
createdUserId, RecommendationStatus.ACTIVE));
userRepository.deleteById(createdUserId);
} catch (RuntimeException ignored) {
// 정리 실패가 검증 결과를 가리지 않도록 best-effort 로 둔다(개발 DB 한정 e2e).
}
}

@Test
void recommend_bindsDistinctReasoningPerJobAndTrack() {
assumeTrue(aiServerUp(), "AI feat/175 서버가 :" + AI_PORT + " 에 없음 — e2e 스킵");

String email = "e2e-reasoning-" + java.util.UUID.randomUUID() + "@hansung.ac.kr";
User user = userRepository.save(User.builder().email(email).passwordHash("x").build());
createdUserId = user.getId();
String token = jwtUtil.generateToken(new UserPrincipal(user));

// 추천 파이프라인은 RAGFlow + 항목별 근거 생성 LLM 호출로 길어질 수 있어 읽기 타임아웃을 넉넉히 둔다(백엔드는 자체 타임아웃 없음).
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory();
requestFactory.setReadTimeout(Duration.ofMinutes(5));
ResponseEntity<ApiResponse<RecommendationResponse>> response =
RestClient.builder()
.requestFactory(requestFactory)
.build()
.post()
.uri("http://localhost:" + port + "/api/v1/recommendations?forceRefresh=true")
.header("Authorization", "Bearer " + token)
.retrieve()
.onStatus(HttpStatusCode::isError, (req, res) -> {})
.toEntity(new ParameterizedTypeReference<ApiResponse<RecommendationResponse>>() {});

// 실 LLM 호출은 OpenAI 지연으로 일시 실패(AI_RELAY_ERROR)할 수 있다. 이는 백엔드 결함이 아니라 외부 의존성
// 트랜션트이므로, 비정상 응답이면 실패가 아니라 스킵한다(매핑 로직 검증은 결정론 단위/계약 테스트가 담당).
assumeTrue(
response.getStatusCode().is2xxSuccessful(),
"AI 응답 비정상(LLM 지연/일시 오류) — e2e 스킵: " + response.getBody());
RecommendationResponse body = response.getBody().data();
assertThat(body).isNotNull();

// 직무: AI 가 직무 식별자(job_id)별 개별 근거를 제공하므로, 서로 다른 직무는 서로 다른 근거로 바인딩된다.
List<JobView> jobs = body.jobs();
assertThat(jobs).as("추천 직무").hasSizeGreaterThanOrEqualTo(2);
assertThat(jobs).allSatisfy(j -> assertThat(j.reasoning()).isNotBlank());
assertThat(jobs.stream().map(JobView::reasoning).toList())
.as("직무 항목별 근거가 더 이상 동일 문구로 중복되지 않음")
.doesNotHaveDuplicates();

// 트랙: 모든 항목에 근거가 채워져 노출된다(항목별 근거 또는 영역 단락 안전 폴백). 조합 전체 근거도 노출된다.
// 트랙 항목별 문구의 상이성은 AI(LLM) 의 항목별 생성 품질에 의존하므로, 결정론 단위 테스트에서 검증한다.
List<TrackView> allTracks = new ArrayList<>();
allTracks.addAll(body.tracks().primary());
allTracks.addAll(body.tracks().secondary());
assertThat(allTracks).as("추천 트랙").isNotEmpty();
assertThat(allTracks).allSatisfy(t -> assertThat(t.reasoning()).isNotBlank());
assertThat(body.tracks().combinationReasoning()).as("조합 전체 근거").isNotBlank();
// 응답 본문은 영속 aggregate 를 조립한 결과이므로, 위 검증으로 매핑이 DB 까지 흐른 것이 확인된다.
// (영속 엔티티 직접 검증은 결정론 단위 테스트가 ArgumentCaptor 로 담당한다.)
}

private static boolean aiServerUp() {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress("localhost", AI_PORT), 500);
return true;
} catch (IOException e) {
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ void deserializesRealFastApiEnvelopeWithoutFieldLoss() {
"explanation": {
"text":"전체 설명",
"sections":[{"topic":"tracks","body":"트랙 설명"}],
"job_rationales":[{"job_id":"ml_engineer","rationale":"머신러닝 직무 개별 근거"}],
"track_rationales":[{"combo_key":"빅데이터트랙|AIㆍ소프트웨어학과",
"combo_rationale":"조합 시너지 근거","track_a_rationale":"빅데이터 트랙 근거",
"track_b_rationale":"AI 트랙 근거"}],
"semester_subtitles":[{"semester":3,"subtitle":"기초 다지기"}],
"course_flows":[{"course_id":"V020002","flow":"기초→응용"}]
}
Expand Down Expand Up @@ -99,5 +103,17 @@ void deserializesRealFastApiEnvelopeWithoutFieldLoss() {

assertThat(data.explanation().sections().get(0).topic()).isEqualTo("tracks");
assertThat(data.explanation().semesterSubtitles().get(0).semester()).isEqualTo(3);

// 항목별 개별 근거(job_rationales / track_rationales)의 snake_case 매핑 가드 — 직무는 job_id, 트랙은 combo_key 로
// 바인딩된다.
AiRecommendResponse.JobRationale jobRationale = data.explanation().jobRationales().get(0);
assertThat(jobRationale.jobId()).isEqualTo("ml_engineer");
assertThat(jobRationale.rationale()).isEqualTo("머신러닝 직무 개별 근거");

AiRecommendResponse.TrackRationale trackRationale = data.explanation().trackRationales().get(0);
assertThat(trackRationale.comboKey()).isEqualTo("빅데이터트랙|AIㆍ소프트웨어학과");
assertThat(trackRationale.comboRationale()).isEqualTo("조합 시너지 근거");
assertThat(trackRationale.trackARationale()).isEqualTo("빅데이터 트랙 근거");
assertThat(trackRationale.trackBRationale()).isEqualTo("AI 트랙 근거");
}
}
Loading
Loading