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