From 3ba9ac3b96df47db8042bc151bfa298d84289806 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <50106190+jwon0523@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:37:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=EC=A7=81=EB=AC=B4=20=EB=B8=8C?= =?UTF-8?q?=EB=A6=AC=ED=95=91=20=EC=A4=91=EA=B3=84=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/briefing/ai/AiBriefingRequest.java | 14 ++ .../briefing/ai/AiBriefingResponse.java | 34 +++++ .../domain/briefing/ai/BriefingClient.java | 62 ++++++++ .../controller/BriefingController.java | 32 +++++ .../domain/briefing/dto/BriefingResponse.java | 27 ++++ .../briefing/service/BriefingService.java | 93 ++++++++++++ .../ai/AiBriefingEnvelopeContractTest.java | 76 ++++++++++ .../briefing/service/BriefingServiceTest.java | 132 ++++++++++++++++++ 8 files changed, 470 insertions(+) create mode 100644 src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingRequest.java create mode 100644 src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingResponse.java create mode 100644 src/main/java/com/hansung/tracktory/domain/briefing/ai/BriefingClient.java create mode 100644 src/main/java/com/hansung/tracktory/domain/briefing/controller/BriefingController.java create mode 100644 src/main/java/com/hansung/tracktory/domain/briefing/dto/BriefingResponse.java create mode 100644 src/main/java/com/hansung/tracktory/domain/briefing/service/BriefingService.java create mode 100644 src/test/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingEnvelopeContractTest.java create mode 100644 src/test/java/com/hansung/tracktory/domain/briefing/service/BriefingServiceTest.java diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingRequest.java b/src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingRequest.java new file mode 100644 index 0000000..7aeb0e6 --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingRequest.java @@ -0,0 +1,14 @@ +package com.hansung.tracktory.domain.briefing.ai; + +import java.util.List; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +/** + * AI 중계 서버의 직무 브리핑 요청 바디 — FastAPI BriefingRequest 를 mirror 한다. + * + *

브리핑 표면은 추천 입력 전체가 아니라 직무 식별자만 받는다. camelCase 자바 필드 {@code jobIds} 는 snake_case JSON {@code + * job_ids} 로 직렬화된다. FastAPI 계약상 최소 1개이며 각 코드는 공백 제거 후 1자 이상이어야 하므로, 빈 목록을 보내지 않는 책임은 호출 서비스에 있다. + */ +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record AiBriefingRequest(List jobIds) {} diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingResponse.java b/src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingResponse.java new file mode 100644 index 0000000..0e7d97e --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingResponse.java @@ -0,0 +1,34 @@ +package com.hansung.tracktory.domain.briefing.ai; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +/** + * AI 중계 서버 직무 브리핑 응답의 data 본문 — FastAPI BriefingResponse 와 중첩 모델(JobBriefing/BriefingSource)을 + * mirror 한다. 모든 레코드는 snake_case JSON 매핑 + 알 수 없는 필드 무시를 적용한다. + */ +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public record AiBriefingResponse(List briefings) { + + /** + * 추천 직무 한 건에 연결된 트렌드 브리핑 카드. {@code jobId} 는 직무 카탈로그 표준 코드(추천 직무 식별자와 정합), {@code sources} 는 검증 + * 가능한 출처(FastAPI 가 최소 1개를 보장). + */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Briefing( + String jobId, + String jobName, + String headline, + String summary, + List skills, + List sources) {} + + /** 브리핑 근거 출처. {@code publishedAt} 은 발행 시점(YYYY 또는 YYYY-MM, 미상이면 null). */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Source(String title, String url, String publishedAt) {} +} diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/ai/BriefingClient.java b/src/main/java/com/hansung/tracktory/domain/briefing/ai/BriefingClient.java new file mode 100644 index 0000000..c124a12 --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/briefing/ai/BriefingClient.java @@ -0,0 +1,62 @@ +package com.hansung.tracktory.domain.briefing.ai; + +import com.hansung.tracktory.domain.recommendation.ai.AiEnvelope; +import com.hansung.tracktory.global.exception.BusinessException; +import com.hansung.tracktory.global.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientException; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +/** + * AI 중계 서버(FastAPI)의 직무 브리핑 엔드포인트를 호출하는 클라이언트. + * + *

내부 인증 헤더(X-Internal-Token)·사용자 식별 헤더(X-User-Id)는 {@code aiRelayWebClient} 빈의 필터가 자동 부착하므로 여기서 + * 설정하지 않는다. 필터가 호출 스레드의 SecurityContext 를 읽으므로 인증된 요청 스레드에서 블로킹 호출해야 한다. + */ +@Component +public class BriefingClient { + + private static final String BRIEFING_PATH = "/api/v1/ai/briefing"; + private static final Logger log = LoggerFactory.getLogger(BriefingClient.class); + + private final WebClient aiRelayWebClient; + + public BriefingClient(@Qualifier("aiRelayWebClient") WebClient aiRelayWebClient) { + this.aiRelayWebClient = aiRelayWebClient; + } + + /** + * 추천 직무 코드 목록으로 큐레이션 브리핑 카드를 요청한다. + * + * @throws BusinessException AI 서버 호출 실패 또는 비정상 응답 시 ({@link ErrorCode#AI_RELAY_ERROR}). + */ + public AiBriefingResponse fetch(AiBriefingRequest request) { + AiEnvelope envelope; + try { + envelope = + aiRelayWebClient + .post() + .uri(BRIEFING_PATH) + .bodyValue(request) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + } catch (WebClientResponseException e) { + log.warn("AI relay 응답 오류: status={} body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new BusinessException(ErrorCode.AI_RELAY_ERROR); + } catch (WebClientException e) { + log.warn("AI relay 호출 실패", e); + throw new BusinessException(ErrorCode.AI_RELAY_ERROR); + } + + if (envelope == null || !envelope.success() || envelope.data() == null) { + throw new BusinessException(ErrorCode.AI_RELAY_ERROR); + } + return envelope.data(); + } +} diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/controller/BriefingController.java b/src/main/java/com/hansung/tracktory/domain/briefing/controller/BriefingController.java new file mode 100644 index 0000000..274988d --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/briefing/controller/BriefingController.java @@ -0,0 +1,32 @@ +package com.hansung.tracktory.domain.briefing.controller; + +import com.hansung.tracktory.domain.briefing.dto.BriefingResponse; +import com.hansung.tracktory.domain.briefing.service.BriefingService; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 직무 브리핑 조회 API — 인증된 사용자의 활성 추천 직무에 연결된 트렌드 브리핑 카드를 반환한다. + * + *

앱은 AI 서버를 직접 호출하지 않고 본 백엔드를 경유한다. 활성 추천이 없으면 {@code RECOMMENDATION_NOT_FOUND}(404) 로 거절된다. + */ +@RestController +@RequestMapping("/api/v1/briefings") +@RequiredArgsConstructor +public class BriefingController { + + private final BriefingService briefingService; + + @GetMapping + public ResponseEntity> getBriefings( + @AuthenticationPrincipal UserPrincipal principal) { + BriefingResponse response = briefingService.getBriefings(principal.getUserId()); + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/dto/BriefingResponse.java b/src/main/java/com/hansung/tracktory/domain/briefing/dto/BriefingResponse.java new file mode 100644 index 0000000..bcace36 --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/briefing/dto/BriefingResponse.java @@ -0,0 +1,27 @@ +package com.hansung.tracktory.domain.briefing.dto; + +import java.util.List; + +/** + * 직무 브리핑 조회 API 의 프론트엔드 응답 — 활성 추천 직무에 연결된 트렌드 카드 묶음을 전달한다. + * + *

홈 브리핑 시트가 소비하며, 카드는 추천 직무 순서(적합도 내림차순)를 따른다. 큐레이션이 없는 직무는 빠지므로 카드 수가 추천 직무 수보다 적을 수 있고, 매칭이 + * 하나도 없으면 빈 리스트다. + */ +public record BriefingResponse(List briefings) { + + /** + * 직무 한 건의 트렌드 브리핑 카드. {@code code} 는 직무 카탈로그 표준 코드(추천 직무 식별자와 정합), {@code skills} 는 카드가 강조하는 필수 + * 역량·기술 키워드(없으면 빈 리스트), {@code sources} 는 검증 가능한 출처(최소 1개). + */ + public record BriefingCardView( + String code, + String name, + String headline, + String summary, + List skills, + List sources) {} + + /** 브리핑 근거 출처. {@code publishedAt} 은 발행 시점(YYYY 또는 YYYY-MM), 미상이면 null. */ + public record SourceView(String title, String url, String publishedAt) {} +} diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/service/BriefingService.java b/src/main/java/com/hansung/tracktory/domain/briefing/service/BriefingService.java new file mode 100644 index 0000000..f7e3c36 --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/briefing/service/BriefingService.java @@ -0,0 +1,93 @@ +package com.hansung.tracktory.domain.briefing.service; + +import com.hansung.tracktory.domain.briefing.ai.AiBriefingRequest; +import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse; +import com.hansung.tracktory.domain.briefing.ai.BriefingClient; +import com.hansung.tracktory.domain.briefing.dto.BriefingResponse; +import com.hansung.tracktory.domain.briefing.dto.BriefingResponse.BriefingCardView; +import com.hansung.tracktory.domain.briefing.dto.BriefingResponse.SourceView; +import com.hansung.tracktory.domain.recommendation.entity.Recommendation; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationStatus; +import com.hansung.tracktory.domain.recommendation.entity.RecommendedJob; +import com.hansung.tracktory.domain.recommendation.repository.RecommendationRepository; +import com.hansung.tracktory.global.exception.BusinessException; +import com.hansung.tracktory.global.exception.ErrorCode; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 직무 브리핑 중계 — 사용자의 활성 추천 직무를 기준으로 AI 중계 서버의 큐레이션 브리핑을 조회한다. + * + *

추천 재계산 없이 홈 추천과 같은 활성 추천 aggregate 를 읽어 직무 코드만 추출하므로 홈 결과와 정합한다. 활성 추천이 없으면 {@code + * RECOMMENDATION_NOT_FOUND}(404) 로 거절해 새 추천 생성을 유도한다. 브리핑 카드는 AI 서버가 큐레이션 카탈로그에서 직접 채우며, 본 백엔드는 추천 + * 직무 코드 전달과 응답 매핑만 담당한다. + */ +@Service +@RequiredArgsConstructor +public class BriefingService { + + private final RecommendationRepository recommendationRepository; + private final BriefingClient briefingClient; + + /** + * 활성 추천 직무 코드로 브리핑 카드를 조회한다. 직무 코드는 적합도(score) 내림차순으로 정렬하고 중복을 제거해 카드 순서가 추천 직무 순서를 따르게 한다. + * + *

추천 직무가 하나도 없으면 AI 가 빈 {@code job_ids} 를 422 로 거절하므로, 호출하지 않고 빈 결과를 반환한다(부분 충족 허용). 큐레이션이 없는 + * 직무는 AI 응답에서 빠져 카드 수가 추천 직무 수보다 적을 수 있다. + */ + @Transactional(readOnly = true) + public BriefingResponse getBriefings(Long userId) { + Recommendation active = + recommendationRepository + .findFirstByUser_IdAndStatusOrderByCreatedAtDesc(userId, RecommendationStatus.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_NOT_FOUND)); + + List jobCodes = jobCodesByScoreDesc(active); + if (jobCodes.isEmpty()) { + return new BriefingResponse(List.of()); + } + + AiBriefingResponse aiResponse = briefingClient.fetch(new AiBriefingRequest(jobCodes)); + return toResponse(aiResponse); + } + + // 추천 직무 코드를 적합도 내림차순 + 등장 순 중복 제거로 추출한다. 같은 코드로 fold 된 중복 직무가 같은 브리핑을 두 번 요청하지 않도록 한다. + private static List jobCodesByScoreDesc(Recommendation recommendation) { + Set ordered = new LinkedHashSet<>(); + recommendation.getRecommendedJobs().stream() + .filter(job -> job.getJob() != null && job.getJob().getCode() != null) + .sorted(Comparator.comparingInt(RecommendedJob::getScore).reversed()) + .map(job -> job.getJob().getCode()) + .forEach(ordered::add); + return List.copyOf(ordered); + } + + private static BriefingResponse toResponse(AiBriefingResponse aiResponse) { + List cards = + nullSafe(aiResponse.briefings()).stream().map(BriefingService::toCard).toList(); + return new BriefingResponse(cards); + } + + private static BriefingCardView toCard(AiBriefingResponse.Briefing briefing) { + return new BriefingCardView( + briefing.jobId(), + briefing.jobName(), + briefing.headline(), + briefing.summary(), + nullSafe(briefing.skills()), + nullSafe(briefing.sources()).stream().map(BriefingService::toSource).toList()); + } + + private static SourceView toSource(AiBriefingResponse.Source source) { + return new SourceView(source.title(), source.url(), source.publishedAt()); + } + + private static List nullSafe(List list) { + return list == null ? List.of() : list; + } +} diff --git a/src/test/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingEnvelopeContractTest.java b/src/test/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingEnvelopeContractTest.java new file mode 100644 index 0000000..006fe9f --- /dev/null +++ b/src/test/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingEnvelopeContractTest.java @@ -0,0 +1,76 @@ +package com.hansung.tracktory.domain.briefing.ai; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse.Briefing; +import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse.Source; +import com.hansung.tracktory.domain.recommendation.ai.AiEnvelope; +import org.junit.jupiter.api.Test; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.json.JsonMapper; + +/** + * AI 중계 서버(FastAPI) 브리핑 응답 계약의 역직렬화 회귀 가드. + * + *

실 FastAPI 페이로드 형태({@code {success, data:{briefings:[...]}}} envelope + 카드의 + * job_id/headline/summary + 출처의 published_at)가 DTO 로 손실 없이 매핑되는지 검증한다. WebClient 도 DTO 자체 어노테이션만으로 + * 동일하게 매핑하므로 기본 매퍼로 충분하다. + */ +class AiBriefingEnvelopeContractTest { + + private static final JsonMapper MAPPER = JsonMapper.builder().build(); + + @Test + void deserializesRealFastApiBriefingEnvelopeWithoutFieldLoss() { + String json = + """ + { + "success": true, + "data": { + "briefings": [ + { + "job_id": "BE", + "job_name": "백엔드 개발자", + "headline": "클라우드 네이티브 백엔드 수요 증가", + "summary": "컨테이너·MSA 역량이 채용에서 점점 더 중요해지고 있다.", + "skills": ["Spring Boot", "Kubernetes", "AWS"], + "sources": [ + {"title": "Stack Overflow Developer Survey 2024", + "url": "https://survey.stackoverflow.co/2024", + "published_at": "2024"}, + {"title": "JetBrains State of Developer Ecosystem", + "url": "https://www.jetbrains.com/lp/devecosystem-2023", + "published_at": null} + ] + } + ] + }, + "error": null + } + """; + + AiEnvelope envelope = + MAPPER.readValue(json, new TypeReference>() {}); + + assertThat(envelope.success()).isTrue(); + AiBriefingResponse data = envelope.data(); + assertThat(data).isNotNull(); + assertThat(data.briefings()).hasSize(1); + + Briefing card = data.briefings().get(0); + assertThat(card.jobId()).isEqualTo("BE"); + assertThat(card.jobName()).isEqualTo("백엔드 개발자"); + assertThat(card.headline()).isEqualTo("클라우드 네이티브 백엔드 수요 증가"); + assertThat(card.summary()).isNotBlank(); + assertThat(card.skills()).containsExactly("Spring Boot", "Kubernetes", "AWS"); + assertThat(card.sources()).hasSize(2); + + Source dated = card.sources().get(0); + assertThat(dated.title()).isEqualTo("Stack Overflow Developer Survey 2024"); + assertThat(dated.url()).isEqualTo("https://survey.stackoverflow.co/2024"); + assertThat(dated.publishedAt()).isEqualTo("2024"); + + // published_at 미상(null)도 손실 없이 매핑되어야 한다. + assertThat(card.sources().get(1).publishedAt()).isNull(); + } +} diff --git a/src/test/java/com/hansung/tracktory/domain/briefing/service/BriefingServiceTest.java b/src/test/java/com/hansung/tracktory/domain/briefing/service/BriefingServiceTest.java new file mode 100644 index 0000000..c6dbba6 --- /dev/null +++ b/src/test/java/com/hansung/tracktory/domain/briefing/service/BriefingServiceTest.java @@ -0,0 +1,132 @@ +package com.hansung.tracktory.domain.briefing.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.hansung.tracktory.domain.briefing.ai.AiBriefingRequest; +import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse; +import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse.Briefing; +import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse.Source; +import com.hansung.tracktory.domain.briefing.ai.BriefingClient; +import com.hansung.tracktory.domain.briefing.dto.BriefingResponse; +import com.hansung.tracktory.domain.catalog.career.entity.Job; +import com.hansung.tracktory.domain.recommendation.entity.Recommendation; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationStatus; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationTriggerSource; +import com.hansung.tracktory.domain.recommendation.entity.RecommendedJob; +import com.hansung.tracktory.domain.recommendation.repository.RecommendationRepository; +import com.hansung.tracktory.global.exception.BusinessException; +import com.hansung.tracktory.global.exception.ErrorCode; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BriefingServiceTest { + + private static final long USER_ID = 1L; + + @InjectMocks private BriefingService service; + + @Mock private RecommendationRepository recommendationRepository; + @Mock private BriefingClient briefingClient; + + @Test + void getBriefings_noActiveRecommendation_throwsRecommendationNotFound() { + given( + recommendationRepository.findFirstByUser_IdAndStatusOrderByCreatedAtDesc( + USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getBriefings(USER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.RECOMMENDATION_NOT_FOUND); + + verify(briefingClient, never()).fetch(any()); + } + + @Test + void getBriefings_noRecommendedJobs_returnsEmptyWithoutCallingClient() { + given( + recommendationRepository.findFirstByUser_IdAndStatusOrderByCreatedAtDesc( + USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(Optional.of(activeRecommendation())); + + BriefingResponse response = service.getBriefings(USER_ID); + + assertThat(response.briefings()).isEmpty(); + verify(briefingClient, never()).fetch(any()); + } + + @Test + void getBriefings_passesJobCodesByScoreDescDedup_andMapsResponse() { + Recommendation recommendation = activeRecommendation(); + recommendation.addRecommendedJob(recommendedJob("BE", "백엔드 개발자", 70)); + recommendation.addRecommendedJob(recommendedJob("AI", "AI/ML 엔지니어", 90)); + recommendation.addRecommendedJob(recommendedJob("FE", "프론트엔드 개발자", 80)); + recommendation.addRecommendedJob(recommendedJob("AI", "AI/ML 엔지니어", 60)); // 같은 코드 중복 + given( + recommendationRepository.findFirstByUser_IdAndStatusOrderByCreatedAtDesc( + USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(Optional.of(recommendation)); + + given(briefingClient.fetch(any())) + .willReturn( + new AiBriefingResponse( + List.of( + new Briefing( + "AI", + "AI/ML 엔지니어", + "LLM 응용 수요 급증", + "RAG·파인튜닝 역량이 핵심으로 떠오른다.", + List.of("Python", "PyTorch"), + List.of(new Source("AI Index Report", "https://aiindex.org", "2024")))))); + + BriefingResponse response = service.getBriefings(USER_ID); + + // 적합도 내림차순 + 등장 순 중복 제거: AI(90) > FE(80) > BE(70), 중복 AI 는 한 번만. + ArgumentCaptor captor = ArgumentCaptor.forClass(AiBriefingRequest.class); + verify(briefingClient).fetch(captor.capture()); + assertThat(captor.getValue().jobIds()).containsExactly("AI", "FE", "BE"); + + assertThat(response.briefings()).hasSize(1); + BriefingResponse.BriefingCardView card = response.briefings().get(0); + assertThat(card.code()).isEqualTo("AI"); + assertThat(card.name()).isEqualTo("AI/ML 엔지니어"); + assertThat(card.headline()).isEqualTo("LLM 응용 수요 급증"); + assertThat(card.skills()).containsExactly("Python", "PyTorch"); + assertThat(card.sources()).hasSize(1); + assertThat(card.sources().get(0).title()).isEqualTo("AI Index Report"); + assertThat(card.sources().get(0).url()).isEqualTo("https://aiindex.org"); + assertThat(card.sources().get(0).publishedAt()).isEqualTo("2024"); + } + + // ------------------------------ helpers ------------------------------ + + private static Recommendation activeRecommendation() { + return Recommendation.builder() + .status(RecommendationStatus.ACTIVE) + .triggerSource(RecommendationTriggerSource.MANUAL) + .build(); + } + + private static RecommendedJob recommendedJob(String code, String name, int score) { + Job job = Job.builder().code(code).name(name).description("설명").build(); + return RecommendedJob.builder() + .score(score) + .reasoning("근거") + .competencyTags(List.of()) + .job(job) + .build(); + } +}