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,8 @@
package com.hansung.tracktory.domain.catalog.curriculum.repository;

import com.hansung.tracktory.domain.catalog.curriculum.entity.Subject;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

Expand All @@ -10,4 +12,6 @@ public interface SubjectRepository extends JpaRepository<Subject, Long> {
Optional<Subject> findByName(String name);

boolean existsByCode(String code);

List<Subject> findByCodeIn(Collection<String> codes);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ public interface TrackSubjectRepository extends JpaRepository<TrackSubject, Long
List<TrackSubject> findBySubjectIn(Collection<Subject> subjects);

List<TrackSubject> findByTrackInAndType(Collection<Track> tracks, SubjectType type);

List<TrackSubject> findByTrackIn(Collection<Track> tracks);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public record AiRecommendResponse(
List<RankedCombo> primaryCombos,
List<RankedCombo> secondaryCombos,
Roadmap roadmap,
CoverageAnalysis coverageAnalysis,
Explanation explanation) {

/** 추천 직무 단건. match_score 는 [0,1] 적합도. */
Expand Down Expand Up @@ -73,6 +74,50 @@ public record SemesterPlan(
public record Roadmap(
List<RoadmapStage> stages, List<SemesterPlan> semesters, String derivedFromComboKey) {}

/** 분야(추천 직무)별 현재/예상 역량 충족도. ratio 는 [0,1], count 는 표기 정합 후 토큰 수. */
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record JobCoverage(
String jobId,
String jobName,
int requiredCount,
int currentCovered,
int expectedCovered,
double currentRatio,
double expectedRatio,
List<String> missingTokens) {}

/** 잔여(추천) 과목 한 건이 충족도에 더하는 독립 한계 기여. contribution_ratio 는 그 과목 단독 이수 시 증가분. */
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record CourseCoverageContribution(
String courseId, String courseName, List<String> addedTokens, double contributionRatio) {}

/** 추천 기반 다음 액션 — 충족도를 가장 많이 올리는 과목 제안. */
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record NextActionSuggestion(
String courseId, String courseName, double contributionRatio, String message) {}

/**
* 추천 직무 요구 역량 대비 현재 → 예상 충족도 분석. next_actions_covered 는 다음 액션 shortlist 까지 이수 시 덮는 토큰의 합집합 수로,
* {@code current_covered <= next_actions_covered <= expected_covered} 를 만족한다.
*/
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record CoverageAnalysis(
int requiredCount,
int currentCovered,
int expectedCovered,
double currentRatio,
double expectedRatio,
int nextActionsCovered,
double nextActionsRatio,
List<JobCoverage> jobs,
List<CourseCoverageContribution> courseContributions,
List<NextActionSuggestion> nextActions,
List<String> gapTokens) {}

/** LLM 설명의 영역별 단락 — topic: jobs/tracks/roadmap. */
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
package com.hansung.tracktory.domain.recommendation.controller;

import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse;
import com.hansung.tracktory.domain.recommendation.dto.RecommendationResponse;
import com.hansung.tracktory.domain.recommendation.service.RecommendationReportService;
import com.hansung.tracktory.domain.recommendation.service.RecommendationService;
import com.hansung.tracktory.domain.user.service.UserPrincipal;
import com.hansung.tracktory.global.response.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
* 추천 생성 API — 인증된 사용자의 온보딩 데이터로 직무·트랙·로드맵 추천을 한 번에 생성한다.
* 추천 생성·조회 API — 인증된 사용자의 온보딩 데이터로 직무·트랙·로드맵 추천을 생성하고, 활성 추천 기준 상세 분석 리포트를 조회한다.
*
* <p>온보딩 미완료 사용자는 {@code ONBOARDING_NOT_FOUND}(404) 로 거절된다. {@code forceRefresh=true} 면 기존 활성 추천을
* 무시하고 새로 생성한다.
Expand All @@ -24,6 +27,7 @@
public class RecommendationController {

private final RecommendationService recommendationService;
private final RecommendationReportService recommendationReportService;

@PostMapping
public ResponseEntity<ApiResponse<RecommendationResponse>> generate(
Expand All @@ -33,4 +37,19 @@ public ResponseEntity<ApiResponse<RecommendationResponse>> generate(
recommendationService.generate(principal.getUserId(), forceRefresh);
return ResponseEntity.ok(ApiResponse.ok(response));
}

/**
* 상세 분석 리포트 조회 — 활성 추천을 기준으로 기준 직무·역량 충족도·분야별 분석·잔여 과목·다음 액션·집계 수치를 반환한다.
*
* <p>{@code anchorJobCode} 로 충족도 기준 직무를 지정할 수 있다. 생략하면 매칭 1순위 직무가 기준이 되고, 추천 직무 목록에 없는 코드는 {@code
* INVALID_ANCHOR_JOB}(400) 로 거절된다. 활성 추천이 없으면 {@code RECOMMENDATION_NOT_FOUND}(404) 로 거절된다.
*/
@GetMapping("/report")
public ResponseEntity<ApiResponse<AnalysisReportResponse>> getReport(
@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(name = "anchorJobCode", required = false) String anchorJobCode) {
AnalysisReportResponse response =
recommendationReportService.getReport(principal.getUserId(), anchorJobCode);
return ResponseEntity.ok(ApiResponse.ok(response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.hansung.tracktory.domain.recommendation.dto;

import java.util.List;

/**
* 상세 분석 리포트 응답 — 활성 추천을 기준으로 기준 직무·역량 충족도·분야별 분석·잔여 과목·다음 액션·집계 수치를 한 묶음으로 전달한다.
*
* <p>역량 충족도(현재/예상)와 분야별 분석은 추천 생성 시점에 AI 가 산출해 보존한 스냅샷에서 나오고, 잔여 과목·이수 수·취득 학점은 카탈로그와 이수 이력으로 조회
* 시점에 계산한다. 모두 같은 활성 추천을 기준으로 하므로 홈 추천 결과와 정합한다.
*
* <p>{@code anchorJob} 은 충족도(현재/다음 N개/전체)가 어느 직무를 기준으로 산출됐는지 알려주는 단일 기준 직무로, 화면의 "○○ 직무 기준" 라벨을
* 채운다. 기본값은 매칭 1순위 직무이며, 조회 시 다른 추천 직무를 기준으로 지정하면 충족도가 그 직무 기준으로 재산출된다.
*/
public record AnalysisReportResponse(
Long recommendationId,
AnchorJobView anchorJob,
CoverageView coverage,
AggregateView aggregate,
List<RemainingCourseView> remainingCourses,
List<NextActionView> nextActions) {

/** 충족도 산출 기준 직무 — "○○ 직무 기준" 라벨용. 추천 직무가 없으면 null. */
public record AnchorJobView(String code, String name) {}

/**
* 역량 충족도 — 현재 → 다음 N개 이수 시 → 전체(천장) 3단 + 분야별 분석. 카운트는 {@code current ≤ nextActions ≤ expected} 를
* 만족하고, 백분율은 {@code covered / required} 의 반올림이다(분모 0 이면 0). {@code nextActionsCovered} 는 다음 액션
* 과목까지 이수 시 도달하는 합집합 충족 토큰 수로, 과목별 기여(+%)의 단순 합과 다르다.
*/
public record CoverageView(
int requiredCount,
int currentCovered,
int nextActionsCovered,
int expectedCovered,
int currentPercent,
int nextActionsPercent,
int expectedPercent,
List<String> gapTokens,
List<FieldCoverageView> fields) {}

/** 분야(추천 직무)별 현재/예상 충족도. */
public record FieldCoverageView(
String jobCode,
String jobName,
int requiredCount,
int currentCovered,
int expectedCovered,
int currentPercent,
int expectedPercent,
List<String> missingTokens) {}

/** 이수 과목 수·취득 학점 집계. */
public record AggregateView(int completedCourseCount, double earnedCredits) {}

/**
* 잔여(미이수) 과목 한 건. {@code type} 으로 전공필수/전공선택/전공기초를 구분해 필수 잔여만 추릴 수 있다. {@code tracks} 는 이 과목이 기여하는
* 추천 트랙명 목록(한 과목이 여러 트랙에 걸칠 수 있음), {@code contributionPercent} 는 이 과목 단독 이수 시 역량 충족도 증가분(없으면
* null).
*/
public record RemainingCourseView(
String code,
String name,
double credit,
String type,
String stage,
List<String> tracks,
Integer contributionPercent) {}

/** 추천 기반 다음 액션 — 충족도를 가장 많이 올리는 과목 제안. */
public record NextActionView(String code, String name, int contributionPercent, String message) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ public class Recommendation extends BaseEntity {
fetch = FetchType.LAZY)
private Roadmap roadmap;

@OneToOne(
mappedBy = "recommendation",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
private RecommendationCoverage coverage;

@Builder
public Recommendation(
User user,
Expand Down Expand Up @@ -101,6 +108,12 @@ public void attachRoadmap(Roadmap newRoadmap) {
newRoadmap.assignTo(this);
}

/** 역량 충족도 분석 1:1 자식을 연결하고 양방향 연관을 맞춘다. */
public void attachCoverage(RecommendationCoverage newCoverage) {
this.coverage = newCoverage;
newCoverage.assignTo(this);
}

/** 새 추천으로 대체되었음을 표시한다 (메인 노출 제외, 이력으로만 조회). */
public void markSuperseded() {
this.status = RecommendationStatus.SUPERSEDED;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.hansung.tracktory.domain.recommendation.entity;

import com.hansung.tracktory.global.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* 잔여(추천) 과목 한 건의 충족도 기여 — coverage 분석의 자식. {@code contributionPercent} 는 그 과목을 단독으로 이수했을 때의 독립 한계
* 증가분(0~100). 과목 간 토큰이 겹칠 수 있어 기여도 합은 누적 도달과 다르므로 "이 과목 가치(+X%)" 배지로만 쓴다.
*/
@Entity
@Table(name = "recommendation_course_contribution")
@Getter
@NoArgsConstructor
public class RecommendationCourseContribution extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "coverage_id", nullable = false)
private RecommendationCoverage coverage;

@Column(name = "course_code", nullable = false)
private String courseCode;

@Column(name = "course_name", nullable = false)
private String courseName;

@Column(name = "contribution_percent", nullable = false)
private int contributionPercent;

@Builder
public RecommendationCourseContribution(
String courseCode, String courseName, int contributionPercent) {
this.courseCode = courseCode;
this.courseName = courseName;
this.contributionPercent = contributionPercent;
}

void assignTo(RecommendationCoverage owner) {
this.coverage = owner;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.hansung.tracktory.domain.recommendation.entity;

import com.hansung.tracktory.global.entity.BaseEntity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* 추천 직무 요구 역량 대비 현재 → 예상 충족도 분석 — recommendation 과 1:1. AI 추천 응답이 추천 생성 시점에 산출한 스냅샷을 그대로 보존해, 상세 분석
* 리포트가 홈 추천과 같은 기준으로 조회되도록 한다.
*
* <p>비율은 저장하지 않고 카운트({@code coveredCount / requiredCount})로 조회 시점에 파생한다. 세 카운트가 {@code
* currentCovered ≤ nextActionsCovered ≤ expectedCovered} 를 만족하므로 화면의 3단(현재 → 다음 N개 → 전체) 표기 불변이
* 보장된다. {@code nextActionsCovered} 는 다음 액션 shortlist 까지 이수 시 덮는 토큰의 합집합 수로, AI 가 계산해 내려준 값을 그대로
* 보존한다(중복 토큰 이중 계산 없음).
*/
@Entity
@Table(name = "recommendation_coverage")
@Getter
@NoArgsConstructor
public class RecommendationCoverage extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recommendation_id", nullable = false, unique = true)
private Recommendation recommendation;

@Column(name = "required_count", nullable = false)
private int requiredCount;

@Column(name = "current_covered", nullable = false)
private int currentCovered;

@Column(name = "next_actions_covered", nullable = false)
private int nextActionsCovered;

@Column(name = "expected_covered", nullable = false)
private int expectedCovered;

@Column(name = "gap_tokens", columnDefinition = "text")
private String gapTokens;

@OneToMany(mappedBy = "coverage", cascade = CascadeType.ALL, orphanRemoval = true)
private List<RecommendationJobCoverage> jobCoverages = new ArrayList<>();

@OneToMany(mappedBy = "coverage", cascade = CascadeType.ALL, orphanRemoval = true)
private List<RecommendationCourseContribution> courseContributions = new ArrayList<>();

@OneToMany(mappedBy = "coverage", cascade = CascadeType.ALL, orphanRemoval = true)
private List<RecommendationNextAction> nextActions = new ArrayList<>();

@Builder
public RecommendationCoverage(
int requiredCount,
int currentCovered,
int nextActionsCovered,
int expectedCovered,
String gapTokens) {
this.requiredCount = requiredCount;
this.currentCovered = currentCovered;
this.nextActionsCovered = nextActionsCovered;
this.expectedCovered = expectedCovered;
this.gapTokens = gapTokens;
}

void assignTo(Recommendation owner) {
this.recommendation = owner;
}

/** 분야(직무)별 충족도 자식을 추가하고 양방향 연관을 맞춘다. */
public void addJobCoverage(RecommendationJobCoverage jobCoverage) {
jobCoverages.add(jobCoverage);
jobCoverage.assignTo(this);
}

/** 과목별 기여 자식을 추가하고 양방향 연관을 맞춘다. */
public void addCourseContribution(RecommendationCourseContribution contribution) {
courseContributions.add(contribution);
contribution.assignTo(this);
}

/** 다음 액션 자식을 추가하고 양방향 연관을 맞춘다. */
public void addNextAction(RecommendationNextAction nextAction) {
nextActions.add(nextAction);
nextAction.assignTo(this);
}
}
Loading
Loading