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
Expand Up @@ -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(
Expand Down Expand Up @@ -60,11 +63,15 @@ private BrowserRerankingCandidate toHandoffCandidate(
);
}

static BigDecimal tagSimilarity(
BigDecimal tagSimilarity(
List<TagInput> tags,
ExternalProductCandidate candidate
) {
RecommendationTagMatcher.Match match = RecommendationTagMatcher.match(tags, candidate);
RecommendationTagMatcher.Match match = RecommendationTagMatcher.match(
tags,
candidate,
queryPlanner
);
if (match.eligibleTagCount() == 0) {
return ONE;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,19 +21,32 @@ final class RecommendationTagMatcher {
private RecommendationTagMatcher() {
}

static Match match(List<TagInput> tags, ExternalProductCandidate candidate) {
static Match match(
List<TagInput> tags,
ExternalProductCandidate candidate,
RecommendationRetrievalQueryPlanner queryPlanner
) {
Objects.requireNonNull(queryPlanner, "queryPlanner must not be null");
String searchableText = searchableText(candidate);
List<TagInput> 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(
" ",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -40,7 +42,11 @@ class BrowserRerankingHandoffServiceTest {

@BeforeEach
void setUp() {
service = new BrowserRerankingHandoffService(candidateTokenService, candidateMapper);
service = new BrowserRerankingHandoffService(
candidateTokenService,
candidateMapper,
new RecommendationRetrievalQueryPlanner()
);
}

@Test
Expand All @@ -52,33 +58,78 @@ 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<TagInput> 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<TagInput> 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<TagInput> tags = List.of(
new TagInput(1L, "Minimal", TagType.STYLE),
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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}