diff --git a/src/main/java/com/fitback/backend/domain/recommendation/service/BrowserRerankingHandoffService.java b/src/main/java/com/fitback/backend/domain/recommendation/service/BrowserRerankingHandoffService.java index e082f3cc..bfcaa8d6 100644 --- a/src/main/java/com/fitback/backend/domain/recommendation/service/BrowserRerankingHandoffService.java +++ b/src/main/java/com/fitback/backend/domain/recommendation/service/BrowserRerankingHandoffService.java @@ -21,13 +21,16 @@ public class BrowserRerankingHandoffService { private final CandidateTokenService candidateTokenService; private final ProductCandidateMapper candidateMapper; + private final RecommendationRetrievalQueryPlanner queryPlanner; public BrowserRerankingHandoffService( CandidateTokenService candidateTokenService, - ProductCandidateMapper candidateMapper + ProductCandidateMapper candidateMapper, + RecommendationRetrievalQueryPlanner queryPlanner ) { this.candidateTokenService = candidateTokenService; this.candidateMapper = candidateMapper; + this.queryPlanner = queryPlanner; } public BrowserRerankingHandoff create( @@ -60,11 +63,15 @@ private BrowserRerankingCandidate toHandoffCandidate( ); } - static BigDecimal tagSimilarity( + BigDecimal tagSimilarity( List tags, ExternalProductCandidate candidate ) { - RecommendationTagMatcher.Match match = RecommendationTagMatcher.match(tags, candidate); + RecommendationTagMatcher.Match match = RecommendationTagMatcher.match( + tags, + candidate, + queryPlanner + ); if (match.eligibleTagCount() == 0) { return ONE; } diff --git a/src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationTagMatcher.java b/src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationTagMatcher.java index 865d4f46..177f8c29 100644 --- a/src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationTagMatcher.java +++ b/src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationTagMatcher.java @@ -6,6 +6,7 @@ import java.util.EnumSet; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.Set; final class RecommendationTagMatcher { @@ -20,19 +21,32 @@ final class RecommendationTagMatcher { private RecommendationTagMatcher() { } - static Match match(List tags, ExternalProductCandidate candidate) { + static Match match( + List tags, + ExternalProductCandidate candidate, + RecommendationRetrievalQueryPlanner queryPlanner + ) { + Objects.requireNonNull(queryPlanner, "queryPlanner must not be null"); String searchableText = searchableText(candidate); List eligibleTags = tags.stream() .filter(tag -> ELIGIBLE_TAG_TYPES.contains(tag.tagType())) .toList(); long matchedTagCount = eligibleTags.stream() - .map(TagInput::name) - .map(name -> name.toLowerCase(Locale.ROOT)) + .map(tag -> matchingText(tag, queryPlanner)) + .map(text -> text.toLowerCase(Locale.ROOT)) .filter(searchableText::contains) .count(); return new Match(matchedTagCount, eligibleTags.size()); } + private static String matchingText( + TagInput tag, + RecommendationRetrievalQueryPlanner queryPlanner + ) { + String alias = queryPlanner.aliasFor(tag); + return alias == null ? tag.name() : alias; + } + private static String searchableText(ExternalProductCandidate candidate) { return String.join( " ", diff --git a/src/test/java/com/fitback/backend/domain/recommendation/service/BrowserRerankingHandoffServiceTest.java b/src/test/java/com/fitback/backend/domain/recommendation/service/BrowserRerankingHandoffServiceTest.java index d2220fce..442c4a2e 100644 --- a/src/test/java/com/fitback/backend/domain/recommendation/service/BrowserRerankingHandoffServiceTest.java +++ b/src/test/java/com/fitback/backend/domain/recommendation/service/BrowserRerankingHandoffServiceTest.java @@ -22,6 +22,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -40,7 +42,11 @@ class BrowserRerankingHandoffServiceTest { @BeforeEach void setUp() { - service = new BrowserRerankingHandoffService(candidateTokenService, candidateMapper); + service = new BrowserRerankingHandoffService( + candidateTokenService, + candidateMapper, + new RecommendationRetrievalQueryPlanner() + ); } @Test @@ -52,20 +58,57 @@ void calculatesZeroPartialAndFullTagSimilarity() { new TagInput(4L, "A-line", TagType.SILHOUETTE) ); - assertThat(BrowserRerankingHandoffService.tagSimilarity( + assertThat(service.tagSimilarity( tags, candidate("black nylon shirt", null) )).isEqualByComparingTo("0.00"); - assertThat(BrowserRerankingHandoffService.tagSimilarity( + assertThat(service.tagSimilarity( tags, candidate("blue cotton shirt", null) )).isEqualByComparingTo("0.50"); - assertThat(BrowserRerankingHandoffService.tagSimilarity( + assertThat(service.tagSimilarity( tags, candidate("blue cotton pleat a-line dress", null) )).isEqualByComparingTo("1.00"); } + @Test + void matchesKoreanCanonicalTagsWithRetrievalAliases() { + List tags = List.of( + new TagInput(1L, "레귤러핏", TagType.SILHOUETTE), + new TagInput(2L, "베이지", TagType.COLOR), + new TagInput(3L, "라운드넥", TagType.DETAIL), + new TagInput(4L, "니트", TagType.MATERIAL), + new TagInput(5L, "캐주얼", TagType.STYLE) + ); + + assertThat(service.tagSimilarity( + tags, + candidate("regular-fit beige crewneck knit shirt", null) + )).isEqualByComparingTo("1.00"); + } + + @ParameterizedTest + @CsvSource({ + "regular-fit beige crewneck knit shirt, 1.00", + "regular-fit beige crewneck shirt, 0.75", + "regular-fit beige shirt, 0.50", + "regular-fit shirt, 0.25", + "plain shirt, 0.00" + }) + void keepsUnweightedPartialMatchRatio(String productName, String expectedSimilarity) { + List tags = List.of( + new TagInput(1L, "레귤러핏", TagType.SILHOUETTE), + new TagInput(2L, "베이지", TagType.COLOR), + new TagInput(3L, "라운드넥", TagType.DETAIL), + new TagInput(4L, "니트", TagType.MATERIAL), + new TagInput(5L, "캐주얼", TagType.STYLE) + ); + + assertThat(service.tagSimilarity(tags, candidate(productName, null))) + .isEqualByComparingTo(expectedSimilarity); + } + @Test void excludesStyleAndCustomTagsFromSimilarity() { List tags = List.of( @@ -73,12 +116,20 @@ void excludesStyleAndCustomTagsFromSimilarity() { new TagInput(2L, "Cotton", TagType.MATERIAL) ); - assertThat(BrowserRerankingHandoffService.tagSimilarity( + assertThat(service.tagSimilarity( tags, candidate("minimal cotton shirt", null) )).isEqualByComparingTo("1.00"); } + @Test + void preservesOneForZeroEligibleTags() { + assertThat(service.tagSimilarity( + List.of(new TagInput(1L, "캐주얼", TagType.STYLE)), + candidate("unrelated shirt", null) + )).isEqualByComparingTo("1.00"); + } + @Test void mapsExternalCandidateSnapshotAndReusesProductPriceResponse() { ProductOffer offer = offer( diff --git a/src/test/java/com/fitback/backend/domain/recommendation/service/RecommendationTagMatcherTest.java b/src/test/java/com/fitback/backend/domain/recommendation/service/RecommendationTagMatcherTest.java new file mode 100644 index 00000000..02797ba0 --- /dev/null +++ b/src/test/java/com/fitback/backend/domain/recommendation/service/RecommendationTagMatcherTest.java @@ -0,0 +1,84 @@ +package com.fitback.backend.domain.recommendation.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fitback.backend.domain.product.service.model.ExternalProductCandidate; +import com.fitback.backend.domain.product.service.model.ProviderProductRef; +import com.fitback.backend.domain.recommendation.service.RecommendationTagMatcher.Match; +import com.fitback.backend.domain.recommendation.service.model.RecommendationInputSnapshot.TagInput; +import com.fitback.backend.domain.tag.entity.TagType; +import java.net.URI; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RecommendationTagMatcherTest { + + private static final Instant OBSERVED_AT = Instant.parse("2026-08-20T00:00:00Z"); + + private final RecommendationRetrievalQueryPlanner queryPlanner = + new RecommendationRetrievalQueryPlanner(); + + @Test + void reusesCuratedAliasesAcrossNameBrandAndCategoryPath() { + Match match = RecommendationTagMatcher.match( + List.of( + tag(1, "레귤러핏", TagType.SILHOUETTE), + tag(2, "베이지", TagType.COLOR), + tag(3, "라운드넥", TagType.DETAIL), + tag(4, "니트", TagType.MATERIAL), + tag(5, "캐주얼", TagType.STYLE) + ), + candidate("regular-fit shirt", "Beige Brand", "tops/crewneck/knit"), + queryPlanner + ); + + assertThat(match).isEqualTo(new Match(4, 4)); + } + + @Test + void fallsBackToCanonicalNameWhenAliasIsUnavailable() { + Match match = RecommendationTagMatcher.match( + List.of(tag(1, "턱", TagType.DETAIL)), + candidate("턱 디테일 블라우스", null, "tops/blouses"), + queryPlanner + ); + + assertThat(match).isEqualTo(new Match(1, 1)); + } + + @Test + void doesNotIntroduceFuzzyMatchingForCuratedAliases() { + Match match = RecommendationTagMatcher.match( + List.of( + tag(1, "지퍼", TagType.DETAIL), + tag(2, "미디기장", TagType.SILHOUETTE) + ), + candidate("slip mini dress", null, "dresses"), + queryPlanner + ); + + assertThat(match).isEqualTo(new Match(0, 2)); + } + + private static TagInput tag(long id, String name, TagType type) { + return new TagInput(id, name, type); + } + + private static ExternalProductCandidate candidate( + String name, + String brand, + String categoryPath + ) { + return new ExternalProductCandidate( + ProviderProductRef.stable("fixture", name, null, "store"), + name, + brand, + categoryPath, + null, + URI.create("https://example.com/product.jpg"), + null, + OBSERVED_AT + ); + } +}