Skip to content

빈 셸을 LLM 에 넘기지 않는 게이트 추가 - CSR 셸 환각 차단 - #26

Merged
m-a-king merged 2 commits into
mainfrom
feat/llm-shell-gate
Aug 9, 2026
Merged

빈 셸을 LLM 에 넘기지 않는 게이트 추가 - CSR 셸 환각 차단#26
m-a-king merged 2 commits into
mainfrom
feat/llm-shell-gate

Conversation

@m-a-king

@m-a-king m-a-king commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Situation

  • dev 에서 에이블리 링크가 "성공인데 이미지가 안 뜨는" 증상으로 조사가 시작됐다. 실측 결과 이미지만이 아니라 응답 전체가 가짜였다: mobile.a-bly.com 상품 하나를 15회 호출하니 200 이 8회 나왔고, 8회 전부 서로 다른 실존하지 않는 상품이었다 (이미지 URL 은 전부 NXDOMAIN 또는 403, core dev DB 에 READY 로 저장까지 됨).
  • 원인은 페이지가 비어 있다는 것이다. mobile.a-bly.com 은 메타태그가 런타임에 채워지는 react-native-web 셸이라 헤드리스로 렌더해도 가시 텍스트가 0자다 (정상 m.a-bly.com 은 렌더 후 61,496자). 파싱 no-data + CSR 셸을 헤드리스 에스컬레이션 대상으로 재분류 #22 는 "1차 fetch 가 빈 셸이면 헤드리스로 올린다"까지만 하고, 헤드리스 결과까지 셸이면 그대로 LLM 으로 흘렀다.
  • 빈 입력을 받은 LLM 은 "모르겠다" 대신 그럴듯한 상품을 지어내고, 지어낸 값은 형식이 유효해 (https 이미지, 양수 가격) 응답 경계와 호출자(core) 검증을 전부 통과한다.

Task

  • LLM 이 볼 게 아무것도 없는 HTML 을 LLM 에 넘기지 않고 확정 실패로 끊는다. 환각 차단과 무의미한 LLM 호출 절약이 같은 지점에서 해결된다 (셸 한 건당 LLM 2회: plain 1회 + 헤드리스 1회가 전부 사라진다).

Action

판정 설계

  • 게이트 조건: 가시 텍스트가 전혀 없음(0자), 그리고 데이터 script(JSON-LD·JSON data island) 부재. 두 조건을 모두 만족할 때만 발동한다.
  • 왜 데이터 script 조건이 붙나: LLM 입력 sanitize 는 JSON-LD 와 __NEXT_DATA__ 류 data island 를 일부러 보존한다. 파서가 놓친 사이트를 LLM 이 건지는 유일한 근거인데, 가시 텍스트를 재는 jsoup text() 는 script 내용을 세지 않는다. 이 조건이 없으면 데이터는 JSON 에 다 있고 화면만 아직 안 그려진 페이지(hydration 전 SPA)를 죽인다.
  • 왜 기존 300자 임계를 재사용하지 않나: 오탐 비용이 다르다.
판정 오탐 비용
에스컬레이션 (기존 EmptyShellDetector, 무수정) 가시 텍스트 300자 미만 헤드리스 1회 낭비 (fail-open)
LLM 게이트 (신규 LlmInputGate) 가시 텍스트 전무 + 데이터 script 부재 확정 422 로 굳음 (hard-fail)
  • 왜 길이 임계를 아예 두지 않나: 처음엔 "사실상 아무것도 없음" 마진으로 50자를 뒀으나, 0-300자 구간에 실측된 정상 페이지가 없어 마진 크기 자체를 정당화할 데이터가 없다는 리뷰로 제거했다. 판단(짧다)을 사실(전혀 없다)로 치환해 임계값 논쟁을 없앤다. 실측된 사고 케이스는 전부 렌더 후 0자라 차단 효과는 같다.
선택지 잡는 것 잃는 것
0자 (채택) 실측 사고 전부. 임계 자의성 제거 몇십 자 보일러플레이트만 있는 미지의 셸은 LLM 으로 흘러 환각 가능성 잔존 (via=llm 로그의 html 크기·host 분포로 감시)
50자 마진 미지의 보일러플레이트 셸 선제 차단 마진 크기가 자의적. 40자대 실존 미니멀 페이지가 있다면 영구 422 오탐

구현

  • LlmInputGate (신규): 게이트 판정. 파이프라인이 구조화 파싱 Miss 직후, LLM 호출 전에 묻는다.
  • DataScripts (신규): "LLM 입력에 남길 데이터 script 인가" 판정을 GeminiHtmlExtractor 의 private 에서 이관. sanitize 가 보존하는 것과 게이트가 인정하는 것이 두 벌이 되면 조용히 어긋나므로 single source 로 강제했다.
  • HtmlSnapshotPipeline: 판정 -> 집계 -> 실행이 위에서 아래로 읽히도록 분기별 메서드로 분해. 카운터 viastructured / skipped_shell / llm 3분기로 재구성하되 발행 지점 한 곳과 {via, reason} 키 집합을 유지했다 (키가 갈라지면 Prometheus 가 뒤 시계열을 조용히 드롭).
  • NO_EXTRACTABLE_CONTENT (신규 code, 422 확정): NOT_PRODUCT_PAGE 와 나눈 이유는 호출자 관측이다. "사용자가 상품 아닌 링크를 넣음"과 "몰을 우리가 못 읽음"이 한 code 로 섞이면 후자의 빈도를 추적할 수 없다. EMPTY_SHELL 재사용도 배제했다 (그 code 는 일시·에스컬레이션 의미로 이미 쓰인다).

전략별 귀결 (전략 코드 무수정)

검토 후 뒤로 뺀 것

  • 이미지 grounding (LLM 이 낸 이미지 URL 이 원문에 실재하는지 대조): 통과 검증 표본이 1건뿐인데 차단은 영구 422 이고, JSON 이스케이프(\/)·http-only 몰 등 오탐 사각이 구체적으로 확인돼 별도 PR 로 분리했다. shadow 모드(관측만)로 먼저 배포해 오탐률을 실측한 뒤 enforce 로 올린다.
  • mobile.a-bly.comm.a-bly.com 으로 접는 호스트 정규화: 몰마다 하나씩 등록해야 하는 대증요법이라 보류.

Result

  • 셸 페이지의 귀결이 "무작위 환각 200 또는 무작위 422"에서 "결정적 422 (plain 은 헤드리스 승격 후)"로 바뀐다. 배포 후 mobile.a-bly.com/goods/70580148 반복 호출로 200 이 나오지 않는 것을 재검증한다.
  • via=skipped_shell 카운터가 게이트가 아낀 LLM 호출 수를 그대로 보여준다.
  • 남는 리스크 양방향과 감시 채널: (1) 가시 텍스트 0자인데 정상인 페이지(이미지·가격이 전부 attribute 로만 실린 극단 케이스)가 실재하면 확정 실패로 바뀐다. 게이트 발동 시 host 포함 로그를 남겨 분포로 감시한다. (2) 몇십 자 보일러플레이트 셸은 게이트를 지나 LLM 으로 흘러 환각 가능성이 남는다. via=llm 로그의 html 크기 분포로 감시한다 (셸은 본문이 극단적으로 작다).

연관 이슈

- 렌더 후에도 빈 CSR 셸을 LLM 에 넘기면 실존하지 않는 상품을 지어낸다(에이블리 mobile.* 실측: 같은 URL 15회 중 200 이 8회, 전부 서로 다른 가짜 상품에 이미지 URL 은 NXDOMAIN·403). 지어낸 값은 형식이 유효해 응답 경계도 호출자(core) 검증도 통과해 READY 로 저장됐다 - 입력이 비었음을 아는 파이프라인이 LLM 호출 전에 NO_EXTRACTABLE_CONTENT 확정 실패로 끊는다
- 판정(LlmInputGate)은 가시 텍스트 50자 미만 AND 데이터 script 부재. 에스컬레이션 축(EmptyShellDetector 300자)의 임계를 재사용하지 않는다 - 그쪽 오탐은 헤드리스 1회 낭비지만 게이트 오탐은 확정 422 로 굳어 비용이 다르다. 가시 텍스트가 없어도 JSON-LD·data island 가 있으면 LLM 이 읽을 수 있으므로 통과시키며, 그 데이터 script 판정은 DataScripts 로 이관해 sanitize 와 single source 를 공유한다
- 전략 코드는 무수정: plain 은 기존 셸 재분류 catch(#22)가 게이트 예외를 받아 그대로 헤드리스로 승격하고, 헤드리스 결과까지 셸일 때만 새 code 가 422 로 표면화된다. NOT_PRODUCT_PAGE 와 code 를 나눈 이유는 호출자 관측 - "상품 아닌 링크"와 "몰을 못 읽음"이 한 code 로 섞이면 후자를 추적할 수 없다
- 카운터 via 를 structured/skipped_shell/llm 3분기로 재구성({via,reason} 키 집합 불변, 발행 지점 한 곳 유지)하고 게이트 발동 시 host 포함 로그를 남긴다 - "가시 텍스트도 script 도 없는 정상 상품 페이지" 오탐 실재 여부는 배포 후 이 로그의 host 분포로 감시한다
@m-a-king m-a-king added the feat 외부 가시적 새 기능 label Aug 9, 2026
@m-a-king m-a-king self-assigned this Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@m-a-king, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 917d5fa0-4298-4491-bf40-613ecf5b9cdc

📥 Commits

Reviewing files that changed from the base of the PR and between 27c6318 and 3d99068.

📒 Files selected for processing (13)
  • docs/api-contract.md
  • src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java
  • src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshotException.java
  • src/main/java/com/depromeet/piki/extractor/extraction/DataScripts.java
  • src/main/java/com/depromeet/piki/extractor/extraction/GeminiHtmlExtractor.java
  • src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java
  • src/main/java/com/depromeet/piki/extractor/extraction/LlmInputGate.java
  • src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java
  • src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java
  • src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java
  • src/test/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipelineGateTest.java
  • src/test/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipelineMetricTest.java
  • src/test/java/com/depromeet/piki/extractor/extraction/LlmInputGateTest.java

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

- "50자도 결국 자의적 임계값" 지적로 재검토: 0-300자 구간에 실측된 정상 페이지가 없어 마진 크기(30이든 80이든)를 정당화할 데이터가 없다. 판단(짧다)을 사실(전혀 없다)로 치환해 임계값 논쟁 자체를 없앤다 - 상수가 사라지고 판정은 text().isEmpty() 가 된다
- 트레이드오프를 알고 내린 선택이다: 놓친 셸(몇십 자 보일러플레이트만 있는 미지의 셸)은 LLM 으로 흘러 환각 가능성이 남고 그 실패는 조용하다. 반면 임계 오탐(정상 미니멀 페이지 차단)은 영구 422 로 굳는다. 잔존 위험 감시는 via=llm 로그의 html 크기·host 분포가 담당한다(셸은 본문이 극단적으로 작아 분포에서 드러난다)
- 실측된 사고 케이스는 전부 렌더 후 0자라 차단 효과는 동일하다. 경계 테스트는 49/50 에서 "공백뿐 body 발동 / 한 글자면 통과"로 교체
@m-a-king
m-a-king merged commit 3fa9894 into main Aug 9, 2026
2 checks passed
@m-a-king
m-a-king deleted the feat/llm-shell-gate branch August 9, 2026 21:45
m-a-king added a commit that referenced this pull request Aug 11, 2026
- 기존 "DO NOT GUESS" 는 Strategy 3(가격)에 국한돼 있었고, 빈 셸 환각 사고(에이블리 mobile.*, 15회 중 8회 창작)에서 flash-lite 가 이 지시를 절반가량만 따르는 것이 실측됐다. 극한(빈 입력)은 #26 게이트가 결정론으로 막았으므로, 이 규칙은 남은 표면 - 내용이 일부 있는 페이지에서 없는 필드를 채워 넣는 창작 - 을 겨냥한다
- 전역 규칙을 프롬프트 서두에 승격: 모든 출력 값은 제공된 HTML 에 존재해야 하고, 없으면 null(상품 자체가 없으면 isProductPage=false). 기억·유사 상품에서 회상한 값 생성을 명시적으로 금지하고 "창작이 null 보다 엄격히 나쁘다"는 우선순위를 박는다
- imageUrl 필드에도 개별 강화: URL(또는 resolve 전 상대경로)이 HTML 에 실재해야 하며 조립·축약 금지. 사고에서 창작된 이미지가 전부 NXDOMAIN/403 이었던 것의 직접 대응이며, 보류 중인 출력 대조(grounding 검증) PR 이 들어오기 전까지의 확률적 방어층이다
- 효과 측정은 dev 모델 실험(gemini-3.6-flash, extraction_models)과 함께 via=llm 대비 UNTRUSTWORTHY_VALUE·INCOMPLETE_SNAPSHOT 비율로 본다
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 외부 가시적 새 기능

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant