From bfa43b02d9b9b71c883bde9b8866b8199cd7f662 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:16:03 +0000 Subject: [PATCH] =?UTF-8?q?=ED=97=A4=EB=93=9C=EB=A6=AC=EC=8A=A4=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EC=9D=98=20LLM=20=ED=9B=84=EB=B3=B4=20?= =?UTF-8?q?=ED=99=89=20=EC=8B=A4=ED=8C=A8=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TeamPiKi/ops#5 ## 한눈에 ### 헤드리스 경로가 고른 페이지를 실패 때도 기록 - 헤드리스 경로는 브라우저가 거쳐 간 페이지 여럿 중 하나를 골라 LLM 에 넘김 - **이전:** 추출이 실패하면 요청 URL 만 로그에 남음 - 어느 페이지를 골랐는지 알 수 없음 - 추출이 실패한 경우에만 고른 페이지의 경로·status·분량을 한 줄로 기록 - 성공 경로의 로그와 동작은 그대로 ## 무엇을 ### 기록 항목 | 항목 | 값 | 가르려는 판정 | |---|---|---| | `code` | 실패 code | 상품 아님·빈 문서·이상값 구분 | | `hop` | 고른 홉 순번·전체 홉 수 | 마지막 홉 선택 여부 | | `status` | 고른 홉의 status | 차단 status 우회 여부 | | `source` | `dom` 또는 `body` | 렌더 결과·서버 원문 구분 | | `html` | 가지친 뒤 분량 | 챌린지 셸·상품 상세 구분 | | `hopUrl` | `safeLogString` 의 host·path | 상품 경로 착지 여부 | ### 가르려는 판정 - 헤드리스 승격 뒤 LLM 이 상품 아님으로 닫는 실패의 재발 - **원인:** 차단 페이지가 신호를 피해 후보로 남았는지 확인 불가 - **원인:** 상품 홉을 두고 다른 홉을 골랐는지 확인 불가 - 작고 상품 경로가 아닌 홉이면 차단 판정 보강 대상 - 크고 상품 경로인 홉이면 후보 선택·LLM 입력 쪽 결함 대상 ### 범위 - `HeadlessProductLinkExtractor.java:83` 의 고른 후보 추출만 감쌈 - `ProductSnapshotException` 을 잡아 기록한 뒤 그대로 다시 던짐 - 반환값·분기·예외·재시도 변경 없음 - 메트릭 추가 없음 - title 은 페이지 본문이라 기록하지 않음 - 기존 테스트 중 이 로그 문자열을 단언하는 테스트 없음 ### 확인 - `./gradlew test --tests '*Headless*' --tests '*Fallback*' --tests '*TestConvention*'` 통과 --- .../HeadlessProductLinkExtractor.java | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java index 78f830f..06194cd 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java @@ -2,6 +2,7 @@ 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.headless.HeadlessRenderException; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderer; import com.depromeet.piki.extractor.extraction.headless.RenderedHop; @@ -40,7 +41,7 @@ public class HeadlessProductLinkExtractor implements LinkExtractionStrategy { private record Candidate(RenderedHop hop, String html) { } - private record Chosen(PageContent page, StructuredExtraction result) { + private record Chosen(Candidate candidate, PageContent page, StructuredExtraction result) { } @Override @@ -72,15 +73,15 @@ public ProductSnapshot extract(ProductLink link, boolean authorized, String mode continue; } if (first == null) { - first = new Chosen(page, result); + first = new Chosen(candidate, page, result); } if (fallback == null && !LlmInputGate.hasNothingForLlm(page.document())) { - fallback = new Chosen(page, result); + fallback = new Chosen(candidate, page, result); } } Chosen chosen = fallback != null ? fallback : first; if (chosen != null) { - return htmlSnapshotPipeline.extract(chosen.page(), chosen.result(), timing, model); + return extractChosen(chosen, link, hops, timing, model); } if (blocked) { log.warn("headless hops unusable blocked=true hops={} url={}", hops.size(), link.safeLogString()); @@ -94,6 +95,37 @@ public ProductSnapshot extract(ProductLink link, boolean authorized, String mode throw HeadlessRenderException.upstream("렌더 HTML 이 없다", null); } + /** + * 고른 홉이 파이프라인에서 실패하면 어느 홉이 LLM 에 갔는지 남긴다. 성공 쪽은 extract 원장이 지지만 실패 쪽은 + * 요청 URL 만 남아, 차단 페이지가 신호를 피해 후보로 남았는지와 상품 홉을 두고 다른 홉을 골랐는지를 가를 수 + * 없었다. title 은 페이지 본문이라 남기지 않고 경로·status·분량으로만 가른다. + */ + private ProductSnapshot extractChosen( + Chosen chosen, + ProductLink link, + List hops, + String timing, + String model + ) { + try { + return htmlSnapshotPipeline.extract(chosen.page(), chosen.result(), timing, model); + } catch (ProductSnapshotException e) { + RenderedHop hop = chosen.candidate().hop(); + log.info( + "headless chosen hop failed code={} hop={}/{} status={} source={} html={}chars hopUrl={} url={}", + e.code(), + hops.indexOf(hop) + 1, + hops.size(), + hop.status(), + chosen.candidate().html().equals(hop.dom()) ? "dom" : "body", + chosen.page().retainedChars(), + chosen.page().finalUrl().safeLogString(), + link.safeLogString() + ); + throw e; + } + } + private static List candidates(List hops) { List candidates = new ArrayList<>(); for (RenderedHop hop : hops.reversed()) {