From f697961142049b7b0e729aa1517866719a23ca76 Mon Sep 17 00:00:00 2001
From: Jaewon Lee <50106190+jwon0523@users.noreply.github.com>
Date: Fri, 5 Jun 2026 03:37:33 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=20=EC=B6=94=EC=B2=9C=20=EA=B7=BC?=
=?UTF-8?q?=EA=B1=B0=EB=A5=BC=20=EC=A7=81=EB=AC=B4=C2=B7=ED=8A=B8=EB=9E=99?=
=?UTF-8?q?=20=ED=95=AD=EB=AA=A9=EB=B3=84=EB=A1=9C=20=EC=9D=91=EB=8B=B5?=
=?UTF-8?q?=EC=97=90=20=EB=A7=A4=ED=95=91=20(#63)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ai/AiRecommendResponse.java | 26 +++-
.../service/RecommendationService.java | 94 ++++++++++--
.../RecommendationReasoningE2ETest.java | 139 ++++++++++++++++++
.../ai/AiEnvelopeContractTest.java | 16 ++
.../service/RecommendationServiceTest.java | 110 ++++++++++++++
5 files changed, 374 insertions(+), 11 deletions(-)
create mode 100644 src/test/java/com/hansung/tracktory/domain/recommendation/RecommendationReasoningE2ETest.java
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