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
6 changes: 5 additions & 1 deletion docs/hairstyle-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
최초 실행이 없으면 404, 적합한 후보가 없으면 422를 반환한다.
- 카탈로그 호환성·점수·정렬 조건도 입력 해시에 포함한다. 운영 메타데이터 버전을 빠뜨린
수정도 이전 결과를 재사용하지 않는다. `/recommendations/{id}`는 과거 실행 조회를 유지한다.
- `force_refresh=true`는 새 실행을 만들지만 같은 입력의 순위를 무작위로 섞지는 않는다.
- `force_refresh=true`는 새 실행을 만들고, 동일 사용자·입력의 최근 20회 추천에서
노출 횟수가 적은 적합한 스타일을 우선 선택한다. 같은 노출 횟수에서는 기존 점수와
카테고리·태그 다양화 규칙을 유지한다. 길이·손상도·제외 선호 필터는 완화하지 않는다.
적합한 후보가 3개 이하면 이전 스타일이 반복될 수 있다.
일반 생성과 `/recommendations/latest`는 새로고침으로 만든 최신 결과를 재사용한다.

## 추천 → AR 연결 (2026-10-20)

Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ public Optional<RecommendationRun> findLatestByUserId(UUID userId) {
.map(this::assemble);
}

@Override
public Map<UUID, Long> 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<RecommendationRun> findByIdAndUserId(UUID recommendationRunId, UUID userId) {
return runRepository.findByRecommendationRunIdAndUserId(recommendationRunId, userId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecommendationRunEntity, UUID> {
Expand All @@ -14,6 +15,26 @@ Optional<RecommendationRunEntity> findFirstByUserIdAndStrategyAndInputHashAndSta
Optional<RecommendationRunEntity> findFirstByUserIdOrderByGeneratedAtDescRecommendationRunIdDesc(UUID userId);
Optional<RecommendationRunEntity> 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<HairstyleExposure> findRecentExposureCounts(
@Param("userId") UUID userId, @Param("inputHash") String inputHash);

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = """
UPDATE recommendation_runs SET status = 'STALE'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ && canReuseExplanation(reusable, generatedAt)) {
}
}

Map<UUID, Long> exposureCounts = forceRefresh
? recommendationRepositoryPort.findRecentExposureCounts(userId, inputHash) : Map.of();
List<ScoredRecommendation> selected = diversifier.topThree(
scorer.scoreAll(candidates, context));
scorer.scoreAll(candidates, context), exposureCounts);
if (selected.isEmpty()) {
throw new RecommendationException(RecommendationError.NO_ELIGIBLE_CANDIDATES);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -12,5 +13,8 @@ public interface RecommendationRepositoryPort {

Optional<RecommendationRun> findLatestByUserId(UUID userId);

/** 동일 사용자·입력의 최근 20회 추천에서 스타일별 노출 횟수. */
Map<UUID, Long> findRecentExposureCounts(UUID userId, String inputHash);

Optional<RecommendationRun> findByIdAndUserId(UUID recommendationRunId, UUID userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScoredRecommendation> topThree(List<ScoredRecommendation> orderedCandidates) {
return topThree(orderedCandidates, Map.of());
}

/** 새로고침은 미노출/저노출 후보부터 선택하고 같은 노출 횟수 안에서는 기존 순위를 유지한다. */
public List<ScoredRecommendation> topThree(
List<ScoredRecommendation> orderedCandidates, Map<UUID, Long> exposureCounts
) {
List<ScoredRecommendation> selected = new ArrayList<>(3);
addEligible(orderedCandidates, selected, true, true);
addEligible(orderedCandidates, selected, true, false);
addEligible(orderedCandidates, selected, false, false);
Map<Long, List<ScoredRecommendation>> 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<ScoredRecommendation> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<UUID>();
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<UUID> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading