diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/ai/AiRecommendResponse.java b/src/main/java/com/hansung/tracktory/domain/recommendation/ai/AiRecommendResponse.java index 2613e14..ee4f033 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/ai/AiRecommendResponse.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/ai/AiRecommendResponse.java @@ -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; @@ -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 자연어 설명 전체. + * + *

영역별 단락({@code sections})은 직무/트랙/로드맵 영역의 요약 근거를, 항목별 근거({@code jobRationales}/{@code + * trackRationales})는 직무 한 건·트랙 조합 한 건 단위의 개별 근거를 담는다. 항목별 리스트가 비면 영역 단락으로 폴백한다. + */ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) public record Explanation( String text, List sections, + List jobRationales, + List trackRationales, List semesterSubtitles, List courseFlows) {} } 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 5c715d8..1b93012 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 @@ -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; @@ -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); @@ -207,13 +209,15 @@ private static String joinTokens(List 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 rationaleByJobId = jobRationaleIndex(ai.explanation()); + String areaReasoning = explanationBody(ai.explanation(), "jobs"); Set 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( @@ -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 rationaleByComboKey = trackRationaleIndex(ai.explanation()); + String areaReasoning = explanationBody(ai.explanation(), "tracks"); Set seen = new HashSet<>(); if (top != null && top.combo() != null) { - for (AiRecommendResponse.Track aiTrack : pair(top)) { + List 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 = 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)); } } @@ -251,7 +262,9 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra continue; } boolean crossCombination = isCrossCollege(combo); - for (AiRecommendResponse.Track aiTrack : pair(combo)) { + List pair = pair(combo); + for (int i = 0; i < pair.size(); i++) { + AiRecommendResponse.Track aiTrack = pair.get(i); if (secondaryCount >= MAX_SECONDARY_TRACKS) { break; } @@ -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(), @@ -274,6 +288,66 @@ private void addTracks(Recommendation recommendation, AiRecommendResponse ai, Ra } } + // 조합 한 건의 개별 근거에서 트랙 위치(0=트랙A, 1=트랙B)에 해당하는 근거를 고른다. 항목별 근거가 없거나 비면 영역 단락으로 폴백한다. + private static String trackReasoning( + Map 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 jobRationaleIndex(Explanation explanation) { + Map 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 trackRationaleIndex(Explanation explanation) { + Map 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 로 한 번 더 조회한다. // 원본이 이미 매칭되는 트랙은 동작이 바뀌지 않고, 가운뎃점 불일치로 누락되던 트랙만 구제된다. diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/RecommendationReasoningE2ETest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/RecommendationReasoningE2ETest.java new file mode 100644 index 0000000..73ccc02 --- /dev/null +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/RecommendationReasoningE2ETest.java @@ -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, 항목별 근거 제공)를 호출해 직무·트랙 항목마다 서로 다른 근거가 응답에 + * 바인딩되는지 검증한다. + * + *

실제 외부 의존성(로컬 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> 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>() {}); + + // 실 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 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 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; + } + } +} 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 b0e7010..4742a76 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 @@ -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":"기초→응용"}] } @@ -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 트랙 근거"); } } 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 d73f652..200bd0c 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 @@ -23,6 +23,7 @@ import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.ExplanationSection; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.JobCandidate; 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.Roadmap; @@ -30,6 +31,7 @@ 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.TrackCombo; +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.RecommendationStatus; @@ -43,6 +45,7 @@ import com.hansung.tracktory.global.exception.BusinessException; import com.hansung.tracktory.global.exception.ErrorCode; import java.util.List; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -214,6 +217,110 @@ void generate_freshWhenNoActive_callsAiSupersedesSavesAndMaps() { .satisfies(cc -> assertThat(cc.getCourseCode()).isEqualTo("os")); } + @Test + void generate_perItemRationales_boundIndividuallyWithAreaFallback() { + OnboardingProfileSnapshot profile = sampleProfile(); + + AiRecommendResponse.Track bigdata = + new AiRecommendResponse.Track("c", "d", "BIGDATA", "빅데이터트랙"); + AiRecommendResponse.Track web = new AiRecommendResponse.Track("c", "d", "WEB", "웹공학트랙"); + AiRecommendResponse.Track mobile = + new AiRecommendResponse.Track("c", "d", "MOBILE", "모바일소프트웨어트랙"); + RankedCombo primary = + new RankedCombo(new TrackCombo(bigdata, web, "BIGDATA+WEB"), 0.85, "primary", 1); + RankedCombo secondary = + new RankedCombo(new TrackCombo(mobile, bigdata, "MOBILE+BIGDATA"), 0.6, "cross_college", 2); + + // 직무: be_dev/fe_dev 는 개별 근거 보유, de 는 누락 → 영역(jobs) 단락으로 폴백. + // 트랙: 주 조합은 combo/A/B 근거 모두 보유, 보조 조합(MOBILE)은 A 근거 보유. 영역(tracks) 단락은 폴백용. + Explanation explanation = + new Explanation( + "전체 설명", + List.of( + new ExplanationSection("jobs", "직무 영역 폴백"), + new ExplanationSection("tracks", "트랙 영역 폴백")), + List.of( + new JobRationale("be_dev", "백엔드 직무 개별 근거"), + new JobRationale("fe_dev", "프론트 직무 개별 근거")), + List.of( + new TrackRationale("BIGDATA+WEB", "주 조합 시너지 근거", "빅데이터 트랙 근거", "웹공학 트랙 근거"), + new TrackRationale("MOBILE+BIGDATA", "보조 조합 근거", "모바일 트랙 근거", "빅데이터(보조) 근거")), + List.of(), + List.of()); + AiRecommendResponse ai = + new AiRecommendResponse( + List.of( + new JobCandidate("be_dev", "백엔드 개발자", List.of(), List.of(), 0.9, 0.8, false), + new JobCandidate("fe_dev", "프론트 개발자", List.of(), List.of(), 0.8, 0.7, false), + new JobCandidate("de", "데이터 엔지니어", List.of(), List.of(), 0.7, 0.6, false)), + List.of(primary), + List.of(secondary), + null, + null, + explanation); + + given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.of(profile)); + given( + recommendationRepository.findFirstByUser_IdAndStatusOrderByCreatedAtDesc( + USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(Optional.empty()); + given(aiRecommendClient.generate(any())).willReturn(ai); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(sampleUser())); + given(recommendationRepository.findByUser_IdAndStatus(USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(List.of()); + given(jobRepository.findByCode("be_dev")) + .willReturn(Optional.of(Job.builder().code("be_dev").name("백엔드 개발자").build())); + given(jobRepository.findByCode("fe_dev")) + .willReturn(Optional.of(Job.builder().code("fe_dev").name("프론트 개발자").build())); + given(jobRepository.findByCode("de")) + .willReturn(Optional.of(Job.builder().code("de").name("데이터 엔지니어").build())); + given(trackRepository.findByCode("BIGDATA")) + .willReturn(Optional.of(Track.builder().code("BIGDATA").name("빅데이터트랙").build())); + given(trackRepository.findByCode("WEB")) + .willReturn(Optional.of(Track.builder().code("WEB").name("웹공학트랙").build())); + given(trackRepository.findByCode("MOBILE")) + .willReturn(Optional.of(Track.builder().code("MOBILE").name("모바일소프트웨어트랙").build())); + given(recommendationRepository.save(any(Recommendation.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + given(recommendationAssembler.assemble(any(Recommendation.class), eq(profile))) + .willReturn(sentinelResponse()); + + recommendationService.generate(USER_ID, false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Recommendation.class); + verify(recommendationRepository).save(captor.capture()); + Recommendation saved = captor.getValue(); + + // 직무: 항목별 개별 근거가 서로 다르게 바인딩되고, 근거 누락 직무는 영역 단락으로 폴백한다. + Map jobReasonByCode = + saved.getRecommendedJobs().stream() + .collect( + java.util.stream.Collectors.toMap( + j -> j.getJob().getCode(), + com.hansung.tracktory.domain.recommendation.entity.RecommendedJob + ::getReasoning)); + assertThat(jobReasonByCode.get("be_dev")).isEqualTo("백엔드 직무 개별 근거"); + assertThat(jobReasonByCode.get("fe_dev")).isEqualTo("프론트 직무 개별 근거"); + assertThat(jobReasonByCode.get("de")).isEqualTo("직무 영역 폴백"); + assertThat(jobReasonByCode.values()).doesNotHaveDuplicates(); + + // 트랙 조합 전체 근거(시너지)는 개별 트랙 근거와 구분된다. + assertThat(saved.getTrackCombinationReasoning()).isEqualTo("주 조합 시너지 근거"); + + // 트랙: 같은 조합 안에서 트랙별(A/B)로 다른 근거가 바인딩된다. + Map trackReasonByCode = + saved.getRecommendedTracks().stream() + .collect( + java.util.stream.Collectors.toMap( + t -> t.getTrack().getCode(), RecommendedTrack::getReasoning)); + assertThat(trackReasonByCode.get("BIGDATA")).isEqualTo("빅데이터 트랙 근거"); + assertThat(trackReasonByCode.get("WEB")).isEqualTo("웹공학 트랙 근거"); + assertThat(trackReasonByCode.get("MOBILE")).isEqualTo("모바일 트랙 근거"); + assertThat(trackReasonByCode.values()).doesNotHaveDuplicates(); + // 개별 트랙 근거는 조합 전체 근거와도 구분된다. + assertThat(trackReasonByCode.values()).doesNotContain(saved.getTrackCombinationReasoning()); + } + @Test void generate_forceRefresh_bypassesReuseAndCallsAi() { OnboardingProfileSnapshot profile = sampleProfile(); @@ -421,6 +528,7 @@ private static AiRecommendResponse sampleAiResponse() { SemesterPlan plan = new SemesterPlan(5, 3, List.of(course), 3, false, false); Roadmap roadmap = new Roadmap(List.of(stage), List.of(plan), "BIGDATA+WEB"); + // 항목별 근거(job_rationales/track_rationales)를 비워 영역 단락 폴백 경로를 검증한다. Explanation explanation = new Explanation( "전체 설명", @@ -429,6 +537,8 @@ private static AiRecommendResponse sampleAiResponse() { new ExplanationSection("tracks", "트랙 설명"), new ExplanationSection("roadmap", "로드맵 설명")), List.of(), + List.of(), + List.of(), List.of()); CoverageAnalysis coverage =