From 601600b8b358232475920364127b9524f6887638 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Wed, 21 Oct 2026 01:02:19 +0900 Subject: [PATCH] fix: rotate eligible styles on forced recommendation refresh --- docs/hairstyle-catalog.md | 6 +- .../dto/GenerateRecommendationRequest.java | 3 + .../RecommendationPersistenceAdapter.java | 8 +++ .../RecommendationRunJpaRepository.java | 21 ++++++ .../service/RecommendationService.java | 4 +- .../out/RecommendationRepositoryPort.java | 4 ++ .../service/RecommendationDiversifier.java | 22 ++++++- .../RecommendationApiIntegrationTest.java | 65 +++++++++++++++++++ .../RecommendationDiversifierTest.java | 24 +++++++ 9 files changed, 152 insertions(+), 5 deletions(-) diff --git a/docs/hairstyle-catalog.md b/docs/hairstyle-catalog.md index f330f389..56a01bdb 100644 --- a/docs/hairstyle-catalog.md +++ b/docs/hairstyle-catalog.md @@ -20,7 +20,11 @@ 최초 실행이 없으면 404, 적합한 후보가 없으면 422를 반환한다. - 카탈로그 호환성·점수·정렬 조건도 입력 해시에 포함한다. 운영 메타데이터 버전을 빠뜨린 수정도 이전 결과를 재사용하지 않는다. `/recommendations/{id}`는 과거 실행 조회를 유지한다. -- `force_refresh=true`는 새 실행을 만들지만 같은 입력의 순위를 무작위로 섞지는 않는다. +- `force_refresh=true`는 새 실행을 만들고, 동일 사용자·입력의 최근 20회 추천에서 + 노출 횟수가 적은 적합한 스타일을 우선 선택한다. 같은 노출 횟수에서는 기존 점수와 + 카테고리·태그 다양화 규칙을 유지한다. 길이·손상도·제외 선호 필터는 완화하지 않는다. + 적합한 후보가 3개 이하면 이전 스타일이 반복될 수 있다. + 일반 생성과 `/recommendations/latest`는 새로고침으로 만든 최신 결과를 재사용한다. ## 추천 → AR 연결 (2026-10-20) diff --git a/src/main/java/com/heddy/adapter/in/web/recommendation/dto/GenerateRecommendationRequest.java b/src/main/java/com/heddy/adapter/in/web/recommendation/dto/GenerateRecommendationRequest.java index 2c8b731c..d833dc6e 100644 --- a/src/main/java/com/heddy/adapter/in/web/recommendation/dto/GenerateRecommendationRequest.java +++ b/src/main/java/com/heddy/adapter/in/web/recommendation/dto/GenerateRecommendationRequest.java @@ -1,8 +1,11 @@ package com.heddy.adapter.in.web.recommendation.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; public record GenerateRecommendationRequest( + @Schema(description = "true이면 새 실행을 만들고 동일 입력의 최근 20회 추천에서 덜 노출된 적합한 스타일을 우선 선택합니다. " + + "후보가 부족하면 이전 스타일이 포함될 수 있습니다.") @JsonProperty("force_refresh") Boolean forceRefresh ) { public boolean shouldForceRefresh() { diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationPersistenceAdapter.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationPersistenceAdapter.java index dd0b10e6..569f95b1 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationPersistenceAdapter.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationPersistenceAdapter.java @@ -54,6 +54,14 @@ public Optional findLatestByUserId(UUID userId) { .map(this::assemble); } + @Override + public Map findRecentExposureCounts(UUID userId, String inputHash) { + return runRepository.findRecentExposureCounts(userId, inputHash).stream() + .collect(java.util.stream.Collectors.toMap( + RecommendationRunJpaRepository.HairstyleExposure::getHairstyleId, + RecommendationRunJpaRepository.HairstyleExposure::getExposureCount)); + } + @Override public Optional findByIdAndUserId(UUID recommendationRunId, UUID userId) { return runRepository.findByRecommendationRunIdAndUserId(recommendationRunId, userId) diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunJpaRepository.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunJpaRepository.java index 1d45591d..1653057a 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunJpaRepository.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunJpaRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.repository.query.Param; import java.util.Optional; +import java.util.List; import java.util.UUID; interface RecommendationRunJpaRepository extends JpaRepository { @@ -14,6 +15,26 @@ Optional findFirstByUserIdAndStrategyAndInputHashAndSta Optional findFirstByUserIdOrderByGeneratedAtDescRecommendationRunIdDesc(UUID userId); Optional findByRecommendationRunIdAndUserId(UUID recommendationRunId, UUID userId); + interface HairstyleExposure { + UUID getHairstyleId(); + Long getExposureCount(); + } + + @Query(value = """ + SELECT item.hairstyle_id AS "hairstyleId", count(*) AS "exposureCount" + FROM recommendation_items item + JOIN ( + SELECT recommendation_run_id + FROM recommendation_runs + WHERE user_id = :userId AND input_hash = :inputHash + ORDER BY generated_at DESC, recommendation_run_id DESC + LIMIT 20 + ) recent ON recent.recommendation_run_id = item.recommendation_run_id + GROUP BY item.hairstyle_id + """, nativeQuery = true) + List findRecentExposureCounts( + @Param("userId") UUID userId, @Param("inputHash") String inputHash); + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query(value = """ UPDATE recommendation_runs SET status = 'STALE' diff --git a/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java b/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java index 2bcd1478..886bd148 100644 --- a/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java +++ b/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java @@ -175,8 +175,10 @@ && canReuseExplanation(reusable, generatedAt)) { } } + Map exposureCounts = forceRefresh + ? recommendationRepositoryPort.findRecentExposureCounts(userId, inputHash) : Map.of(); List selected = diversifier.topThree( - scorer.scoreAll(candidates, context)); + scorer.scoreAll(candidates, context), exposureCounts); if (selected.isEmpty()) { throw new RecommendationException(RecommendationError.NO_ELIGIBLE_CANDIDATES); } diff --git a/src/main/java/com/heddy/domain/recommendation/port/out/RecommendationRepositoryPort.java b/src/main/java/com/heddy/domain/recommendation/port/out/RecommendationRepositoryPort.java index c3837004..22ce5192 100644 --- a/src/main/java/com/heddy/domain/recommendation/port/out/RecommendationRepositoryPort.java +++ b/src/main/java/com/heddy/domain/recommendation/port/out/RecommendationRepositoryPort.java @@ -3,6 +3,7 @@ import com.heddy.domain.recommendation.model.RecommendationRun; import java.util.Optional; +import java.util.Map; import java.util.UUID; public interface RecommendationRepositoryPort { @@ -12,5 +13,8 @@ public interface RecommendationRepositoryPort { Optional findLatestByUserId(UUID userId); + /** 동일 사용자·입력의 최근 20회 추천에서 스타일별 노출 횟수. */ + Map findRecentExposureCounts(UUID userId, String inputHash); + Optional findByIdAndUserId(UUID recommendationRunId, UUID userId); } diff --git a/src/main/java/com/heddy/domain/recommendation/service/RecommendationDiversifier.java b/src/main/java/com/heddy/domain/recommendation/service/RecommendationDiversifier.java index c7e76f7d..4edc3bea 100644 --- a/src/main/java/com/heddy/domain/recommendation/service/RecommendationDiversifier.java +++ b/src/main/java/com/heddy/domain/recommendation/service/RecommendationDiversifier.java @@ -8,16 +8,32 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.UUID; /** 정렬된 후보에서 카테고리·대표 태그 편중을 결정론적으로 줄인다. */ public final class RecommendationDiversifier { public List topThree(List orderedCandidates) { + return topThree(orderedCandidates, Map.of()); + } + + /** 새로고침은 미노출/저노출 후보부터 선택하고 같은 노출 횟수 안에서는 기존 순위를 유지한다. */ + public List topThree( + List orderedCandidates, Map exposureCounts + ) { List selected = new ArrayList<>(3); - addEligible(orderedCandidates, selected, true, true); - addEligible(orderedCandidates, selected, true, false); - addEligible(orderedCandidates, selected, false, false); + Map> tiers = new TreeMap<>(); + for (ScoredRecommendation candidate : orderedCandidates) { + long exposures = exposureCounts.getOrDefault(candidate.candidate().hairstyleId(), 0L); + tiers.computeIfAbsent(exposures, ignored -> new ArrayList<>()).add(candidate); + } + for (List tier : tiers.values()) { + addEligible(tier, selected, true, true); + addEligible(tier, selected, true, false); + addEligible(tier, selected, false, false); + if (selected.size() == 3) break; + } return List.copyOf(selected); } diff --git a/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java index c836b03d..5c6152a0 100644 --- a/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java @@ -56,6 +56,7 @@ AwsCredentialsProvider offlineAwsCredentialsProvider() { @Autowired MockMvc mockMvc; @Autowired JdbcTemplate jdbcTemplate; @Autowired EntityManager entityManager; + @Autowired com.heddy.domain.recommendation.port.out.RecommendationRepositoryPort recommendationRepository; @org.springframework.test.context.bean.override.mockito.MockitoBean com.heddy.domain.recommendation.port.out.PhotoHairLengthPort photoAnalyzer; @@ -377,6 +378,70 @@ void forcedRefreshStillCreatesANewRunAndFiltersLongStyles() throws Exception { assertThat(runCount()).isEqualTo(2); } + @Test + void forcedRefreshExploresEligibleStylesAndLatestKeepsTheRefreshedSelection() throws Exception { + for (int i = 4; i <= 9; i++) { + insertStyle("94000000-0000-4000-8000-%012d".formatted(i), + "추가 스타일 " + i, "MEDIUM", "OVERLAY", 10 - i, false); + } + var seen = new java.util.HashSet(); + String firstId = generateRun(); + seen.addAll(styleIdsForRun(firstId)); + assertThat(seen).hasSize(3); + for (int round = 0; round < 2; round++) { + String response = mockMvc.perform(post("/recommendations/generate") + .with(authentication(auth(USER_ID))) + .contentType(MediaType.APPLICATION_JSON).content("{\"force_refresh\":true}")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + String refreshedId = runId(response); + var styles = styleIdsForRun(refreshedId); + assertThat(styles).hasSize(3).doesNotHaveDuplicates().doesNotContainAnyElementsOf(seen); + seen.addAll(styles); + assertThat(latestRun()).isEqualTo(refreshedId); + assertThat(generateRun()).isEqualTo(refreshedId); + } + assertThat(seen).hasSize(9); + assertThat(runCount()).isEqualTo(3); + mockMvc.perform(get("/recommendations/{id}", firstId).with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recommendation_run_id").value(firstId)); + } + + @Test + void refreshHistoryIsLimitedToTwentyRunsOfTheSameUserAndInput() throws Exception { + UUID initialRunId = UUID.fromString(generateRun()); + String hash = jdbcTemplate.queryForObject( + "SELECT input_hash FROM recommendation_runs WHERE recommendation_run_id = ?", String.class, initialRunId); + // Each group copies the same three styles. Only this user's matching input may count. + for (var group : List.of(new Object[]{USER_ID, hash}, new Object[]{OTHER_USER_ID, hash}, + new Object[]{USER_ID, "different-input"})) { + jdbcTemplate.update(""" + WITH copied AS ( + INSERT INTO recommendation_runs + (recommendation_run_id, user_id, strategy, status, input_snapshot_json, input_hash, generated_at) + SELECT gen_random_uuid(), ?, 'RULE_BASED_V1', 'ACTIVE', '{}'::jsonb, ?, + now() + n * interval '1 second' FROM generate_series(1,25) n + RETURNING recommendation_run_id + ) + INSERT INTO recommendation_items + (recommendation_item_id, recommendation_run_id, hairstyle_id, display_rank, + score, score_breakdown_json, reasons_json, management_difficulty, estimated_daily_care_minutes) + SELECT gen_random_uuid(), copied.recommendation_run_id, item.hairstyle_id, item.display_rank, + item.score, item.score_breakdown_json, item.reasons_json, + item.management_difficulty, item.estimated_daily_care_minutes + FROM copied CROSS JOIN recommendation_items item WHERE item.recommendation_run_id = ? + """, group[0], group[1], initialRunId); + } + assertThat(recommendationRepository.findRecentExposureCounts(USER_ID, hash)) + .hasSize(3).allSatisfy((id, count) -> assertThat(count).isEqualTo(20L)); + } + + private List styleIdsForRun(String runId) { + return jdbcTemplate.queryForList( + "SELECT hairstyle_id FROM recommendation_items WHERE recommendation_run_id = ? ORDER BY display_rank", + UUID.class, UUID.fromString(runId)); + } + @Test void cutShapeEditReranksLatestAndThenReusesItWithoutANewAccount() throws Exception { jdbcTemplate.update("UPDATE treatment_records SET cut_shape = 'LAYERED' WHERE record_id = ?", treatmentRecordId); diff --git a/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java b/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java index bafd3119..7036739d 100644 --- a/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java +++ b/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java @@ -37,6 +37,30 @@ void relaxesLimitsWhenCandidatesAreInsufficientAndIsDeterministic() { assertThat(diversifier.topThree(candidates)).isEqualTo(diversifier.topThree(candidates)); } + @Test + void prioritizesUnseenStylesEvenWhenTheirCategoryAndTagMatch() { + UUID sameTag = UUID.randomUUID(); + var seen = item("B", UUID.randomUUID(), 100); + var unseen = List.of(item("A", sameTag, 90), item("A", sameTag, 80), item("A", sameTag, 70)); + var candidates = new java.util.ArrayList<>(List.of(seen)); + candidates.addAll(unseen); + + assertThat(diversifier.topThree(candidates, Map.of(seen.candidate().hairstyleId(), 1L))) + .containsExactlyElementsOf(unseen); + } + + @Test + void fillsFromLeastExposedStylesWithoutDuplicatesWhenUnseenStylesRunOut() { + var frequent = item("A", UUID.randomUUID(), 100); + var rare = item("B", UUID.randomUUID(), 90); + var unseen = item("C", UUID.randomUUID(), 80); + var counts = Map.of(frequent.candidate().hairstyleId(), 4L, rare.candidate().hairstyleId(), 1L); + + assertThat(diversifier.topThree(List.of(frequent, rare, unseen), counts)) + .containsExactly(unseen, rare, frequent); + assertThat(diversifier.topThree(List.of(frequent), counts)).containsExactly(frequent); + } + private ScoredRecommendation item(String category, UUID tagId, double score) { HairstyleCandidate candidate = new HairstyleCandidate(UUID.randomUUID(), "style", category, UUID.randomUUID(), "NONE", true, "1", Set.of(), Set.of(), Set.of(), Set.of(), Set.of(),