From 401df1effe6ae36f1083cb744edd8a595a582031 Mon Sep 17 00:00:00 2001 From: chadchan <112686545+coconutcococode@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:08:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20bounded=20retrieval=20depth=20experimen?= =?UTF-8?q?t=20=EC=A4=80=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...on-accuracy-retrieval-metadata-taxonomy.md | 300 ++++++++++++++++++ .../service/RecommendationService.java | 129 ++++++-- .../service/RecommendationServiceTest.java | 296 +++++++++++++++++ 3 files changed, 692 insertions(+), 33 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-22-recommendation-accuracy-retrieval-metadata-taxonomy.md diff --git a/docs/superpowers/plans/2026-08-22-recommendation-accuracy-retrieval-metadata-taxonomy.md b/docs/superpowers/plans/2026-08-22-recommendation-accuracy-retrieval-metadata-taxonomy.md new file mode 100644 index 00000000..65defbba --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-recommendation-accuracy-retrieval-metadata-taxonomy.md @@ -0,0 +1,300 @@ +# Recommendation Accuracy Retrieval, Metadata, and Taxonomy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use executing-plans task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `origin/main`의 production 기본 동작을 유지하면서 bounded depth, Shopify semantic metadata, structured taxonomy 세 Track을 production/provider 호출 없이 offline experiment-ready 상태로 만든다. + +**Architecture:** Track A만 기존 `RecommendationService`에 default-off 최대 2-page 경로를 복구하고 Spring public constructor는 1 page로 고정한다. Track B/C는 각각 Git-ignored `.local/` Node 모듈로 구현해 Shopify raw JSON을 transient input으로만 받고 normalized projection 또는 aggregate diagnostics만 반환한다. 공통 durable schema는 identity/title/URL/raw payload를 거절하며 `SAFE_SNAPSHOT -> PRIVACY_VALIDATION -> DURABLE_WRITE -> CLEANUP` 순서를 강제한다. + +**Tech Stack:** Java 21, Spring Boot 4.1.0, Gradle, JUnit 5, Mockito, AssertJ, Node.js built-in test runner + +## Global Constraints + +- Baseline은 `78bcd4ffd5134a2eca6e8780d5c62ada1600291d`이며 production public constructor/runtime 기본 동작은 page 1 only다. +- source-local ordering, planner, family order, category filter, selector, scorer, Fashion-CLIP, R2, price/value ordering을 변경하지 않는다. +- provider/Shopify/recommendation/deploy/holdout/GitHub write는 모두 0이다. +- `depth_top02_20260821_01..03`과 모든 과거 ledger/report/identity artifact는 복사·수정·재사용하지 않는다. +- durable output에는 provider/merchant/product/candidate identity, title, URL, query, raw payload, image, embedding, price를 넣지 않는다. +- 실제 GOOD label join이 없으면 Track B/C policy는 `NOT_JUDGED`로 유지한다. + +--- + +### Task 1: Restore bounded retrieval with tracked failure-parity tests + +**Files:** +- Modify: `src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationService.java` +- Modify: `src/test/java/com/fitback/backend/domain/recommendation/service/RecommendationServiceTest.java` + +**Interfaces:** +- Consumes: `ProductSearchResult.hasNext()`, `ProductSearchResult.nextCursor()` +- Produces: `RecommendationService(..., int maxPagesPerQuery)` package-private experiment constructor; public constructor remains one-page + +- [ ] **Step 1: Add red tests for the bounded contract and failure parity** + +Use the real selector and capture `ProductSearchQuery` calls. The essential assertions are: + +```java +assertThat(capturedQueries).extracting(ProductSearchQuery::cursor) + .containsExactly(null, "page-2"); +verify(productCatalogPort, times(2)).search(any()); +assertThat(selectedIds).containsSubsequence(page1LastId, page2FirstId); +``` + +Add separate cases for page1 failure continuing to the next family, page2 failure preserving page1, page2 returning another cursor without page3, public-constructor one-page compatibility, max `2Q` calls with retry 0, and all-page1 failures attempting every family once before the terminal error. + +- [ ] **Step 2: Run focused tests and confirm the new tests fail** + +Run: + +```bash +env GRADLE_USER_HOME=/tmp/fitback-accuracy-gradle ./gradlew test \ + --tests com.fitback.backend.domain.recommendation.service.RecommendationServiceTest \ + --no-daemon --no-watch-fs --offline --rerun-tasks +``` + +Expected before implementation: compile or assertion failure because the max-pages constructor and page traversal do not exist. + +- [ ] **Step 3: Implement the minimum default-off traversal** + +The public constructor delegates with `CURRENT_MAX_PAGES_PER_QUERY`; the package-private constructor accepts only 1 or 2. The family loop follows this shape: + +```java +List categoryFilteredBatch = new ArrayList<>(); +boolean familySearchSucceeded = false; +String cursor = null; +for (int page = 1; page <= maxPagesPerQuery; page++) { + String pageCursor = cursor; + ProductSearchResult result = productCatalogPort.search( + new ProductSearchQuery(plannedQuery.keyword(), category, pageCursor, 20)); + familySearchSucceeded = true; + categoryFilteredBatch.addAll(result.items().stream() + .filter(candidate -> candidateMapper.category(candidate) == category) + .toList()); + if (!result.hasNext()) break; + cursor = result.nextCursor(); +} +if (familySearchSucceeded) candidateBatches.add(List.copyOf(categoryFilteredBatch)); +``` + +Keep the existing `ProductProviderException` catch outside the page loop so page1 failure skips the family and page2 failure preserves the accumulated page1 batch. Do not add retry or sorting. + +- [ ] **Step 4: Run focused and recommendation-domain tests** + +Run the focused command above, then: + +```bash +env GRADLE_USER_HOME=/tmp/fitback-accuracy-gradle ./gradlew test \ + --tests 'com.fitback.backend.domain.recommendation.**' \ + --no-daemon --no-watch-fs --offline --rerun-tasks +``` + +Expected: all selected tests pass with provider/network calls 0. + +--- + +### Task 2: Add an exact aggregate depth evidence gate + +**Files:** +- Create: `.local/track-a-depth/package.json` +- Create: `.local/track-a-depth/src/depth-report.mjs` +- Create: `.local/track-a-depth/src/finalizer.mjs` +- Create: `.local/track-a-depth/test/depth-report.test.mjs` +- Create: `.local/track-a-depth/test/finalizer.test.mjs` + +**Interfaces:** +- Consumes: aggregate-only `fitback-dev-query-aggregate-depth-v1` +- Produces: normalized CURRENT/DEPTH metrics and `SUPPORTED | NOT_SUPPORTED | NOT_JUDGED` + +- [ ] **Step 1: Write red schema and finalization tests** + +The exact root keys are: + +```js +const ROOT_KEYS = [ + "schemaVersion", "queryKey", "category", "coverage", "privacy", + "deterministic", "queryBudget", "current", "depth", "depthDiagnostics", +]; +``` + +Each arm must contain `rawLabelCounts`, `selectedLabelCounts`, `top10LabelCounts`, `selectedGoodRateAt30`, `goodRetentionAt30`, `goodCountAt10`, `highCountAt30`, and `queryBudget`. Diagnostics must contain page2 candidate/GOOD/selected counts, duplicates, calls, latency, partial/unavailable family counts, retry count, and latency-budget state. Tests must accept aggregate names such as `invalidProviderReferenceDropCount` but reject any extra provider-reference value field, URL, title, raw query, or identity. + +Finalization test order: + +```js +assert.deepEqual(order, [ + "SAFE_SNAPSHOT", "PRIVACY_VALIDATION", "DURABLE_WRITE", "CLEANUP", +]); +``` + +If durable write fails, cleanup must not destroy transient state; the caller receives the primary write error. + +- [ ] **Step 2: Implement exact-key validation and the frozen gate** + +Use exact allowlists, not forbidden substrings. The policy gate returns `NOT_JUDGED` when coverage is not `JUDGED`, any family is partial/unavailable, privacy/determinism/retry/call/latency gates fail, or required metrics are null. It returns `SUPPORTED` only when retention improves, selected rate and overall HIGH do not worsen, at least two categories are non-worsening at macro evaluation, and page2 GOOD selected contribution is positive. A clear measured regression returns `NOT_SUPPORTED`. + +- [ ] **Step 3: Implement recoverable finalization** + +```js +export async function publish({ snapshot, validate, write, cleanup }) { + const frozen = deepFreeze(structuredClone(snapshot)); + validate(frozen); + await write(frozen); // no cleanup on failed durable write + await cleanup(); + return frozen; +} +``` + +- [ ] **Step 4: Run Node tests** + +```bash +npm --prefix .local/track-a-depth test +``` + +Expected: all schema, privacy, gate, determinism, and fault-injection tests pass. + +--- + +### Task 3: Build the local-only Shopify semantic sidecar + +**Files:** +- Create: `.local/track-b-metadata/package.json` +- Create: `.local/track-b-metadata/src/semantic-sidecar.mjs` +- Create: `.local/track-b-metadata/src/usefulness.mjs` +- Create: `.local/track-b-metadata/test/semantic-sidecar.test.mjs` +- Create: `.local/track-b-metadata/test/usefulness.test.mjs` +- Create: `.local/track-b-metadata/README.md` + +**Interfaces:** +- Consumes: one transient Shopify product JSON object plus a session-local `safeOrdinal` +- Produces: transient normalized token sets and durable aggregate counts only + +- [ ] **Step 1: Write red extractor tests** + +Fixture coverage must include missing/null/malformed fields, duplicated attributes, conflicting taxonomy, absent inferred fields, and extra unknown fields. The projection has exact keys: + +```js +{ + semanticTokens: { + material: [], style: [], occasion: [], silhouette: [], + detail: [], color: [], categoryTaxonomy: [], + }, + diagnostics: { + missingFieldCount: 0, malformedFieldCount: 0, + duplicateTokenCount: 0, conflictingTaxonomyCount: 0, + }, +} +``` + +Inputs may read `description`, `options`, `metadata.attributes`, variant options/tags, categories, and media alt text. The output must never contain description text, title, IDs, URLs, seller, price, or raw attribute values outside the normalized allowlist. + +- [ ] **Step 2: Implement bounded normalization** + +Use fixed per-dimension allowlists and lowercase Unicode token normalization. Optional fields contribute nothing when absent. Unknown values are ignored and counted, duplicates are deduplicated, and conflicting category tokens increment the conflict counter without selecting a winner. + +- [ ] **Step 3: Implement aggregate-only usefulness evaluation** + +The evaluator accepts only exact session-local safeOrdinal joins. With no joined labels it returns: + +```js +{ policy: "NOT_JUDGED", exactJoinCount: 0, reason: "LABEL_EVIDENCE_ABSENT" } +``` + +With labels it reports only per-label token-presence counts and comparison counts; it never emits row identity or raw tokens from a product. + +- [ ] **Step 4: Run tests** + +```bash +npm --prefix .local/track-b-metadata test +``` + +Expected: all fail-safe, privacy, determinism, and absent-evidence tests pass. + +--- + +### Task 4: Build the local-only taxonomy mapper and false-negative diagnostics + +**Files:** +- Create: `.local/track-c-category/package.json` +- Create: `.local/track-c-category/src/current-mapper.mjs` +- Create: `.local/track-c-category/src/taxonomy-mapper.mjs` +- Create: `.local/track-c-category/src/diagnostics.mjs` +- Create: `.local/track-c-category/test/mapper.test.mjs` +- Create: `.local/track-c-category/test/diagnostics.test.mjs` +- Create: `.local/track-c-category/README.md` + +**Interfaces:** +- Consumes: transient taxonomy/value pairs and optional title tokens +- Produces: paired CURRENT/TAXONOMY enum outcomes and `CategoryDiagnosticV1` aggregates + +- [ ] **Step 1: Freeze CURRENT precedence tests** + +Encode the repository behavior `DRESS > OUTER > BOTTOM > TOP` and verify at least: + +```js +[ + ["denim shirt", "BOTTOM"], ["shirt jacket", "OUTER"], + ["jacket dress", "DRESS"], ["dress pants", "DRESS"], + ["skirt top", "BOTTOM"], +] +``` + +- [ ] **Step 2: Implement conservative structured taxonomy mapping** + +Merchant hierarchy segments are evaluated leaf-to-root. Multiple recognized categories produce `CONFLICT`; missing/malformed/unrecognized structured values produce `UNMAPPED`. Numeric taxonomy IDs such as `212` remain `UNMAPPED` unless an official versioned mapping is explicitly supplied; do not infer their meaning. + +- [ ] **Step 3: Implement exact aggregate diagnostics** + +```js +{ + mapperVariant, family, queryIndex, preFilterCount, postFilterCount, + mappedSameCategory, mappedDifferentCategory, mappedOther, + mappedEnumCounts, ambiguousTitleFallbackCount, + structuredSignalAbsentCount, structuredSignalConflictCount, + labelJoinState, goodPreFilterCount, goodPostFilterCount, goodRetention, +} +``` + +Enforce `preFilterCount == sum(mappedEnumCounts)`, `postFilterCount == mappedSameCategory`, and null GOOD fields when `labelJoinState=NOT_JOINED`. Optional diagnostic samples may contain only `{family, queryIndex, safeOrdinal, currentMappedEnum, taxonomyMappedEnum, outcomeEnum}`. + +- [ ] **Step 4: Run tests** + +```bash +npm --prefix .local/track-c-category test +``` + +Expected: precedence, conflict, missing-field, aggregate, privacy, and determinism tests pass. + +--- + +### Task 5: Integrated verification and final evidence report + +**Files:** +- Create: `outputs/FITBACK_RECOMMENDATION_ACCURACY_OFFLINE_READINESS_2026-08-22.md` outside the repository worktree +- Inspect only: all tracked and ignored experiment files + +**Interfaces:** +- Consumes: fresh test outputs and diffs +- Produces: Korean completion report in the exact order requested by the user + +- [ ] **Step 1: Run all offline suites** + +Run Track A Gradle tests and all three `npm --prefix ... test` commands. If the Gradle environment blocks execution, record the exact environmental failure and do not claim PASS. + +- [ ] **Step 2: Audit forbidden tracked components and diff** + +```bash +git diff --check +git status --short +git diff --name-only +shasum -a 256 \ + src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationRetrievalQueryPlanner.java \ + src/main/java/com/fitback/backend/domain/recommendation/service/ImageComparisonCandidateSelector.java \ + src/main/java/com/fitback/backend/domain/recommendation/service/MultiTagPriorityImageComparisonCandidateOrderingPolicy.java \ + src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationScorer.java +``` + +Expected: only the plan plus Track A service/test are tracked changes; forbidden component hashes remain baseline values. + +- [ ] **Step 3: Produce the Korean report** + +Report baseline, source-local closure, A/B/C results, ranked next policy, accuracy/cost/privacy/cleanup/request counts/diff, final flags, and the next minimum step. Since no live calls are authorized, `DEPTH_POLICY=NOT_JUDGED`, all production/holdout counts are 0, and `PRODUCTION_APPLICATION_ALLOWED=NO`. diff --git a/src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationService.java b/src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationService.java index fdeb055d..caf4b343 100644 --- a/src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationService.java +++ b/src/main/java/com/fitback/backend/domain/recommendation/service/RecommendationService.java @@ -32,6 +32,7 @@ import java.util.Locale; import java.util.Set; import java.util.TreeSet; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service @@ -41,6 +42,8 @@ public class RecommendationService { static final String THRESHOLD_SCORE_VERSION = "IMAGE_TAG_WEIGHTED_THR_V1"; private static final int SEARCH_PAGE_SIZE = 20; + private static final int CURRENT_MAX_PAGES_PER_QUERY = 1; + private static final int EXPERIMENT_MAX_PAGES_PER_QUERY = 2; private static final int MAX_ITEMS_PER_CATEGORY = 10; private static final BigDecimal TEMPORARY_IMAGE_SIMILARITY_SCORE = new BigDecimal("70"); @@ -60,7 +63,9 @@ public class RecommendationService { private final RecommendationScorer scorer; private final RecommendationSetWriter setWriter; private final RecommendationQueryService queryService; + private final int maxPagesPerQuery; + @Autowired public RecommendationService( RecommendationInputReader inputReader, RecommendationInputCommandService inputCommandService, @@ -75,6 +80,42 @@ public RecommendationService( RecommendationSetWriter setWriter, RecommendationQueryService queryService ) { + this( + inputReader, + inputCommandService, + hmacUtil, + productCatalogPort, + retrievalQueryPlanner, + candidateMapper, + materializationService, + imageComparisonCandidateSelector, + browserRerankingHandoffService, + scorer, + setWriter, + queryService, + CURRENT_MAX_PAGES_PER_QUERY + ); + } + + RecommendationService( + RecommendationInputReader inputReader, + RecommendationInputCommandService inputCommandService, + HmacUtil hmacUtil, + ProductCatalogPort productCatalogPort, + RecommendationRetrievalQueryPlanner retrievalQueryPlanner, + ProductCandidateMapper candidateMapper, + ProductMaterializationService materializationService, + ImageComparisonCandidateSelector imageComparisonCandidateSelector, + BrowserRerankingHandoffService browserRerankingHandoffService, + RecommendationScorer scorer, + RecommendationSetWriter setWriter, + RecommendationQueryService queryService, + int maxPagesPerQuery + ) { + if (maxPagesPerQuery < CURRENT_MAX_PAGES_PER_QUERY + || maxPagesPerQuery > EXPERIMENT_MAX_PAGES_PER_QUERY) { + throw new IllegalArgumentException("maxPagesPerQuery must be 1 or 2"); + } this.inputReader = inputReader; this.inputCommandService = inputCommandService; this.hmacUtil = hmacUtil; @@ -87,6 +128,7 @@ public RecommendationService( this.scorer = scorer; this.setWriter = setWriter; this.queryService = queryService; + this.maxPagesPerQuery = maxPagesPerQuery; } public RecommendationCreateResponse generate(Long memberId, Long reportId) { @@ -174,45 +216,66 @@ private CandidateCollection collectCandidates( // query family 순서를 이후 라운드 로빈의 검색 배치 순서로 유지 for (int index = 0; index < queryPlan.size(); index++) { PlannedQuery plannedQuery = queryPlan.get(index); + List categoryFilteredBatch = new ArrayList<>(); + boolean familySearchSucceeded = false; + String cursor = null; try { - ProductSearchResult searchResult = RecommendationPerformanceTrace.measureSearchCatalog( - new RecommendationPerformanceTrace.SearchCatalogCallInput( - index + 1, - traceQueryFingerprint(category, plannedQuery.keyword()), - plannedQuery.tagTypeComposition(), - category.name() - ), - () -> productCatalogPort.search( - new ProductSearchQuery( - plannedQuery.keyword(), - category, - null, - SEARCH_PAGE_SIZE - ) - ), - result -> result.items().size() - ); - successfulSearches++; - searchedCandidateCount += searchResult.items().size(); + for (int page = 1; page <= maxPagesPerQuery; page++) { + String pageCursor = cursor; + ProductSearchResult searchResult = + RecommendationPerformanceTrace.measureSearchCatalog( + new RecommendationPerformanceTrace.SearchCatalogCallInput( + index + 1, + traceQueryFingerprint( + category, + plannedQuery.keyword() + ), + plannedQuery.tagTypeComposition(), + category.name() + ), + () -> productCatalogPort.search( + new ProductSearchQuery( + plannedQuery.keyword(), + category, + pageCursor, + SEARCH_PAGE_SIZE + ) + ), + result -> result.items().size() + ); + successfulSearches++; + familySearchSucceeded = true; + searchedCandidateCount += searchResult.items().size(); - // 검색어 내부의 공급자 상품 순위 보존을 위한 결과 목록 단위 저장 - List categoryFiltered = - RecommendationPerformanceTrace.measureStage( - "categoryFiltering", - () -> searchResult.items().stream() - .filter(candidate -> candidateMapper.category(candidate) == category) - .toList() - ); - RecommendationPerformanceTrace.recordCategoryFilteredResultCount( - index + 1, - categoryFiltered.size() - ); - categoryFilteredCandidateCount += categoryFiltered.size(); - candidateBatches.add(categoryFiltered); + // page 1 뒤에 page 2를 이어붙여 검색어 내부의 공급자 순위 보존 + List categoryFiltered = + RecommendationPerformanceTrace.measureStage( + "categoryFiltering", + () -> searchResult.items().stream() + .filter(candidate -> + candidateMapper.category(candidate) == category + ) + .toList() + ); + RecommendationPerformanceTrace.recordCategoryFilteredResultCount( + index + 1, + categoryFiltered.size() + ); + categoryFilteredCandidateCount += categoryFiltered.size(); + categoryFilteredBatch.addAll(categoryFiltered); + + if (!searchResult.hasNext()) { + break; + } + cursor = searchResult.nextCursor(); + } } catch (ProductProviderException exception) { // 전체 실패와 부분 실패를 구분하기 위한 공급자 오류 누적 failures.add(ProductProviderErrorMapper.toBusinessException(exception)); } + if (familySearchSucceeded) { + candidateBatches.add(List.copyOf(categoryFilteredBatch)); + } } // 모든 공급자 검색 실패 시 기존 추천 세트 보존을 위한 즉시 실패 diff --git a/src/test/java/com/fitback/backend/domain/recommendation/service/RecommendationServiceTest.java b/src/test/java/com/fitback/backend/domain/recommendation/service/RecommendationServiceTest.java index 06bb51c7..4605ded1 100644 --- a/src/test/java/com/fitback/backend/domain/recommendation/service/RecommendationServiceTest.java +++ b/src/test/java/com/fitback/backend/domain/recommendation/service/RecommendationServiceTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -120,6 +121,40 @@ private RecommendationService recommendationService(int candidateLimit) { ); } + private RecommendationService recommendationService( + int candidateLimit, + int maxPagesPerQuery + ) { + return new RecommendationService( + inputReader, + inputCommandService, + hmacUtil, + productCatalogPort, + new RecommendationRetrievalQueryPlanner(), + candidateMapper, + materializationService, + new ImageComparisonCandidateSelector( + new MultiTagPriorityImageComparisonCandidateOrderingPolicy(), + candidateLimit + ), + browserRerankingHandoffService, + scorer, + setWriter, + queryService, + maxPagesPerQuery + ); + } + + @Test + void boundedRetrievalRejectsPageLimitsOutsideOneOrTwo() { + assertThatThrownBy(() -> recommendationService(TEST_CANDIDATE_LIMIT, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("maxPagesPerQuery must be 1 or 2"); + assertThatThrownBy(() -> recommendationService(TEST_CANDIDATE_LIMIT, 3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("maxPagesPerQuery must be 1 or 2"); + } + @Test void deduplicatesAndSelectsTopTenPerCategoryDeterministically() { RecommendationInputSnapshot input = input(); @@ -242,6 +277,201 @@ void handsOffAtMostThirtyCandidatesAfterSelector() { assertThat(trace.browserRerankingCandidateCount()).isZero(); } + @Test + void boundedRetrievalAppendsSecondPageWithoutChangingSourceLocalOrder() { + recommendationService = recommendationService(TEST_CANDIDATE_LIMIT, 2); + RecommendationInputSnapshot input = styleOnlyInput(); + ExternalProductCandidate page1Last = candidate(1, null, true); + ExternalProductCandidate page2First = candidate(2, null, true); + when(inputReader.read(1L, 501L)).thenReturn(input); + when(productCatalogPort.search(new ProductSearchQuery( + "", + ProductCategory.TOP, + null, + 20 + ))).thenReturn(new ProductSearchResult(List.of(page1Last), "page-2")); + when(productCatalogPort.search(new ProductSearchQuery( + "", + ProductCategory.TOP, + "page-2", + 20 + ))).thenReturn(new ProductSearchResult(List.of(page2First), null)); + stubSuccessfulGeneration(); + + recommendationService.generate(1L, 501L); + + ArgumentCaptor queryCaptor = + ArgumentCaptor.forClass(ProductSearchQuery.class); + verify(productCatalogPort, times(2)).search(queryCaptor.capture()); + assertThat(queryCaptor.getAllValues()) + .extracting(ProductSearchQuery::cursor) + .containsExactly(null, "page-2"); + assertThat(candidateIds(handedOffCandidates())) + .containsSubsequence(1, 2); + } + + @Test + void pageOneFailureContinuesWithTheNextQueryFamily() { + recommendationService = recommendationService(TEST_CANDIDATE_LIMIT, 2); + RecommendationInputSnapshot input = multiFamilyInput(); + ExternalProductCandidate recovered = candidate(2, null, true); + when(inputReader.read(1L, 501L)).thenReturn(input); + when(productCatalogPort.search(new ProductSearchQuery( + "a-line", + ProductCategory.TOP, + null, + 20 + ))).thenThrow(providerTimeout()); + when(productCatalogPort.search(new ProductSearchQuery( + "v-neck", + ProductCategory.TOP, + null, + 20 + ))).thenReturn(new ProductSearchResult(List.of(recovered), null)); + when(productCatalogPort.search(new ProductSearchQuery( + "", + ProductCategory.TOP, + null, + 20 + ))).thenReturn(new ProductSearchResult(List.of(), null)); + stubSuccessfulGeneration(); + + RecommendationCreateResponse response = recommendationService.generate(1L, 501L); + + verify(productCatalogPort, times(3)).search(any(ProductSearchQuery.class)); + assertThat(candidateIds(handedOffCandidates())).containsExactly(2); + assertThat(response.partial()).isTrue(); + assertThat(response.warnings()).containsExactly("PROVIDER_PARTIAL_FAILURE"); + } + + @Test + void pageTwoFailurePreservesTheSuccessfulFirstPage() { + recommendationService = recommendationService(TEST_CANDIDATE_LIMIT, 2); + RecommendationInputSnapshot input = styleOnlyInput(); + ExternalProductCandidate firstPageCandidate = candidate(1, null, true); + when(inputReader.read(1L, 501L)).thenReturn(input); + when(productCatalogPort.search(new ProductSearchQuery( + "", + ProductCategory.TOP, + null, + 20 + ))).thenReturn(new ProductSearchResult(List.of(firstPageCandidate), "page-2")); + when(productCatalogPort.search(new ProductSearchQuery( + "", + ProductCategory.TOP, + "page-2", + 20 + ))).thenThrow(providerTimeout()); + stubSuccessfulGeneration(); + + RecommendationCreateResponse response = recommendationService.generate(1L, 501L); + + verify(productCatalogPort, times(2)).search(any(ProductSearchQuery.class)); + assertThat(candidateIds(handedOffCandidates())).containsExactly(1); + assertThat(response.partial()).isTrue(); + assertThat(response.warnings()).containsExactly("PROVIDER_PARTIAL_FAILURE"); + } + + @Test + void boundedRetrievalNeverRequestsAPageThree() { + recommendationService = recommendationService(TEST_CANDIDATE_LIMIT, 2); + RecommendationInputSnapshot input = styleOnlyInput(); + when(inputReader.read(1L, 501L)).thenReturn(input); + when(productCatalogPort.search(new ProductSearchQuery( + "", + ProductCategory.TOP, + null, + 20 + ))).thenReturn(new ProductSearchResult(List.of(), "page-2")); + when(productCatalogPort.search(new ProductSearchQuery( + "", + ProductCategory.TOP, + "page-2", + 20 + ))).thenReturn(new ProductSearchResult(List.of(), "page-3")); + when(queryService.findByReportId(1L, 501L)).thenReturn(currentResult()); + + recommendationService.generate(1L, 501L); + + verify(productCatalogPort, times(2)).search(any(ProductSearchQuery.class)); + verify(productCatalogPort, never()).search(new ProductSearchQuery( + "", + ProductCategory.TOP, + "page-3", + 20 + )); + } + + @Test + void publicConstructorRemainsPageOneOnlyWhenACursorExists() { + RecommendationInputSnapshot input = styleOnlyInput(); + ExternalProductCandidate firstPageCandidate = candidate(1, null, true); + when(inputReader.read(1L, 501L)).thenReturn(input); + when(productCatalogPort.search(new ProductSearchQuery( + "", + ProductCategory.TOP, + null, + 20 + ))).thenReturn(new ProductSearchResult(List.of(firstPageCandidate), "page-2")); + stubSuccessfulGeneration(); + + recommendationService.generate(1L, 501L); + + verify(productCatalogPort, times(1)).search(any(ProductSearchQuery.class)); + verify(productCatalogPort, never()).search(new ProductSearchQuery( + "", + ProductCategory.TOP, + "page-2", + 20 + )); + assertThat(candidateIds(handedOffCandidates())).containsExactly(1); + } + + @Test + void boundedRetrievalUsesAtMostTwoCallsPerQueryFamilyWithoutRetry() { + recommendationService = recommendationService(TEST_CANDIDATE_LIMIT, 2); + RecommendationInputSnapshot input = multiFamilyInput(); + when(inputReader.read(1L, 501L)).thenReturn(input); + when(productCatalogPort.search(any(ProductSearchQuery.class))) + .thenAnswer(invocation -> { + ProductSearchQuery query = invocation.getArgument(0); + return new ProductSearchResult( + List.of(), + query.cursor() == null ? "page-2" : "page-3" + ); + }); + when(queryService.findByReportId(1L, 501L)).thenReturn(currentResult()); + + recommendationService.generate(1L, 501L); + + ArgumentCaptor queryCaptor = + ArgumentCaptor.forClass(ProductSearchQuery.class); + verify(productCatalogPort, times(6)).search(queryCaptor.capture()); + assertThat(queryCaptor.getAllValues()) + .extracting(ProductSearchQuery::cursor) + .containsExactly(null, "page-2", null, "page-2", null, "page-2"); + } + + @Test + void allPageOneFailuresAttemptEveryFamilyOnceBeforeTerminalError() { + recommendationService = recommendationService(TEST_CANDIDATE_LIMIT, 2); + when(inputReader.read(1L, 501L)).thenReturn(multiFamilyInput()); + when(productCatalogPort.search(any(ProductSearchQuery.class))) + .thenThrow(providerTimeout()); + + assertThatThrownBy(() -> recommendationService.generate(1L, 501L)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException) exception).getErrorCode()) + .isEqualTo(ErrorCode.PRODUCT_PROVIDER_UNAVAILABLE); + ArgumentCaptor queryCaptor = + ArgumentCaptor.forClass(ProductSearchQuery.class); + verify(productCatalogPort, times(3)).search(queryCaptor.capture()); + assertThat(queryCaptor.getAllValues()) + .extracting(ProductSearchQuery::cursor) + .containsOnlyNulls(); + verify(setWriter, never()).replaceCurrentSet(any(), any(), any()); + } + @Test void ranksCandidatesByWeightedSimilarityScore() { RecommendationInputSnapshot input = new RecommendationInputSnapshot( @@ -859,6 +1089,72 @@ private static RecommendationInputSnapshot input() { ); } + private static RecommendationInputSnapshot styleOnlyInput() { + return new RecommendationInputSnapshot( + 501L, + 1L, + 1, + ProductCategory.TOP, + List.of(new TagInput(10L, "스타일", TagType.STYLE)) + ); + } + + private static RecommendationInputSnapshot multiFamilyInput() { + return new RecommendationInputSnapshot( + 501L, + 1L, + 1, + ProductCategory.TOP, + List.of( + new TagInput(10L, "A라인", TagType.SILHOUETTE), + new TagInput(20L, "브이넥", TagType.DETAIL) + ) + ); + } + + private void stubSuccessfulGeneration() { + when(candidateMapper.category(any())).thenReturn(ProductCategory.TOP); + when(materializationService.materializeForRecommendation(any())) + .thenAnswer(invocation -> { + ExternalProductCandidate candidate = invocation.getArgument(0); + return new RecommendationMaterializationResult( + Long.parseLong(candidate.providerRef().externalProductId()), + true + ); + }); + when(queryService.findByReportId(1L, 501L)).thenReturn(currentResult()); + } + + private List handedOffCandidates() { + @SuppressWarnings("unchecked") + ArgumentCaptor> candidatesCaptor = + ArgumentCaptor.forClass(List.class); + verify(browserRerankingHandoffService).create( + org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(ProductCategory.TOP), + any(), + candidatesCaptor.capture() + ); + return candidatesCaptor.getValue(); + } + + private static List candidateIds( + List candidates + ) { + return candidates.stream() + .map(candidate -> Integer.parseInt( + candidate.providerRef().externalProductId() + )) + .toList(); + } + + private static ProductProviderException providerTimeout() { + return new ProductProviderException( + "fixture", + ProductProviderFailure.TIMEOUT + ); + } + private static RecommendationResultResponse currentResult() { List groups = Arrays.stream(ProductCategory.values()) .map(category -> new RecommendationGroupResponse(category, List.of()))