#!/bin/sh
# Claude Code Skill 설치 스크립트
# 생성: 2026-08-11 · Skill 1개 · 파일 2개
#
#   ./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/regimen-extract"
cat > "$DEST/regimen-extract/SKILL.md" <<'SKILL_PAYLOAD_EOF'
---
name: regimen-extract
description: 항암 요법(레지멘) DB를 구축한다. HIRA 공고책자·허가초과(허초) 심의결과·Onco Regimen book PDF를 Vision LLM으로 추출하고, 식약처 API(허가상세·대조약)와 약가 엑셀을 병합해 암종별 요법·용법용량·급여기준·약가를 SQLite/D1로 낸다. 출처 추적, 사람 검토·교정 반영 워크플로 포함. TRIGGER - "항암제 DB", "레지멘 추출", "허가초과/허초", "급여기준", "공고책자", "regimen book", "약가", PDF 표를 구조화 DB로 만들어야 할 때.
---

# 항암 레지멘 DB 구축

## 언제 쓰나

"담도암 환자에게 쓸 수 있는 요법이 뭔가"를 답하는 DB를 만들 때. `[[ctrial-collect]]` + `[[cancer-type-classify]]`가 **임상시험** 쪽을 맡는다면, 이쪽은 **이미 승인·급여되는 치료**를 맡는다.

넓게는 **PDF 표를 Vision LLM으로 구조화 DB에 넣는 방법**의 정본이다.

## 정본 코드

```
~/Documents/IMOK/허초프로젝트/
  extraction/           추출 산출물 + 캐시 + 교정 파일 (JSON)
    regbook_vision/, geupyeo_vision/    Vision 추출
    regbook_pages/                      페이지별 결과
    *_cache.json                        API 응답 캐시
    *_corrections.json, review_state.json, verify_findings.json   검토·교정
  scripts/              crop_*, extract_*, enrich_*, apply_*, build_*, export_*
  db_design/
    데이터_출처_정리.md   ★ 테이블·컬럼별 출처를 전부 기록한 문서
    schema_v2.sql, d1_import.sql, d1_overlay.sql
    항암제_DB설계서_v2.xlsx, verify_report.md
  web/, wrangler.toml   Cloudflare D1 + Workers 배포
~/Documents/IMOK/oncoalert/   같은 DB를 쓰는 웹 서비스
```

## 바로 쓰는 코드

```bash
python3 scripts/corrections.py --records out.json --corrections fix.json --key id --out final.json
```

`scripts/corrections.py` — `apply_*.py` 들이 공유하던 패턴을 하나로. `apply_corrections`(멱등, before/after 기록, 교정 표시) · `split_review`(확인완료/필요/미검토) · orphan 경고.
**교정 대상이 사라진 키를 반드시 보고한다** — 재추출로 레코드가 없어지면 사람 검토가 소리 없이 증발한다.

## 소스가 7개다 — 출처 문서를 먼저 써라

| 코드 | 소스 | 형태 | 무엇을 줌 |
|---|---|---|---|
| S1 | 약제급여목록·급여상한금액표 (HIRA) | 엑셀 | 제품목록·EDI·주성분코드·**약가** |
| S2 | 사전신청요법·불승인요법 (HIRA 허초 심의결과) | 엑셀 | 허가초과 요법(인정/검토중/불승인) |
| S3 | 항암제보험급여 공고책자 (HIRA, 연 1회) | **PDF** | 급여 요법·고시번호·투여대상·투여단계 |
| S4 | Onco Regimen book (아산병원 종양내과) | **PDF** | 표준요법·용법용량·투여일·참고논문 |
| S5 | 식약처 제품허가정보 API | API | 영문명·약효분류·전문일반·EDI |
| S6 | 식약처 허가상세 API | API | **ATC코드**·효능효과·용법용량·허가변경이력 |
| S7 | 식약처 대조약 API | API | 오리지널 여부 |

`db_design/데이터_출처_정리.md`가 **테이블·컬럼별로 어느 소스에서 왔는지** 전부 적어둔다. 소스가 여럿이면 이 문서 없이는 몇 달 뒤 아무도 값의 근거를 못 찾는다.

두 가지가 특히 중요하다:

- **AI 수기 입력 항목을 따로 표시한다.** `cancer_aliases`(NSCLC 같은 영문 약어)는 "⚠️ 자료 아님 — 약제부 검토 예정"으로 명시돼 있다. 근거 자료가 있는 값과 AI가 채운 값을 섞으면 신뢰도가 통째로 무너진다.
- **제거한 소스도 이유와 함께 남긴다.** "약가기준정보 API(심평원)는 제거됨 — 약가는 S1 엑셀에서 받음."

## PDF → 구조화: crop 후 Vision

```
crop_regbook_cards.py / crop_geupyeo_criteria.py   페이지에서 카드·표 영역을 잘라냄
  → regbook_vision/, geupyeo_vision/               Vision LLM으로 추출
  → regbook_pages/                                 페이지별 JSON
  → regbook_schema_v3_samples.json                 스키마 정착
```

**페이지 전체를 통째로 Vision에 넣지 마라.** 요법 카드 단위로 잘라 넣어야 정확하다. crop 결과는 사람이 검토하고(`crop_review_decisions.json`), `apply_crop_review.py`로 반영한다.

암종 단위로 나눠 산출한다(`regbook_esophageal.json`, `regbook_gastric_TEST.json`, `regbook_page005_braintumor.json`). 한 암종부터 스키마를 확정하고 나머지로 넓힌다.

## API 응답은 캐시한다

`drug_detail_cache.json`, `drug_permit_cache.json`, `recollect_cache.json`. 식약처 API는 느리고 호출 제한이 있으며, **enrich 스크립트를 여러 번 돌리게 되기 때문에** 캐시가 없으면 작업이 불가능하다. `enrich_from_cache.py`가 캐시만으로 재구성한다.

## 사람 교정을 데이터로 관리한다

이 프로젝트에서 가장 배울 점이다. 교정을 코드에 넣지 않고 **JSON 파일 + apply 스크립트**로 분리했다:

| 파일 | apply 스크립트 |
|---|---|
| `regimenbook_dose_corrections.json` | `apply_dose_corrections.py` |
| `crop_review_decisions.json` | `apply_crop_review.py` |
| `needs_confirm.json` | `apply_needs_confirm.py` |
| `_review_flags_cache.json` | `apply_review_flags.py` / `apply_flag_dispositions.py` |
| `verify_corrections.json`, `orphan_corrections.json` | `apply_review.py` |
| `verified_ok.json`, `verify_findings.json`, `review_state.json` | 검토 상태 추적 |

**추출을 다시 돌려도 사람이 고친 내용이 살아남는다.** 추출 → 검토 → 교정 JSON → 재적용이 반복 가능한 파이프라인이 된다. 추출 결과를 직접 수정하면 다음 재추출에서 전부 날아간다.

`needs_confirm.json`(확인 필요)과 `verified_ok.json`(확인 완료)을 나눠 검토 진행 상황을 추적한다.

## 빌드·배포

```
build_db.py / build_sqlite.py / build_drugcost.py   SQLite 생성
enrich_db.py / enrich_detail.py / enrich_permit.py  API로 보강
export_d1.py → d1_import.sql + d1_overlay.sql       Cloudflare D1
export_web.py → web/                                 정적 웹
```

`d1_overlay.sql`을 `d1_import.sql`과 분리한 게 요령이다 — 전체 재적재 없이 변경분만 덮어쓴다.

## 프로젝트 문서를 Obsidian으로 관리한다

`CLAUDE.md`가 Obsidian 볼트(`~/Documents/obsidian/dnlife/허초프로젝트/`)의 6개 문서를 규정한다: `00_Overview`(고정 정보), `01_Plan`, `02_Progress`(날짜별), `03_Decisions`(기술 결정), `04_Issues`(미해결), `05_Completed`(검증 완료만).

규칙이 좋다 — **덮어쓰지 않고 날짜별로 추가**, **실제 완료한 것만 기록**, **확인되지 않은 내용은 추측하지 않음**. 긴 프로젝트에는 이 방식을 그대로 쓸 만하다. `obsidian-mcp-connector` MCP로 접근한다.

## 함정

- `oncoalert.db-shm`/`-wal`이 있다. SQLite WAL — 복사 시 세 파일을 함께.
- 공고책자는 연 1회 갱신된다. 고시번호가 바뀌면 요법 매칭이 깨진다. `relink_report.json`이 재연결 결과를 남긴다.
- `_experiments/`, `_archive/`에 시도한 것들이 남아 있다.

## 연관 skill

`[[ctrial-collect]]`·`[[cancer-type-classify]]`(담도암 치료옵션 조합), `[[excel-case-validator]]`
SKILL_PAYLOAD_EOF
mkdir -p "$DEST/regimen-extract/scripts"
cat > "$DEST/regimen-extract/scripts/corrections.py" <<'SKILL_PAYLOAD_EOF'
#!/usr/bin/env python3
"""사람이 고친 내용을 별도 파일로 두고 재추출 결과에 다시 얹는다.

출처: 허초프로젝트/scripts 의 apply_dose_corrections / apply_crop_review /
apply_needs_confirm / apply_review_flags 가 공유하는 패턴을 하나로 정리.

추출 결과를 직접 수정하면 다음 재추출에서 전부 날아간다.
교정을 코드가 아니라 **데이터(JSON)** 로 두면 추출 → 검토 → 교정 → 재적용이
반복 가능한 파이프라인이 된다.

    python corrections.py --records out.json --corrections fix.json --key id --out final.json
    python corrections.py --records out.json --corrections fix.json --key id --report
    python corrections.py --self-check
"""
import argparse
import json
import sys
from pathlib import Path

# 검토 상태
NEEDS_CONFIRM = "needs_confirm"   # 확인 필요
VERIFIED_OK = "verified_ok"       # 확인 완료
CORRECTED = "corrected"           # 사람이 고침


def load(path, default=None):
    p = Path(path)
    return json.loads(p.read_text(encoding="utf-8")) if p.exists() else (default if default is not None else {})


def save(path, obj):
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")


def apply_corrections(records, corrections, key="id", track=True):
    """교정을 레코드에 얹는다. → (결과 레코드, 리포트)

    corrections: {키: {필드: 새값}} 또는 {키: {"fields": {...}, "reason": "...", "by": "..."}}

    교정 대상이 사라진 키(orphan)는 반드시 보고한다 — 재추출로 레코드가 없어졌는데
    조용히 넘어가면 사람이 한 검토가 소리 없이 증발한다."""
    index = {str(r.get(key)): r for r in records}
    applied, orphans, unchanged = [], [], []

    out = [dict(r) for r in records]
    out_index = {str(r.get(key)): r for r in out}

    for k, spec in corrections.items():
        k = str(k)
        if k not in index:
            orphans.append(k)
            continue

        fields = spec.get("fields") if isinstance(spec, dict) and "fields" in spec else spec
        if not isinstance(fields, dict):
            orphans.append(k)
            continue

        rec = out_index[k]
        changed = {}
        for f, v in fields.items():
            if rec.get(f) != v:
                changed[f] = {"before": rec.get(f), "after": v}
                rec[f] = v

        if not changed:
            unchanged.append(k)
            continue

        if track:
            rec["_review"] = CORRECTED
            rec["_corrected_fields"] = sorted(changed)
            if isinstance(spec, dict) and spec.get("reason"):
                rec["_correction_reason"] = spec["reason"]
        applied.append({"key": k, "changed": changed})

    return out, {
        "applied": applied,
        "orphans": sorted(orphans),
        "unchanged": sorted(unchanged),
        "total_records": len(records),
        "total_corrections": len(corrections),
    }


def split_review(records, verified_keys=(), confirm_keys=(), key="id"):
    """검토 진행 상황을 나눈다. → dict(verified, needs_confirm, untouched)

    확인 완료와 확인 필요를 나눠 관리해야 어디까지 봤는지 추적된다."""
    v, c = {str(x) for x in verified_keys}, {str(x) for x in confirm_keys}
    buckets = {"verified": [], "needs_confirm": [], "untouched": []}
    for r in records:
        k = str(r.get(key))
        buckets["verified" if k in v else "needs_confirm" if k in c else "untouched"].append(k)
    return buckets


def report_text(rep):
    lines = [f"레코드 {rep['total_records']:,}건 · 교정 {rep['total_corrections']:,}건",
             f"  적용 {len(rep['applied'])}건",
             f"  변화 없음 {len(rep['unchanged'])}건"]
    if rep["orphans"]:
        lines.append(f"  ⚠ 대상 없음 {len(rep['orphans'])}건 — {', '.join(rep['orphans'][:10])}")
        lines.append("    재추출로 레코드가 사라졌는지 확인하세요. 사람 검토가 유실됩니다.")
    for a in rep["applied"][:20]:
        for f, d in a["changed"].items():
            lines.append(f"  {a['key']}.{f}: {d['before']!r} → {d['after']!r}")
    if len(rep["applied"]) > 20:
        lines.append(f"  … 외 {len(rep['applied']) - 20}건")
    return "\n".join(lines)


# ── 자체 점검 ───────────────────────────────────────────────────
def _self_check():
    recs = [{"id": "R1", "dose": "100mg", "cycle": 21},
            {"id": "R2", "dose": "50mg", "cycle": 14},
            {"id": "R3", "dose": "75mg", "cycle": 28}]

    # 단순 형식 + 상세 형식을 함께 받는다
    fixes = {
        "R1": {"dose": "120mg"},
        "R2": {"fields": {"cycle": 21}, "reason": "공고책자 오탈자", "by": "약제부"},
        "R3": {"dose": "75mg"},              # 이미 같은 값 → 변화 없음
        "R9": {"dose": "10mg"},              # 대상 없음
    }
    out, rep = apply_corrections(recs, fixes, key="id")

    assert out[0]["dose"] == "120mg" and out[0]["_review"] == CORRECTED
    assert out[0]["_corrected_fields"] == ["dose"]
    assert out[1]["cycle"] == 21 and out[1]["_correction_reason"] == "공고책자 오탈자"
    assert "_review" not in out[2], out[2]                 # 변화 없으면 표시도 안 남긴다
    assert rep["orphans"] == ["R9"], rep
    assert rep["unchanged"] == ["R3"], rep
    assert len(rep["applied"]) == 2

    # 원본은 건드리지 않는다
    assert recs[0]["dose"] == "100mg", recs[0]
    assert "_review" not in recs[0]

    # before/after 가 남는다
    ch = rep["applied"][0]["changed"]["dose"]
    assert ch == {"before": "100mg", "after": "120mg"}, ch

    # 재적용해도 같은 결과 (멱등)
    out2, rep2 = apply_corrections(out, fixes, key="id")
    assert out2[0]["dose"] == "120mg"
    assert len(rep2["applied"]) == 0 and rep2["unchanged"] == ["R1", "R2", "R3"], rep2

    # track=False 면 표시를 안 붙인다
    out3, _ = apply_corrections(recs, {"R1": {"dose": "120mg"}}, key="id", track=False)
    assert out3[0]["dose"] == "120mg" and "_review" not in out3[0]

    # 형식이 깨진 교정은 orphan 으로
    _, rep4 = apply_corrections(recs, {"R1": "120mg"}, key="id")
    assert rep4["orphans"] == ["R1"], rep4

    b = split_review(recs, verified_keys=["R1"], confirm_keys=["R2"])
    assert b == {"verified": ["R1"], "needs_confirm": ["R2"], "untouched": ["R3"]}, b

    assert "⚠" in report_text(rep) and "R9" in report_text(rep)

    import tempfile
    with tempfile.TemporaryDirectory() as d:
        f = Path(d) / "sub" / "c.json"
        save(f, fixes)
        assert load(f)["R1"] == {"dose": "120mg"}
        assert load(Path(d) / "없음.json", default={"x": 1}) == {"x": 1}
    print("self-check OK")


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--records"); p.add_argument("--corrections")
    p.add_argument("--key", default="id"); p.add_argument("--out")
    p.add_argument("--report", action="store_true")
    p.add_argument("--self-check", action="store_true")
    a = p.parse_args()

    if a.self_check:
        _self_check(); return 0
    if not (a.records and a.corrections):
        p.error("--records 와 --corrections 필요")

    out, rep = apply_corrections(load(a.records, []), load(a.corrections), a.key)
    if a.out:
        save(a.out, out)
        print(f"저장: {a.out}")
    if a.report or not a.out:
        print(report_text(rep))
    return 1 if rep["orphans"] else 0


if __name__ == "__main__":
    sys.exit(main())
SKILL_PAYLOAD_EOF
chmod +x "$DEST/regimen-extract/scripts/corrections.py"

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