diff --git a/docs/api-contract.md b/docs/api-contract.md index 4ebfe0f..130054b 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -70,6 +70,7 @@ | `MALFORMED_REDIRECT` | `PageFetchException.malformedRedirect` | | `PERMANENT_UPSTREAM` | `PageFetchException.permanentUpstreamError` — 대상 500/501 (봇 차단 추정) | | `EMPTY_SHELL` | `PageFetchException.emptyShell` — fetch 는 2xx 지만 본문이 데이터 없는 CSR 셸(파싱 no-data 를 재분류). 헤드리스 에스컬레이션 대상이라, 헤드리스가 켜진 구성에선 헤드리스 결과가 대신 응답된다 | +| `NO_EXTRACTABLE_CONTENT` | `ProductSnapshotException.noExtractableContent` — 본문에 가시 텍스트도 데이터 script 도 없어 LLM 을 부르지 않고 확정(빈 셸 환각 차단). plain 경로는 EMPTY_SHELL 재분류가 선행하므로 사실상 헤드리스 렌더 결과까지 셸일 때 나온다 | | `LLM_INVALID_RESPONSE` | `GeminiApiException` clientError/parseError/noTextPart — 재시도 무의미한 LLM 실패 | | `INVALID_URL` | url 형식·스킴 위반. 정상 흐름에선 호출자가 동기 검증해 도달하지 않는다(방어) | diff --git a/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java b/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java index 8def1ca..2cd6ec7 100644 --- a/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java +++ b/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java @@ -15,6 +15,12 @@ public enum ExtractionErrorCode { PERMANENT_UPSTREAM, /** fetch 는 2xx 였지만 본문이 데이터 없는 CSR 셸 — 파싱 no-data 를 escalatable 로 재분류한 것(EmptyShellDetector). */ EMPTY_SHELL, + /** + * 본문에 가시 텍스트도 데이터 script 도 없어 LLM 을 부르지 않고 확정한 것(LlmInputGate) — 빈 입력의 LLM 은 + * 실존하지 않는 상품을 지어낸다(환각). EMPTY_SHELL(일시, 에스컬레이션 대상)과 달리 확정이다 — plain 경로는 + * 셸 재분류가 선행하므로, 이 code 는 사실상 헤드리스 렌더 결과까지 셸일 때 표면화된다. + */ + NO_EXTRACTABLE_CONTENT, LLM_INVALID_RESPONSE, INVALID_URL, UPSTREAM_ERROR, diff --git a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshotException.java b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshotException.java index d627953..d0854a8 100644 --- a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshotException.java +++ b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshotException.java @@ -3,7 +3,7 @@ import com.depromeet.piki.extractor.common.exception.ExtractionErrorCode; import com.depromeet.piki.extractor.common.exception.ExtractionException; -/** 둘 다 호출자가 재시도해도 얻을 것이 없는 사유라 확정 실패로 답한다. */ +/** 전부 호출자가 재시도해도 얻을 것이 없는 사유라 확정 실패로 답한다. */ public final class ProductSnapshotException extends ExtractionException { private ProductSnapshotException(String message, ExtractionErrorCode code) { @@ -18,4 +18,13 @@ public static ProductSnapshotException notProductPage() { public static ProductSnapshotException untrustworthyValue() { return new ProductSnapshotException("상품 정보를 확인하지 못했어요.", ExtractionErrorCode.UNTRUSTWORTHY_VALUE); } + + /** + * LLM 에 넘겨도 지어낼 뿐인 빈 문서(LlmInputGate) — NOT_PRODUCT_PAGE 와 code 를 나누는 이유는 호출자 + * 관측이다: "사용자가 상품 아닌 링크를 넣음"과 "몰을 우리가 못 읽음"이 한 code 로 섞이면 후자의 빈도를 + * 추적할 수 없다. + */ + public static ProductSnapshotException noExtractableContent() { + return new ProductSnapshotException("상품 정보를 읽을 수 없는 페이지예요.", ExtractionErrorCode.NO_EXTRACTABLE_CONTENT); + } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/DataScripts.java b/src/main/java/com/depromeet/piki/extractor/extraction/DataScripts.java new file mode 100644 index 0000000..1b5ef4e --- /dev/null +++ b/src/main/java/com/depromeet/piki/extractor/extraction/DataScripts.java @@ -0,0 +1,34 @@ +package com.depromeet.piki.extractor.extraction; + +import java.util.Objects; +import org.jsoup.nodes.Document; + +/** + * LLM 입력에 남길 "데이터 script" 판정의 single source. sanitize({@link GeminiHtmlExtractor})가 보존하는 것과 + * 게이트({@link LlmInputGate})가 "LLM 이 읽을 수 있다"고 보는 것이 같은 판정을 공유해야 한다 — 두 벌이 되면 + * "sanitize 는 남기는데 게이트는 없다고 판정"하는 식으로 조용히 어긋난다. + */ +final class DataScripts { + + private DataScripts() { + } + + /** + * type 이 없거나 {@code text/javascript} 인 JS 코드 script 는, 가격이 inline 변수 + * ({@code window.__PRELOADED_STATE__} 등)에 묻혀 있더라도 코드 덩어리라 토큰만 먹고 오판을 부르므로 데이터로 + * 치지 않는다 — 그런 거대 state 사이트는 LLM 토큰 상한에도 안 맞아, 전용 파서가 답이다. 남기는 것은 + * schema.org JSON-LD 와 일반 JSON data island(Next.js 의 {@code __NEXT_DATA__} 등)뿐이다. prefix 비교라 + * {@code ;charset=} 파라미터 변형에도 정확하다. + */ + static boolean isDataScript(String type) { + String normalized = type.trim(); + return normalized.regionMatches(true, 0, "application/ld+json", 0, "application/ld+json".length()) + || normalized.regionMatches(true, 0, "application/json", 0, "application/json".length()); + } + + static boolean hasDataScript(Document document) { + Objects.requireNonNull(document, "document"); + return document.select("script").stream() + .anyMatch(element -> isDataScript(element.attr("type"))); + } +} diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/GeminiHtmlExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/GeminiHtmlExtractor.java index f236c97..cd73941 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/GeminiHtmlExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/GeminiHtmlExtractor.java @@ -38,32 +38,21 @@ public ProductSnapshot extract(Document document, ProductLink link, String model * LLM 입력에서 토큰 낭비·오판 요소(JS {@code """; + /** 구조화 데이터는 없지만 가시 텍스트가 충분한 페이지 — LLM 게이트(LlmInputGate)를 통과해 fallback 이 돈다. */ + private static final String NO_STRUCTURED_HTML = "" + + "구조화 데이터 없이 가시 텍스트로만 상품을 설명하는 상세 페이지 본문. ".repeat(3) + + ""; + @Autowired private WebApplicationContext context; @@ -81,7 +86,7 @@ void structuredSuccess() throws Exception { @DisplayName("구조화 데이터가 없으면 LLM fallback 으로 추출해 200 을 반환한다") void llmFallbackSuccess() throws Exception { stubGeminiClient.reset(); - stubPageFetcher.build = link -> PageContent.of(link, "구조화 없음"); + stubPageFetcher.build = link -> PageContent.of(link, NO_STRUCTURED_HTML); stubGeminiClient.build = request -> new GeminiExtractionResult(true, "엘엘엠 상품", 12000, "KRW", "https://cdn.example.com/llm.png"); @@ -146,27 +151,30 @@ void notProductPage() throws Exception { } @Test - @DisplayName("파싱 no-data 인데 본문이 CSR 셸이면 422 EMPTY_SHELL 로 재분류된다 (헤드리스 꺼진 구성의 표면 계약)") + @DisplayName("CSR 셸은 LLM 호출 없이 422 EMPTY_SHELL 로 재분류된다 (헤드리스 꺼진 구성의 표면 계약)") void emptyShellReclassified() throws Exception { stubGeminiClient.reset(); - // 카카오 톡딜 store.kakao.com 실측 축약형 — script 뿐인 SPA 셸. 헤드리스가 켜진 구성이면 escalatable 이라 - // 헤드리스 결과가 대신 응답되고, 꺼진 기본 구성(이 컨텍스트)에선 EMPTY_SHELL 이 그대로 표면화된다. + // 카카오 톡딜 store.kakao.com 실측 축약형 — script 뿐인 SPA 셸. 게이트(LlmInputGate)가 LLM 호출 전에 + // 끊고, plain 전략이 escalatable 로 재분류한다. 헤드리스가 켜진 구성이면 헤드리스 결과가 대신 응답되고, + // 꺼진 기본 구성(이 컨텍스트)에선 EMPTY_SHELL 이 그대로 표면화된다. stubPageFetcher.build = link -> PageContent.of(link, "
"); - stubGeminiClient.build = request -> new GeminiExtractionResult(false, null, null, null, null); mockMvc().perform(post("/internal/extractions/link") .contentType(MediaType.APPLICATION_JSON) .content(body("https://store.kakao.com/kgcmall/products/445653929"))) .andExpect(status().isUnprocessableEntity()) .andExpect(jsonPath("$.code").value("EMPTY_SHELL")); + + // 빈 입력의 LLM 은 환각을 만든다 — 셸에서 LLM 이 불리지 않는 것 자체가 계약이다. + assertEquals(0, stubGeminiClient.invocations()); } @Test @DisplayName("추출 결과가 READY 필수 필드(name·price·imageUrl)를 못 채우면 422 UNTRUSTWORTHY_VALUE 를 반환한다") void incompleteExtraction() throws Exception { stubGeminiClient.reset(); - stubPageFetcher.build = link -> PageContent.of(link, "구조화 없음"); + stubPageFetcher.build = link -> PageContent.of(link, NO_STRUCTURED_HTML); stubGeminiClient.build = request -> new GeminiExtractionResult(true, "이미지 없는 상품", 5000, "KRW", null); mockMvc().perform(post("/internal/extractions/link") @@ -226,7 +234,7 @@ void passesModelHint() throws Exception { stubGeminiClient.reset(); stubGeminiClient.build = request -> new GeminiExtractionResult( true, "LLM 상품", 12_000, "KRW", "https://cdn.example.com/llm.png"); - stubPageFetcher.build = link -> PageContent.of(link, "구조화 없음"); + stubPageFetcher.build = link -> PageContent.of(link, NO_STRUCTURED_HTML); mockMvc().perform(post("/internal/extractions/link") .contentType(MediaType.APPLICATION_JSON) @@ -243,7 +251,7 @@ void allowsMissingModel() throws Exception { stubGeminiClient.reset(); stubGeminiClient.build = request -> new GeminiExtractionResult( true, "LLM 상품", 12_000, "KRW", "https://cdn.example.com/llm.png"); - stubPageFetcher.build = link -> PageContent.of(link, "구조화 없음"); + stubPageFetcher.build = link -> PageContent.of(link, NO_STRUCTURED_HTML); mockMvc().perform(post("/internal/extractions/link") .contentType(MediaType.APPLICATION_JSON) diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java index 8ed9346..642aea9 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java @@ -48,10 +48,9 @@ private DefaultProductLinkExtractor extractorFetching(String html) { } @Test - @DisplayName("파싱 no-data 인데 본문이 CSR 셸이면 escalatable EMPTY_SHELL 로 재분류한다") + @DisplayName("CSR 셸은 LLM 호출 없이 escalatable EMPTY_SHELL 로 재분류한다") void reclassifiesNoDataOnEmptyShell() { - stubGemini.build = request -> new GeminiExtractionResult(false, null, null, null, null); - + // 게이트(LlmInputGate)가 LLM 전에 끊으므로 stub 세팅이 없어야 정상 — LLM 이 불리면 default throw 로 드러난다. PageFetchException e = assertThrows( PageFetchException.class, () -> extractorFetching(SHELL_HTML).extract(link, null) @@ -62,6 +61,7 @@ void reclassifiesNoDataOnEmptyShell() { assertTrue(e.permanent()); // 원래의 파싱 실패를 cause 로 보존한다 — 에스컬레이션 후에도 최초 사유를 로그에서 추적할 수 있어야 한다. assertInstanceOf(ProductSnapshotException.class, e.getCause()); + assertEquals(0, stubGemini.invocations()); } @Test @@ -78,12 +78,16 @@ void propagatesNoDataOnContentRichPage() { } @Test - @DisplayName("LLM 일시 오류는 셸 페이지여도 재분류하지 않고 그대로 전파한다") + @DisplayName("LLM 일시 오류는 셸 수준 페이지여도 재분류하지 않고 그대로 전파한다") void propagatesLlmUpstreamFailureUntouched() { + // 데이터 script 가 있어 게이트는 통과하되(LLM 이 실제로 불린다) EmptyShellDetector 기준으로는 여전히 + // 셸인 페이지 — 재분류 catch 가 GeminiApiException 을 EMPTY_SHELL 로 오염시키지 않는지가 관심사다. + String dataIslandShell = "
"; stubGemini.build = request -> { throw GeminiApiException.upstreamError(new RuntimeException("gemini 503")); }; - assertThrows(GeminiApiException.class, () -> extractorFetching(SHELL_HTML).extract(link, null)); + assertThrows(GeminiApiException.class, () -> extractorFetching(dataIslandShell).extract(link, null)); } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java index b0a94a6..3ec96ad 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java @@ -3,8 +3,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.depromeet.piki.extractor.common.exception.ExtractionErrorCode; import com.depromeet.piki.extractor.domain.ProductLink; import com.depromeet.piki.extractor.domain.ProductSnapshot; +import com.depromeet.piki.extractor.domain.ProductSnapshotException; import com.depromeet.piki.extractor.extraction.gemini.GeminiExtractionResult; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderException; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderer; @@ -60,7 +62,10 @@ void structuredDataFromRenderedHtml() { @DisplayName("렌더된 HTML 에 구조화 데이터가 없으면 같은 HTML 로 LLM fallback 을 탄다") void llmFallbackOnRenderedHtml() { stubGemini.build = request -> new GeminiExtractionResult(true, "엘엘엠 상품", 50_000, "KRW", "https://cdn.example.com/i.png"); - HeadlessProductLinkExtractor extractor = extractorWith(l -> PageContent.of(l, "구조화 없음")); + HeadlessProductLinkExtractor extractor = extractorWith(l -> PageContent.of( + l, + "" + "구조화 데이터 없이 렌더된 상품 상세 설명 텍스트. ".repeat(3) + "" + )); ProductSnapshot snapshot = extractor.extract(link, null); @@ -68,6 +73,25 @@ void llmFallbackOnRenderedHtml() { assertEquals(1, stubGemini.invocations()); } + @Test + @DisplayName("렌더 결과까지 셸이면 LLM 호출 없이 NO_EXTRACTABLE_CONTENT 가 그대로 전파된다 — plain 과 달리 재분류가 없다") + void renderedShellFailsPermanentlyWithoutLlm() { + // 헤드리스는 마지막 수단이라 셸 재분류(escalation)가 없다 — 게이트의 확정 실패가 곧 최종 응답이 된다. + // 에이블리 mobile.* 환각 사고(렌더 후에도 가시 텍스트 0자)가 이 경로로 닫힌다. + HeadlessProductLinkExtractor extractor = extractorWith(l -> PageContent.of( + l, + "
" + )); + + ProductSnapshotException e = assertThrows( + ProductSnapshotException.class, + () -> extractor.extract(link, null) + ); + + assertEquals(ExtractionErrorCode.NO_EXTRACTABLE_CONTENT, e.code()); + assertEquals(0, stubGemini.invocations()); + } + @Test @DisplayName("렌더 실패는 그대로 전파된다 — 계약 번역은 렌더러가 이미 끝냈다") void renderFailurePropagates() { diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipelineGateTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipelineGateTest.java new file mode 100644 index 0000000..0b7f723 --- /dev/null +++ b/src/test/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipelineGateTest.java @@ -0,0 +1,88 @@ +package com.depromeet.piki.extractor.extraction; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.depromeet.piki.extractor.common.exception.ExtractionErrorCode; +import com.depromeet.piki.extractor.domain.ProductLink; +import com.depromeet.piki.extractor.domain.ProductSnapshot; +import com.depromeet.piki.extractor.domain.ProductSnapshotException; +import com.depromeet.piki.extractor.extraction.gemini.GeminiExtractionResult; +import com.depromeet.piki.extractor.extraction.structured.StructuredDataExtractor; +import com.depromeet.piki.extractor.support.StubGeminiClient; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +/** + * 파이프라인의 게이트 라우팅 — "볼 게 없는 문서는 LLM 을 부르지 않고 확정 실패, 볼 게 있으면 기존 LLM + * fallback" 을 검증한다. 게이트 판정 자체의 분기 망라는 {@link LlmInputGateTest} 가, Prometheus 라벨 키 + * 정합은 {@link HtmlSnapshotPipelineMetricTest} 가 진다. 외부 경계 GeminiClient 만 stub. + */ +class HtmlSnapshotPipelineGateTest { + + private static final String SHELL_HTML = + "
"; + + private final ProductLink link = ProductLink.parse("https://mobile.a-bly.com/goods/70580148"); + + private final StubGeminiClient stubGemini = new StubGeminiClient(); + + private HtmlSnapshotPipeline pipeline(SimpleMeterRegistry registry) { + return new HtmlSnapshotPipeline( + new StructuredDataExtractor(new ObjectMapper()), + new GeminiHtmlExtractor(stubGemini), + registry + ); + } + + @Test + @DisplayName("볼 게 없는 셸은 LLM 호출 없이 NO_EXTRACTABLE_CONTENT 확정 실패로 끊는다") + void shellSkipsLlmAndFailsPermanently() { + // stub 의 default build 는 throw(IllegalStateException) — 게이트가 새면 예외 타입부터 달라져 드러난다. + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + ProductSnapshotException e = assertThrows( + ProductSnapshotException.class, + () -> pipeline(registry).extract(PageContent.of(link, SHELL_HTML), "fetch=0ms", null) + ); + + assertEquals(ExtractionErrorCode.NO_EXTRACTABLE_CONTENT, e.code()); + assertEquals(0, stubGemini.invocations()); + } + + @Test + @DisplayName("게이트 발동 건은 via=skipped_shell 로만 집계되고 via=llm 은 오르지 않는다") + void gateCountsAsSkippedShellNotLlm() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + assertThrows( + ProductSnapshotException.class, + () -> pipeline(registry).extract(PageContent.of(link, SHELL_HTML), "fetch=0ms", null) + ); + + assertEquals( + 1.0, + registry.counter("product.extract", "via", "skipped_shell", "reason", "no_data").count() + ); + assertEquals(0.0, registry.counter("product.extract", "via", "llm", "reason", "no_data").count()); + } + + @Test + @DisplayName("가시 텍스트가 없어도 데이터 script 가 있으면 LLM fallback 이 그대로 돈다") + void dataIslandStillReachesLlm() { + // 구조화 파서가 못 읽는 모양의 JSON(비 schema.org)이라 Miss 로 내려가고, 게이트는 데이터 script 존재로 통과. + String hydrationShell = "
"; + stubGemini.build = request -> + new GeminiExtractionResult(true, "하이드레이션 상품", 12_000, "KRW", "https://cdn.example.com/p.png"); + + ProductSnapshot snapshot = pipeline(new SimpleMeterRegistry()) + .extract(PageContent.of(link, hydrationShell), "fetch=0ms", null); + + assertEquals("하이드레이션 상품", snapshot.name()); + assertEquals(1, stubGemini.invocations()); + } +} diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipelineMetricTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipelineMetricTest.java index 9f7c7b6..ec60842 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipelineMetricTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipelineMetricTest.java @@ -1,8 +1,10 @@ package com.depromeet.piki.extractor.extraction; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.depromeet.piki.extractor.domain.ProductLink; +import com.depromeet.piki.extractor.domain.ProductSnapshotException; import com.depromeet.piki.extractor.extraction.gemini.GeminiExtractionResult; import com.depromeet.piki.extractor.extraction.structured.StructuredDataExtractor; import com.depromeet.piki.extractor.support.StubGeminiClient; @@ -14,10 +16,11 @@ import tools.jackson.databind.ObjectMapper; /** - * product.extract 카운터가 운영 레지스트리(Prometheus)에서 두 경로(structured/llm) 모두 scrape 되는지 검증한다. + * product.extract 카운터가 운영 레지스트리(Prometheus)에서 세 경로(structured/llm/skipped_shell) 모두 scrape + * 되는지 검증한다. * *

Prometheus(client 1.x)는 같은 메트릭 이름의 태그 키 집합이 다르면 뒤 시계열을 예외 없이 조용히 드롭하므로, - * 두 경로의 태그 키가 {@code {via, reason}} 으로 일치해야 둘 다 남는다. 일반 통합 테스트는 이 제약이 없는 + * 모든 경로의 태그 키가 {@code {via, reason}} 으로 일치해야 전부 남는다. 일반 통합 테스트는 이 제약이 없는 * SimpleMeterRegistry 를 주입해 이 회귀를 못 잡으므로, 운영과 같은 PrometheusMeterRegistry 로 파이프라인을 * 직접 구성하는 별도 분류로 둔다(외부 경계 GeminiClient 만 stub). */ @@ -26,8 +29,8 @@ class HtmlSnapshotPipelineMetricTest { private final ProductLink link = ProductLink.parse("https://shop.example.com/products/42"); @Test - @DisplayName("structured 와 llm 두 경로의 product_extract 시계열이 Prometheus scrape 에 모두 남는다") - void bothPathsSurviveInPrometheusScrape() { + @DisplayName("structured·llm·skipped_shell 세 경로의 product_extract 시계열이 Prometheus scrape 에 모두 남는다") + void allPathsSurviveInPrometheusScrape() { PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); StubGeminiClient stubGemini = new StubGeminiClient(); HtmlSnapshotPipeline pipeline = new HtmlSnapshotPipeline( @@ -42,7 +45,16 @@ void bothPathsSurviveInPrometheusScrape() { pipeline.extract(PageContent.of(link, structuredHtml), "fetch=0ms", null); stubGemini.build = request -> new GeminiExtractionResult(true, "엘엘엠", 2_000, null, null); - pipeline.extract(PageContent.of(link, "구조화 없음"), "fetch=0ms", null); + String textOnlyHtml = "" + + "구조화 데이터 없이 가시 텍스트로만 상품을 설명하는 본문. ".repeat(3) + + ""; + pipeline.extract(PageContent.of(link, textOnlyHtml), "fetch=0ms", null); + + String shellHtml = "

"; + assertThrows( + ProductSnapshotException.class, + () -> pipeline.extract(PageContent.of(link, shellHtml), "fetch=0ms", null) + ); String scrape = registry.scrape(); List lines = scrape.lines().filter(l -> l.startsWith("product_extract_total{")).toList(); @@ -55,5 +67,9 @@ void bothPathsSurviveInPrometheusScrape() { lines.stream().anyMatch(l -> l.contains("via=\"llm\"")), "llm 시계열이 scrape 에 있어야 한다 — 태그 키 불일치로 드롭되면 안 된다:\n" + scrape ); + assertTrue( + lines.stream().anyMatch(l -> l.contains("via=\"skipped_shell\"")), + "skipped_shell 시계열이 scrape 에 있어야 한다 — 태그 키 불일치로 드롭되면 안 된다:\n" + scrape + ); } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/LlmInputGateTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/LlmInputGateTest.java new file mode 100644 index 0000000..93786b2 --- /dev/null +++ b/src/test/java/com/depromeet/piki/extractor/extraction/LlmInputGateTest.java @@ -0,0 +1,73 @@ +package com.depromeet.piki.extractor.extraction; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.jsoup.Jsoup; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class LlmInputGateTest { + + @Test + @DisplayName("가시 텍스트도 데이터 script 도 없는 CSR 셸은 LLM 이 볼 게 없다고 판정한다") + void emptyShellHasNothingForLlm() { + // 에이블리 mobile.* 실측 축약형 — 렌더 후에도 스타일·JS 부트스트랩뿐, 가시 텍스트 0자. + String shell = "" + + "" + + "" + + "" + + "
"; + + assertTrue(LlmInputGate.hasNothingForLlm(Jsoup.parse(shell))); + } + + @Test + @DisplayName("가시 텍스트가 없어도 JSON-LD 가 있으면 LLM 이 읽을 수 있다 — 게이트를 통과시킨다") + void ldJsonScriptPassesGate() { + String html = ""; + + assertFalse(LlmInputGate.hasNothingForLlm(Jsoup.parse(html))); + } + + @Test + @DisplayName("가시 텍스트가 없어도 JSON data island 가 있으면 게이트를 통과시킨다 - hydration 전 SPA 구제") + void jsonDataIslandPassesGate() { + String html = "
"; + + assertFalse(LlmInputGate.hasNothingForLlm(Jsoup.parse(html))); + } + + @Test + @DisplayName("type 없는 JS 코드 script 는 데이터로 치지 않는다 — sanitize 가 지우는 것과 같은 판정") + void typelessJsScriptDoesNotPassGate() { + String html = "" + + "" + + ""; + + assertTrue(LlmInputGate.hasNothingForLlm(Jsoup.parse(html))); + } + + @Test + @DisplayName("공백뿐인 body 는 발동하고 가시 텍스트 한 글자라도 있으면 통과한다 (경계 - 길이 임계 없음)") + void thresholdBoundary() { + // jsoup text() 는 공백을 정규화하므로 공백만 있는 body 는 "전혀 없음"이다. + assertTrue(LlmInputGate.hasNothingForLlm(Jsoup.parse(" "))); + assertFalse(LlmInputGate.hasNothingForLlm(Jsoup.parse("a"))); + } + + @Test + @DisplayName("가시 텍스트만으로 이뤄진 미니멀 상품 페이지는 통과한다 - 오탐이 확정 실패로 굳는 축이라 사실 판정만") + void minimalVisibleTextPagePassesGate() { + // 데이터가 script 가 아니라 가시 텍스트에 있는 미니멀 페이지 — EmptyShellDetector(300자) 기준으로는 + // 셸이지만, LLM 은 이 텍스트를 읽을 수 있으므로 게이트가 막으면 안 된다. + String minimal = "

알레 여리핏 골지 살안타 라운드 가디건

" + + "

15,410원

"; + + assertFalse(LlmInputGate.hasNothingForLlm(Jsoup.parse(minimal))); + } +}