#!/bin/sh
# Claude Code Skill 설치 스크립트
# 생성: 2026-08-13 · Skill 1개 · 파일 1개
#
#   ./install-skills.sh            ~/.claude/skills/ 에 설치 (모든 프로젝트에서 사용)
#   ./install-skills.sh ./myrepo   ./myrepo/.claude/skills/ 에 설치 (그 저장소 전용)
set -e

DEST="${1:+$1/.claude/skills}"
DEST="${DEST:-$HOME/.claude/skills}"
mkdir -p "$DEST"
echo "설치 위치: $DEST"


mkdir -p "$DEST/pdf-vision-extract"
cat > "$DEST/pdf-vision-extract/SKILL.md" <<'SKILL_PAYLOAD_EOF'
---
name: pdf-vision-extract
description: 텍스트 추출이 안 되는 PDF의 표·카드를 Vision LLM으로 구조화한다. 페이지를 통째로 넣지 않고 pdftotext -bbox 좌표로 항목 단위로 잘라 확대해 넣는 것이 핵심. 색상·괘선으로 경계 찾기, 좌표와 이미지 병용, 추출본 정규화·검토 표시. TRIGGER - "PDF 표 추출", "Vision 으로 PDF 읽기", "공고책자", "regimen book", "스캔 PDF 구조화", 페이지 전체를 넣었더니 숫자를 잘못 읽을 때.
---

# PDF → Vision 구조화 추출

## 언제 쓰나

표·카드가 빽빽한 PDF를 구조화 데이터로 바꿔야 하는데 텍스트 추출만으로는 배치가 무너질 때. 병리 아웃컴북, 논문 표, 공고책자 모두 해당한다.

## 정본 코드

```
~/Documents/혜연/허초프로젝트/scripts/
  crop_regbook_cards.py     100줄  요법 카드 단위로 자르기
  crop_geupyeo_criteria.py  107줄  표의 '행' 단위로 자르기
  merge_regbook_vision.py          Vision 결과 정규화
  apply_crop_review.py             사람 검토 반영
extraction/regbook_vision/crops/   잘린 이미지
extraction/geupyeo_vision/crops/
```

## 핵심 — 페이지를 통째로 넣지 마라

> 페이지 전체를 한 번에 읽으면 **조밀한 작은 숫자(200↔1000)를 오독**할 수 있어, 카드별로 잘라 확대하면 용량 판독 정확도가 올라간다.

의료 문서에서 200mg과 1000mg을 헷갈리는 건 치명적이다. **항목 단위로 자르고 2배 확대**해서 넣는다. 호출 수는 늘지만 정확도가 다른 문제가 된다.

## 경계를 어떻게 찾나 — 문서마다 다르다

두 스크립트가 서로 다른 단서를 쓴다. **그 문서의 시각적 규칙을 먼저 관찰하고 거기 맞춘다.**

### 1) 색상으로 제목 찾기 (regimen book)

카드 제목·코드가 청록색(teal) 글씨다.

```python
sat = im.max(axis=2) - im.min(axis=2)       # 채도
colored = (sat > 25) & (im.max(axis=2) < 230)  # 유채색이면서 너무 밝지 않은 픽셀
```

무채색 본문 사이에서 유채색 행만 골라내면 제목 위치가 나온다. 행 간격 6px 이상 벌어지면 다른 제목으로 끊는다.

### 2) 괘선으로 카드 끝 찾기

> 가로 괘선(폭 넓은 어두운 1~2px 줄) 감지 → 괘선 아래가 **'여백'이면 카드 끝**(cut), **'텍스트'면 제목 밑줄**(무시)

괘선이 다 경계는 아니다. **아래에 뭐가 있는지를 봐야 구분된다.**

### 3) 좌표로 표의 행 찾기 (공고책자)

`pdftotext -bbox` 로 단어별 좌표를 받아 특정 컬럼(`x >= 페이지폭 * 0.40`)의 글자를 순서대로 이어붙이고, 찾는 텍스트가 어디 있는지로 y범위를 구한다. 그 y범위만 잘라낸다.

**이미지와 좌표를 함께 쓴다.** 이미지만 보면 어느 행이 어느 레코드인지 모르고, 좌표만 보면 배치가 안 보인다.

## 좌표는 캐시한다

```python
_bc = {}
def words(pg):
    if pg not in _bc:
        o = subprocess.run(["pdftotext","-bbox","-f",str(pg),"-l",str(pg),PDF,"-"], ...)
```

`pdftotext` 를 페이지마다 새로 부르면 느리다. 크롭을 여러 번 다시 돌리게 되므로 페이지 단위 캐시가 필수다.

## 오탐을 규칙으로 걸러라

용량줄을 카드 경계로 오인하는 문제를 정규식으로 막는다.

```python
_DOSE = re.compile(r"\d[\d,\.]*\s*(?:mg|mcg|µg|ug|g\b|iu|units?|million)\b|mg/m|mg/kg|\bAUC\s*\d|on\s*D\d", re.I)
```

> `IV`/`PO`/`every N` 같은 단독 토큰은 제목(IV-TBC 등)에도 있어 제외.

**단서가 제목에도 나타나는지 반드시 확인한다.** 안 하면 제목을 용량줄로 오인해 카드가 잘못 쪼개진다.

## Vision 결과는 반드시 정규화한다

LLM이 뱉는 JSON은 같은 필드가 회차마다 형태가 다르다. `merge_regbook_vision.py` 가 하는 일:

| 문제 | 처리 |
|---|---|
| `references` 가 문자열/딕셔너리 혼재 | `{citation, pmid}` 로 통일 |
| `dose` 가 숫자/문자 혼재 | 문자열로 통일 |
| 요법 단위 `cycle_note` | `schedules[].cycle` 로 이동 |
| 출처·검토 필요 여부 | `source_type` · `needs_review` 부여 |

**`needs_review` 를 처음부터 붙인다.** 나중에 붙이려면 어떤 게 자동이고 어떤 게 사람이 본 건지 구분이 안 된다.

## 사람 검토를 데이터로 되먹인다

크롭 결과를 사람이 보고 `crop_review_decisions.json` 에 적으면 `apply_crop_review.py` 가 반영한다. 크롭 규칙은 반드시 몇 %가 틀리므로 **재크롭해도 사람 판단이 살아남는 경로**를 만든다 — `[[regimen-extract]]` 의 교정 반영 패턴과 같다.

## 함정

- DPI를 상수로 두고 좌표 변환에 쓴다(`R_DPI=170`, `PT=72.0/R_DPI` / `DPI=200`, `SC=DPI/72.0`). PDF 좌표는 72dpi 기준이라 렌더 DPI와 섞이면 전부 어긋난다.
- 한 암종·한 페이지 범위로 먼저 스키마를 확정하고 넓힌다.
- 정본 스크립트는 `BASE` 경로가 박혀 있고 `os.chdir` 한다. 옮겨 쓸 때 가장 먼저 고칠 곳이다.

## 연관 skill

`[[regimen-extract]]`(이 크롭을 쓰는 파이프라인), `[[excel-case-validator]]`
SKILL_PAYLOAD_EOF

echo ""
echo "완료 — Skill 1개를 설치했습니다."
echo "Claude Code를 다시 시작하면 인식됩니다."
