diff --git a/README.md b/README.md index 56de0a1..7f5e64e 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,19 @@ server 가 풀세트, 나머지는 그 부분집합. 환경 차이의 대부분 ``` infra/ install.sh # 개발 규약 자산 설치기 (정본) — 자산 목록·설치 위치·실패 처리를 여기만 안다. - # 배포 갈래(blocks·contracts)는 설치 대상이 아니다 — 각 서비스 deploy 가 원격 fetch 로 소비 + # 배포 갈래(blocks)는 설치 대상이 아니다 — 각 서비스 deploy 가 원격 fetch 로 소비. + # contracts 도 산문은 같지만, 기계가 읽는 code 카탈로그만 소비 repo 의 + # shared-infra/contracts 로 설치한다 (로컬 참조 편의 — CI 는 checkout 으로 직접 받는다) conventions/ # 규약 (이미 통일된 기준선 + 이 repo 자산의 작성 규칙) infra.md # terraform state·컨테이너 배포단위·네트워크 격리 (등급 A) blocks.md # 블록 작성 원칙 (실행위치 중립·값 미소유·종료코드·셀프검증) testing.md # 테스트 컨벤션 원칙 (스택 무관 + JVM/Spring 공통) — install.sh 가 소비 repo 의 # .claude/rules/testing-principles.md 로 설치, 언어 바인딩은 각 repo 소유 - contracts/ # 서비스 간 배포 계약 (판정 방식·규약) + contracts/ # 서비스 간 계약 (판정 방식·규약) health.md # 헬스체크 계약 (첫 통일 대상) observability.md # 관측 계약 (Alloy 수집·라벨·로그 형식) + extraction-api.md # 추출 API 계약 (core -> extractor: 요청·응답 3갈래·code 의미·타임아웃 예산) + extraction-error-codes.yaml # 추출 실패 code 카탈로그 (정본 데이터) — 소비 repo 메타 테스트가 읽어 대조 blocks/ # 실행 위치 중립 공유 블록 (bash 스크립트 + 관측 설정) healthcheck.sh healthcheck.test.sh diff --git a/contracts/extraction-api.md b/contracts/extraction-api.md new file mode 100644 index 0000000..1d77642 --- /dev/null +++ b/contracts/extraction-api.md @@ -0,0 +1,242 @@ +# 추출 API 계약 + +core(호출자)와 extractor(추출 서비스) 사이의 API 계약. 구현(springdoc `/v3/api-docs`)이나 각 repo +문서와 어긋나면 **이 문서를 기준으로 구현을 고친다.** + +소비자는 core 의 파싱 워커 하나뿐이다. 공개 API 가 아니며 보안그룹으로 내부망에서만 접근한다(별도 인증 없음). + +**code 목록의 정본은 `contracts/extraction-error-codes.yaml`** 이다. 이 문서는 각 code 가 무엇을 +뜻하는지를 맡고, 목록·disposition·bucket 은 그 파일이 갖는다(이중 관리 방지). 카탈로그에 있는데 +아래 표에 없는 code 가 보이면 카탈로그가 옳다 — 설명을 여기 보탠다. + +> extractor repo 의 `docs/api-contract.md` 에서 이관했다. 정본이 소비자 한쪽에 있으면 다른 쪽이 +> 따라가지 않아도 정본은 멀쩡해 어긋남이 조용하다(TeamPiKi/infra#41). + +## 0. 설계 불변식 + +- Extractor 는 **무상태**다. DB 없음, 호출 간 상태 없음. 같은 요청이 중복 도착해도 상태 오염이 없다 + (중복의 대가는 LLM 비용 한 번뿐). Extractor 에 상태를 넣고 싶어지면 설계 경고 신호다. +- 재시도·내구성·상태 전이는 전부 호출자(core 의 `item_snapshots` 작업 큐)의 책임이다. Extractor 는 + "단건 시도 1회"에만 답한다. +- 에스컬레이션(plain fetch -> 헤드리스 브라우저)은 Extractor 내부 관심사다. 응답 계약에 드러나지 않는다 — + 호출자는 어떤 fetch 전략이 쓰였는지 모른다. 단 "어느 플랫폼을 처음부터 헤드리스로 보낼지"의 **정책**은 + 호출자(DB·백오피스)가 주인이라, 요청 필드 `headlessFirst` 로 힌트만 받는다(2장) — 무상태 불변식을 + 지키는 선에서의 유일한 정책 수용 지점이다. + +## 1. 응답 3갈래 (전이 규약) + +| Extractor 응답 | 의미 | core 전이 | +|---|---|---| +| 2xx + 추출 결과 | 성공 | `markReady` | +| 422 + `{code}` | 확정 실패 (재시도 무의미) | 즉시 `markFailed` | +| 그 외 전부 (5xx·타임아웃·연결 실패·미지의 상태) | 일시 실패 | PROCESSING 유지 -> recover 재시도 (attempt 상한 2) | + +- **전이 판정은 HTTP status 만 사용한다.** 422 body 의 `code` 는 관측·디버깅용이며, 호출자가 모르는 + code 여도 422 면 확정 실패로 처리한다(tolerant reader). +- **fail-safe 원칙**: 분류할 수 없는 실패는 전부 "일시"로 떨어진다. 확정 실패 신호는 422 하나뿐이다. + 호출자의 attempt 상한이 재시도 비용을 바운드한다. +- 카탈로그의 `disposition` 이 이 갈래와 1:1 이다 — `permanent` 는 422 로, `transient` 는 502 로 나간다. + Extractor 쪽 런타임 정본은 각 예외 팩토리의 `permanent` 플래그이며, 카탈로그는 그 계약 표기다. + +## 2. 엔드포인트 + +### POST `/internal/extractions/link` — URL 상품 추출 + +요청: + +```json +{ "url": "https://www.musinsa.com/products/12345", "headlessFirst": false, "model": "gemini-3.1-flash-lite" } +``` + +- `url` (필수): https 스킴의 상품 페이지 URL. 형식·스킴·미지원 플랫폼의 동기 검증은 호출자(core 등록 + 경계)가 이미 끝냈다는 전제이나, Extractor 도 자기 경계에서 방어 검증한다(다층 방어). +- `headlessFirst` (선택, 기본 false): 호출자의 플랫폼 라우팅 정책(`HEADLESS_FIRST`, DB·백오피스 동적 + 설정) 힌트. true 면 plain(정적 fetch)을 건너뛰고 처음부터 헤드리스 브라우저로 추출한다. 정책의 단일 + 진실은 호출자 DB 에 있고 무상태인 Extractor 는 요청 단위로만 받는다. Extractor 의 + `product.extract.headless.enabled` 가 꺼져 있으면 무시된다(스위치가 힌트보다 우선). +- `model` (선택): 이 요청의 LLM 추출에 쓸 모델. headlessFirst 와 같은 성질이다 — 정책의 단일 진실은 + 호출자 DB(백오피스)에 있고 Extractor 는 요청 단위로만 받는다. **요청 단위로 받는 이유**: Extractor + 박스 한 대를 여러 환경이 공유하므로, 모델을 Extractor 환경변수로 잡으면 dev 에서 바꾼 것이 prod + 파싱까지 덮는다. 생략·null·빈 문자열이면 Extractor 의 기본 모델을 쓴다 — 구버전 호출자의 요청이 + 그대로 동작하므로 배포 순서 무관. +- **지정 모델이 404 면 기본 모델로 대체하고 추출을 이어간다.** 등록 당시 유효했던 모델이 폐기돼 사라지는 + 경우가 있고, 그때 파싱 전체가 죽는 것보다 기본 모델로 이어가는 편이 낫다(가용성 우선). 대체가 일어나도 + 응답 모양은 같으며, 발생 사실은 Extractor 의 warn 로그와 `gemini.model.fallback` 카운터에만 남는다. + **400·5xx·timeout 은 대체하지 않는다** — 400 은 요청 body 쪽 결함일 수 있어 대체로 덮으면 버그가 + 묻히고, 나머지는 모델을 바꾼다고 풀리는 실패가 아니다. +- 헤더 `X-Correlation-Id` (선택): 호출자의 item_snapshot id. 로그·trace 상관용이며 동작에 영향 없다. + +성공 200: + +```json +{ + "name": "나이키 에어포스", + "imageUrl": "https://...", + "currentPrice": 99000, + "currency": "KRW", + "finalUrl": "https://www.musinsa.com/products/6760200", + "method": "STRUCTURED" +} +``` + +- `finalUrl`: 리다이렉트를 따라간 최종 페이지 URL. 호출자가 상품 정체성(canonical) 정규화의 입력으로 + 쓴다 — 단축링크(onelink 등)는 경로가 불투명 코드라 이 값 없이 같은 상품을 알아볼 수 없다. link 경로는 + 항상 채워지고 image 경로는 null. 호출자는 이 값이 없으면(구버전 Extractor) canonical 확정을 + 건너뛴다 — 배포 순서 무관. +- `method`: 값을 만든 추출 경로. `STRUCTURED`(구조화 파싱, 결정론적) | `LLM`(Gemini — URL fallback·image + 경로). 호출자가 snapshot 출처(SERVER/SERVER_LLM)를 구분 저장하는 근거다. tolerant reader 라 모르는 + 값이 와도 무시하고 출처 미기록으로 둔다. +- **`name`(non-blank)·`imageUrl`·`currentPrice` 의 non-null 을 Extractor 가 보장한다** — core 의 READY + 불변식(name·price·imageUrl·extractedAt, extractedAt 은 호출자가 전이 시점에 채움)과 동일 조건이다. + 보장할 수 없으면 성공이 아니라 422(`UNTRUSTWORTHY_VALUE`)다. `currency` 는 READY 필수가 아니라 + **nullable** 이다. 호출자의 엔티티 불변식은 최후 보루로 유지된다. + +확정 실패 422: + +```json +{ "code": "NOT_PRODUCT_PAGE" } +``` + +일시 실패는 Extractor 가 502 를 쓴다(호출자는 status 구분 없이 "2xx/422 외 전부"로 처리). body 에 code 를 +실을 수 있으나 호출자는 읽지 않는다. + +### POST `/internal/extractions/image` — S3 이미지 OCR 추출 + 크롭 + +요청: + +```json +{ "bucket": "dev-piki-images-", "key": "items/raw/0f3a....png", "model": "gemini-3.1-flash-lite" } +``` + +- **`bucket` 을 요청이 준다** — Extractor 는 여러 환경의 트래픽을 받고 각 환경의 이미지 버킷이 다르다. + 버킷을 고정 config 로 두지 않고 요청별로 받아 버킷 무관하게 동작한다. IAM 은 이미지 버킷 와일드카드로 + 전 환경을 덮는다. +- `key`: raw 원본 object key(등록 시 core 가 `items/raw/{uuid}.{ext}` 로 durable 적재한 것). +- `model` (선택): link 와 같은 규약이되 **축이 갈린다** — 이미지 경로에는 이미지용 지정만 온다. 링크는 + 텍스트와 JSON 스키마를 다루고 이미지는 보는 능력이 필요해, 한쪽에 맞는 모델이 다른 쪽에 맞지 않을 수 + 있기 때문이다. 대체 규칙(404 만 기본 모델로)도 link 와 같다. + +성공 200 은 link 경로와 **동일한 필드 모양**이다(`finalUrl` 만 null). Extractor 가 +`download(bucket,key) -> OCR 추출 -> bbox 크롭(불가 시 원본) -> upload(bucket, items/{uuid}.png)` 를 +다 하고, 업로드한 결과 이미지의 public URL 을 `imageUrl` 로 돌려준다. imageUrl 은 항상 non-null(크롭 +실패해도 원본을 올린다). non-null 규약은 link 와 같다. + +이 경로에만 나오는 code: `IMAGE_UNSUPPORTED`(확정), `STORAGE_ERROR`(일시). 이미지에서 상품 식별 실패는 +link 와 같은 `UNTRUSTWORTHY_VALUE` 를 재사용한다. + +### POST `/internal/models/probe` — 모델 유효성 프로브 + +호출자가 백오피스에서 모델을 저장하기 전에 "이 모델이 이 경로에서 실제로 동작하는가"를 묻는다. +저장 게이트가 이 응답으로 갈린다. + +요청: + +```json +{ "model": "gemini-3.1-flash-lite", "target": "LINK" } +``` + +- `model` (필수): 확인할 모델 이름. 아는 모델 목록을 Extractor 코드에 박지 않는 것이 이 엔드포인트의 + 존재 이유다 — allowlist 를 박으면 새 모델이 나올 때마다 Extractor 배포가 필요해져 "배포 없이 바꾼다"는 + 목적이 무너진다. 유효성은 런타임 실측이 판정한다. +- `target` (필수): `LINK` 또는 `IMAGE`. 두 경로는 요청 wire 가 달라(link 는 `responseJsonSchema` 에 + 소문자 type, image 는 `responseSchema` 에 대문자 enum type 과 thinkingConfig) 한쪽에서 통과한 모델이 + 다른 쪽에서 400 일 수 있다. + +**판정은 메타 조회가 아니라 그 경로의 실제 generateContent 호출이다.** 모델 존재만 확인하면 요청 스키마 +비호환(400)을 못 거르는데, 400 은 추출 경로에서 대체 대상이 아니라 곧 파싱 전건 실패다. 게이트가 정작 +막아야 할 실패를 놓치게 된다. 최소 입력을 쓰되 wire 모양은 운영과 같다. + +**대체 없이 지정 모델만 친다.** 추출 경로처럼 대체하면 없는 모델을 넣어도 기본 모델이 대신 성공해 +프로브가 통과하고, 저장 게이트가 무력화된다. + +| status | 의미 | 호출자의 처리 | +|---|---|---| +| `200` (body 없음) | 이 경로에서 동작하는 모델 | 저장 허용 | +| `422` + code | 확정 거절 | 저장 거부 + 사유 표시 | +| `400` | 필수 필드 누락·모르는 target | 호출자 구현 버그. 재시도해도 같다 | +| 그 외(502) | 외부 사정으로 확인 불가 | 저장 거부 + 재시도 안내 | + +**일시 실패를 거절로 바꾸지 않는다.** 5xx·429·타임아웃을 422 로 내보내면 외부가 잠깐 흔들린 사이에 +멀쩡한 모델이 "쓸 수 없는 모델"로 판정돼 저장이 막힌다. + +## 3. code 의 의미 + +목록·`disposition`·`bucket` 의 정본은 `contracts/extraction-error-codes.yaml` 이다. 아래는 각 code 가 +무엇을 가리키는지에 대한 설명이다. + +| code | 의미 | +|---|---| +| `NOT_PRODUCT_PAGE` | 읽긴 했는데 상품 페이지가 아니다 | +| `INVALID_URL` | url 형식·스킴 위반. 정상 흐름에선 호출자가 동기 검증해 도달하지 않는다(방어) | +| `EMPTY_SHELL` | fetch 는 2xx 지만 본문이 데이터 없는 CSR 셸(파싱 no-data 를 재분류). 헤드리스 에스컬레이션 대상이라, 헤드리스가 켜진 구성에선 헤드리스 결과가 대신 응답된다 | +| `NO_EXTRACTABLE_CONTENT` | 본문에 가시 텍스트도 데이터 script 도 없어 LLM 을 부르지 않고 확정(빈 셸 환각 차단). plain 경로는 EMPTY_SHELL 재분류가 선행하므로 사실상 헤드리스 렌더 결과까지 셸일 때 나온다 | +| `FETCH_CLIENT_ERROR` | 대상 4xx (403 차단·404·429 등). 봇 방어의 클로킹일 수 있다 | +| `PERMANENT_UPSTREAM` | 대상 500/501 등 결정론적 재실패 5xx. 대형 몰은 상시 가용이라 대개 진짜 장애가 아니라 봇 방어다 | +| `UNTRUSTWORTHY_VALUE` | 추출값이 범위·상식 위반이거나 non-null 보장을 못 채웠다 | +| `LLM_INVALID_RESPONSE` | 재시도해도 같은 LLM 실패(4xx·파싱 불가·정책 거부로 text part 없음) | +| `IMAGE_UNSUPPORTED` | 이미지 경로 전용 — 빈 이미지·미지원 MIME | +| `BLOCKED_HOST` | 사설·메타데이터·loopback 으로 resolve 되는 host 를 SSRF 로 차단. 헤드리스 에스컬레이션 절대 금지 대상 | +| `TOO_MANY_REDIRECTS` | redirect 가 hop 상한을 넘어 무한·체인 의심 | +| `MALFORMED_REDIRECT` | 3xx 를 주면서 Location 이 없거나 깨진 비정상 redirect | +| `UPSTREAM_ERROR` | 대상 몰 502/503/504·연결 실패·빈 body | +| `LLM_UPSTREAM` | Gemini 5xx/429/408/transport 오류 | +| `HEADLESS_BLOCKED` | 실제 브라우저로도 차단(verdict=BLOCK). 렌더 서비스의 BLOCK 판정에는 429·일시 챌린지가 섞여 영구/일시를 못 가르므로 fail-safe 로 일시다. `HEADLESS_UPSTREAM` 과 code 를 나눈 이유는 대응이 달라서다(차단 추세 = 정책 후보, 장애 추세 = 렌더 박스 점검) | +| `HEADLESS_UPSTREAM` | 렌더 서비스 연결 실패·타임아웃·빈 렌더(verdict=EMPTY)·브라우저 오류(verdict=ERROR)·압축 해제 실패. 렌더 서비스는 파싱하지 않으므로 HTML 이 있으면 verdict 와 무관하게 Extractor 파이프라인이 추출을 이어간다 | +| `STORAGE_ERROR` | 이미지 경로 전용 — S3 read/write 실패 | +| `MODEL_NOT_FOUND` | 프로브 전용 — 그런 모델이 없다(Gemini 404). 오타이거나 폐기돼 사라진 모델 | +| `MODEL_INCOMPATIBLE` | 프로브 전용 — 모델은 있으나 그 경로의 요청을 처리하지 못한다. 요청 스키마 비호환(400)·결제 티어 제한, 200 을 주면서 응답 스키마를 못 맞춘 경우까지 포함 | + +### bucket (확정 실패의 운영 분류) + +`bucket` 은 "이 실패를 우리가 어떻게 받아들이나"의 축이다. 확정 실패에만 붙는다 — 일시 실패는 호출자가 +세지 않고 recover 가 종결 시 집계한다. + +| bucket | 뜻 | 운영이 보는 것 | +|---|---|---| +| `not_product` | 애초에 상품 링크가 아니다 | 사용자 입력 문제. 늘어도 서비스 결함이 아니다 | +| `unreadable` | 상품 페이지지만 우리가 읽어내지 못했다 | 렌더·에스컬레이션 커버리지 문제 | +| `blocked` | 대상이 우리를 막았다 | 플랫폼 정책·차단 대응 대상 | +| `extract_quality` | 읽었으나 값을 신뢰할 수 없다 | 추출 품질(프롬프트·모델·파서) 문제 | +| `internal_error` | 우리 쪽 방어·비정상 상태로 끝났다 | 정상 요청이 여기 쌓이면 우리 버그 신호 | + +분류를 각 repo 자유로 두지 않고 카탈로그가 소유하는 이유: "extractor 는 차단으로 보는데 core 는 상품 +아님으로 센다" 같은 의미 어긋남은 기계가 못 잡고, 지표를 조용히 거짓말하게 만든다. + +## 4. 타임아웃 예산 + +| 층 | 값 | 근거 | +|---|---|---| +| core stale 판정 | 60s | `ItemParsingScheduler.STALE_TIMEOUT` | +| core -> Extractor HTTP read | 55s (connect 2s) | stale 미만 — recover 의 유령 중복 발주 방지. link·image 공용 | +| Extractor 내부 합계 (link) | 약 50s | 아래 합 + 여유 | +| 대상 몰 fetch (link) | connect 5s / read 15s | | +| 헤드리스 render (link) | connect 2s / read 20s | 실측 전형 1.6-5.5s(프록시 포함) 대비 약 4배 여유. headless-first 최악(connect 2 + render 20 + LLM 30 = 약 52s)이 호출자 read 55s 안에 들도록 상한 | +| Gemini | read 30s | link LLM fallback·image OCR 동일 | +| Extractor 내부 합계 (image) | 약 40s | S3 download + Gemini OCR 30s + crop + 결과 upload. S3 는 동일 리전이라 수 초 | + +**안쪽 예산은 항상 바깥보다 작아야 한다.** link 와 image 가 호출자 read 55s 를 공유하므로, 어느 경로든 +Extractor 내부 값을 늘릴 땐 이 표를 갱신하고 core 쪽 read 타임아웃과 함께 재검증한다. + +예외적으로 **에스컬레이션 경로(plain 실패 -> headless)의 최악 스택**은 호출자 read 55s 를 넘을 수 있다. +plain fetch 는 수동 redirect 추적(hop 상한 3 = 요청 최대 4회)마다 connect/read 타임아웃이 **새로 적용**되므로 +fetch 단독의 이론 최악이 이미 약 88s 다(헤드리스 이전부터 있던 특성). 여기에 render 22s + LLM 30s 가 +얹히면 이론 최악 약 140s — 단, 각 단이 전부 타임아웃까지 끄는 경우는 실측상 없다시피 하고(차단은 대개 +즉시 4xx/5xx 로 떨어져 fetch 가 빨리 실패한다), 넘치면 호출자는 read 타임아웃 -> 일시 실패로 처리해 +recover 가 재시도한다. 그 사이 Extractor 가 계속 돌아 중복 발주가 겹쳐도 Extractor 는 무상태라 +안전하고(0장), attempt 상한 2 가 총비용을 바운드한다. 이 스택을 55s 안에 구겨 넣으려면 render 예산이 +실측 대비 무의미하게 얇아져(5s 이하) recall 을 잃는다 — 의도된 트레이드오프다. + +## 5. 진화 규칙 + +- **additive-only**: 응답 필드 추가·422 code 추가는 자유. 필드 제거·의미 변경·타입 변경은 금지 — + 필요하면 새 경로로 분리한다. +- **code 를 더하거나 고칠 때는 카탈로그(`extraction-error-codes.yaml`)를 먼저 고친다.** 소비 repo 의 + 메타 테스트가 카탈로그를 읽어 대조하므로, 구현만 고치면 그쪽이 빨간불이 된다 — 그게 이 배치의 목적이다. +- **배포 순서: Extractor 먼저, 소비자(core) 나중.** +- 호출자는 tolerant reader — 모르는 응답 필드·code 를 무시한다. + +## 6. 관측 + +- W3C `traceparent` 헤더를 수용해 core 의 `item.parse` span 아래로 연결된다(micrometer tracing 기본 동작). +- 추출 메트릭(`product.extract{via,reason}`·`product.extract.escalation{outcome,category}`)은 Extractor 가 + 소유하며, `application=piki-extractor` 라벨로 core 시계열과 구분된다(`contracts/observability.md`). diff --git a/contracts/extraction-error-codes.yaml b/contracts/extraction-error-codes.yaml new file mode 100644 index 0000000..f175e76 --- /dev/null +++ b/contracts/extraction-error-codes.yaml @@ -0,0 +1,61 @@ +# 추출 실패 code 카탈로그 (정본 데이터) +# +# core 와 extractor 가 주고받는 실패 code 의 전수 목록이다. 각 code 가 무엇을 뜻하는지는 +# contracts/extraction-api.md 가 설명하고, 이 파일은 **목록과 기계 판정용 속성**만 갖는다 +# (목록을 두 곳에 두지 않는다). +# +# 왜 데이터로 두나: 정본이 소비자 한쪽(extractor 의 docs)에 있으면 따라가지 않아도 정본 쪽은 +# 멀쩡해 어긋남이 조용하다. 실제로 NO_EXTRACTABLE_CONTENT 는 문서에만 있고 core 매핑이 없는 채 +# CI 가 계속 초록불이었다. 기계가 읽을 수 있는 자리로 옮겨 대조를 강제한다. +# +# 소비 경로 +# - CI(강제) — 소비 repo 의 워크플로가 actions/checkout 으로 이 repo 를 shared-infra/ 에 받고, +# 메타 테스트가 shared-infra/contracts/extraction-error-codes.yaml 을 읽어 자기 enum·매핑과 대조한다. +# - 로컬(편의) — install.sh 가 같은 경로에 사본을 깐다. SessionStart 훅이라 CI 에선 돌지 않으므로 +# 강제의 근거가 아니다. 경로를 CI 와 맞춘 이유는 소비 repo 의 테스트가 경로를 하나만 알게 하려는 것. +# +# 필드 +# disposition permanent = 확정 실패(422, 재시도 무의미) / transient = 일시 실패(호출자 recover 재시도). +# 런타임 정본은 extractor 예외 팩토리의 permanent 플래그이며, 이 값은 그 플래그의 계약 표기다. +# bucket permanent code 의 운영 분류(뜻은 extraction-api.md). 분류까지 카탈로그가 소유하는 이유는, +# 각 repo 자유로 두면 "extractor 는 차단으로 보는데 core 는 상품 아님으로 센다" 같은 +# 의미 어긋남이 생기고 그건 기계가 못 잡기 때문이다. +# transient 에는 붙이지 않는다 — core 가 세지 않고 recover 가 종결 시 집계한다. +# scope probe = 모델 프로브 전용. 추출 경로가 아니라 bucket 축 밖이다(기본값은 추출 경로). +# escalatable 정적 fetch 실패를 헤드리스로 재시도하는 대상인지. fetch 경로(PageFetchException)에만 있는 +# 축이라 그 code 에만 붙는다. Extractor 내부 관심사이며 응답 계약에는 드러나지 않는다(관측·문서용). +# +# 진화: additive-only. 추가는 자유, 제거·의미 변경은 금지 (extraction-api.md 의 진화 규칙). + +codes: + # -- 확정 실패 (422) — 호출자는 즉시 markFailed -- + NOT_PRODUCT_PAGE: { disposition: permanent, bucket: not_product } + INVALID_URL: { disposition: permanent, bucket: not_product } + + EMPTY_SHELL: { disposition: permanent, bucket: unreadable, escalatable: true } + NO_EXTRACTABLE_CONTENT: { disposition: permanent, bucket: unreadable } + + FETCH_CLIENT_ERROR: { disposition: permanent, bucket: blocked, escalatable: true } + PERMANENT_UPSTREAM: { disposition: permanent, bucket: blocked, escalatable: true } + + UNTRUSTWORTHY_VALUE: { disposition: permanent, bucket: extract_quality } + LLM_INVALID_RESPONSE: { disposition: permanent, bucket: extract_quality } + IMAGE_UNSUPPORTED: { disposition: permanent, bucket: extract_quality } + + # escalatable=false 는 BLOCKED_HOST 하나뿐이다 — 내부망에 헤드리스를 겨누는 것 자체가 SSRF 라 + # "무조건 폴백" 정책의 유일한 예외다(recall 트레이드오프가 아니라 보안). + BLOCKED_HOST: { disposition: permanent, bucket: internal_error, escalatable: false } + TOO_MANY_REDIRECTS: { disposition: permanent, bucket: internal_error, escalatable: true } + MALFORMED_REDIRECT: { disposition: permanent, bucket: internal_error, escalatable: true } + + # -- 일시 실패 — 호출자는 PROCESSING 유지 후 recover 재시도 -- + UPSTREAM_ERROR: { disposition: transient, escalatable: true } + LLM_UPSTREAM: { disposition: transient } + HEADLESS_BLOCKED: { disposition: transient } + HEADLESS_UPSTREAM: { disposition: transient } + STORAGE_ERROR: { disposition: transient } + + # -- 모델 프로브 전용 (POST /internal/models/probe) -- + # 추출 경로가 아니라 백오피스 저장 게이트의 응답이라, 운영 분류(bucket) 축에 들어가지 않는다. + MODEL_NOT_FOUND: { disposition: permanent, scope: probe } + MODEL_INCOMPATIBLE: { disposition: permanent, scope: probe } diff --git a/install.sh b/install.sh index bcda6e1..0946fe3 100755 --- a/install.sh +++ b/install.sh @@ -30,7 +30,7 @@ else get() { gh api -H "Accept: application/vnd.github.raw" "repos/$INFRA_REPO/contents/$1" 2>/dev/null; } fi -# $1=자산 경로(repo 내) $2=설치 대상(절대경로) $3=권한 mode $4=검증 유형(sh|md) +# $1=자산 경로(repo 내) $2=설치 대상(절대경로) $3=권한 mode $4=검증 유형(sh|md|yaml) # 빈 응답(fetch 실패·권한 없음)이면 어느 유형이든 스킵해 기존 설치본을 유지한다 (가용성 가드). # 그 위에 유형별 검증을 얹는다 (validate_asset). install_asset() { @@ -53,11 +53,15 @@ install_asset() { # 훅을 깨뜨리지 않게 설치를 스킵하고 기존 설치본을 유지한다. # md: 셸이 아니라 bash -n 이 오히려 실패하므로 적용하지 않는다. 비어있지 않음([ -s ])만 보며, # 그건 install_asset 이 이미 확인했다. +# yaml: yaml 파서를 전제할 수 없어(python·yq 가 없는 환경이 있다) 문법 검증 대신 최상위 키만 본다. +# 빈 응답은 install_asset 이 이미 거르므로, 여기가 막는 건 "받긴 받았는데 그 카탈로그가 아닌 것" +# (에러 페이지·잘린 본문)이다. 이 검사가 파서 없이 오탐 없는 유일한 층이다. validate_asset() { case "$2" in - sh) bash -n "$1" 2>/dev/null ;; - md) true ;; - *) false ;; # 알 수 없는 유형은 설치하지 않는다 (안전) + sh) bash -n "$1" 2>/dev/null ;; + md) true ;; + yaml) grep -q '^codes:' "$1" ;; + *) false ;; # 알 수 없는 유형은 설치하지 않는다 (안전) esac } @@ -78,7 +82,7 @@ is_managed_path() { "$HOME/.claude/hooks/"*|"$HOME/.claude/scripts/"*|"$HOME/.claude/commands/"*) return 0 ;; esac [ -n "${repo_root:-}" ] && case "$1" in - "$repo_root/.claude/commands/"*|"$repo_root/.claude/rules/"*) return 0 ;; + "$repo_root/.claude/commands/"*|"$repo_root/.claude/rules/"*|"$repo_root/shared-infra/"*) return 0 ;; esac [ -n "${hooks_dir:-}" ] && case "$1" in "$hooks_dir/"*) return 0 ;; @@ -225,6 +229,26 @@ if [ "$self" = 0 ]; then install_asset conventions/testing.md "$rules_dir/testing-principles.md" 444 md fi +# 계약 카탈로그 — 소비 repo 의 shared-infra/contracts 에 설치한다. +# +# 이 설치는 로컬 세션 참조용 편의이지 CI 강제의 근거가 아니다. install.sh 는 SessionStart 훅이라 +# CI 에서 돌지 않는다. 소비 repo 의 메타 테스트를 실제로 강제하는 건 그 repo 워크플로의 +# actions/checkout(이 repo -> shared-infra/)이다. +# +# 그럼에도 경로를 shared-infra/contracts 로 맞추는 이유: 로컬과 CI 의 카탈로그 위치가 같아야 +# 소비 repo 의 테스트가 경로를 하나만 알면 된다. 경로가 갈리면 테스트에 분기가 생기고, 그 분기가 +# 로컬에서만 초록불인 사각을 만든다. +# +# 다른 계약 문서(health·observability)는 사람이 읽는 산문이라 설치 대상이 아니다. 카탈로그만 여기 +# 오는 건 그것만 기계가 읽는 데이터이기 때문이다. self 모드(infra 자신)는 정본이 이미 손에 있어 제외한다. +# 버전 영역에 사본이 생기므로, 소비 repo 는 이 배선을 받을 때 .gitignore 에 shared-infra/ 를 더한다 +# (CI 의 checkout 도 같은 자리에 풀리므로 그쪽 노이즈까지 함께 덮인다). +if [ "$self" = 0 ]; then + contracts_dir="$repo_root/shared-infra/contracts" + mkdir -p "$contracts_dir" + install_asset contracts/extraction-error-codes.yaml "$contracts_dir/extraction-error-codes.yaml" 444 yaml +fi + # 세션 훅·유틸 — 설치 대상이 repo 가 아니라 사용자 홈(~/.claude)이다. # # 왜 홈인가: Claude Code 의 세션 훅은 사용자 전역 설정이라 repo 안에 둘 자리가 없다. 그럼에도 SSOT