#!/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/recurrence-detect"
cat > "$DEST/recurrence-detect/SKILL.md" <<'SKILL_PAYLOAD_EOF'
---
name: recurrence-detect
description: 환자별 의무기록 타임라인에서 암 재발 여부·재발일·재발 부위(local/distant)를 LLM으로 판정하고, Ground Truth 대비 정확도를 평가한다. 홀드아웃 평가, self-consistency 다수결, 날짜 ±N일 허용·부위 Jaccard 부분점수 채점, 불일치 사례 분석, 프롬프트 BEFORE/AFTER 스냅샷. TRIGGER - "재발 판정", "recurrence", "재발일 추출", "GT 정확도", "모델 비교", "홀드아웃 평가", "다수결/self-consistency", LLM 추출 정확도를 측정·개선해야 할 때.
---

# 재발 판정 + LLM 정확도 평가

## 언제 쓰나

여러 시점의 의무기록을 시간순으로 읽어 **사건(재발)이 언제 어디서 일어났는지** 판정할 때. 그리고 더 넓게는 — **LLM 추출 정확도를 제대로 측정하는 방법**이 필요할 때. 평가 설계 부분은 재발이 아닌 어떤 추출 과제에도 그대로 쓴다.

## 정본 코드

```
~/Documents/IMOK/LungCancerRecurrence/
  run_batch_analysis.py          배치 실행
  analyze_gt_accuracy.py         ★ GT 대비 정확도 (정본)
  analyze_mismatches.py / analyze_all_mismatches.py   불일치 분석
  analyze_patient_detail.py      개별 환자 추적
  scripts_tmp_eval_full.py, scripts_tmp_modelcompare.py, scripts_tmp_parallel_run.py
  model_comparison_260617.md     ★ 모델 비교 결과 기록
  backup_prompts_260526/, backup_prompts_260611/   프롬프트 BEFORE/AFTER 스냅샷
  docker-compose.yml + app.db(SQLite)
```

## 바로 쓰는 코드

```bash
python3 scripts/score.py --gt gt.json --pred pred.json --spec spec.json
```

`scripts/score.py` — 네 개 파일에 재구현돼 있던 채점 코드를 합친 것.
`jaccard`(둘 다 비면 채점 제외) · `date_diff_days` · `Scorer`(지표별 n 분리 누적) · `majority`(self-consistency 다수결) · `report(min_n)`(표본 부족 경고).
재발 전용이 아니라 **어떤 LLM 추출 채점에도** 쓴다.

## 판정 구조

```
환자별 기록 타임라인
  → 상위 분석 (기록에서 재발 관련 사실 추출)
  → final_decision  재발 여부 + 재발일 + 부위 코드
```

상위 분석과 최종 판단을 나눈다. **모델을 바꿔 비교할 때 상위 분석은 재사용하고 최종 판단만 다시 돌린다** — 비교 비용이 크게 준다(`model_comparison_260617.md`의 "동일 상위분석 재사용").

## 채점을 제대로 설계해라 — 이 skill의 핵심

정확도를 "맞다/틀리다"로만 재면 개선이 안 보인다. 이 프로젝트가 쓰는 4지표:

| 지표 | 이유 |
|---|---|
| 날짜 정확일치 | 엄격 기준 |
| **날짜 ±30일** | 재발일은 확진일·영상일·기록일이 며칠씩 다르다. 완전일치만 재면 실제 성능이 안 보인다 |
| Local 부위 Jaccard | 부위는 여러 개일 수 있다 → 부분점수 |
| Distant 부위 Jaccard | 위와 동일 |

**부분점수(Jaccard)와 허용오차(±30일)가 있어야 프롬프트 개선이 지표에 나타난다.** 완전일치만 보면 "3개 중 2개 맞춤"이 0점이라 개선 방향을 못 잡는다.

비교 전에 정규화가 필요하다 — `normalize_llm_location()`이 LLM이 뱉은 부위 표현을 GT 코드 체계로 매핑한다. **정규화 없이 문자열 비교하면 실제보다 훨씬 낮게 나온다.**

`parse_gt_sites(gt_distant_site, gt_local_site, gt_mixed_site)` — GT가 여러 컬럼에 흩어져 있으면 파싱을 한 군데로 모아라.

## 홀드아웃 + 표본 수를 같이 적어라

```
홀드아웃 40명, 프로젝트 19
날짜 n=38~39 / Distant n=32~37 / Local n=3~7  ← Local은 신뢰도 낮음
```

**`n`을 지표 옆에 반드시 적는다.** `model_comparison_260617.md`는 "Local 92.9%"에 `n=7`을 붙이고 본문에 "신뢰도 낮음"을 명시했다. n을 안 적으면 표본 7건짜리 92.9%가 결론을 뒤집는다.

## 모델 비교에서 배운 것

| 방식 | 날짜 정확 | Distant(J) |
|---|---|---|
| gpt-oss:20b 단일 | 31.6% | 73.8% |
| gpt-oss:20b **다수결 3회** | 34.2% | 76.3% |
| llama3.3:70b 단일 | **41.0%** | 51.9% |

- **self-consistency(같은 입력 3회 호출 후 다수결)**: +2~3%p 일관 개선. 비용 3배지만 저위험.
- **단일 최강 모델은 없다.** 날짜는 llama가, 부위는 gpt-oss가 낫다 → 항목별 하이브리드가 현실적.
- 실패도 기록해라: `qwen3.5:35b-a3b`는 서버에서 사실상 무응답(간단 호출도 12초+ 타임아웃), 72분에 10명도 처리 못 해 제외. **왜 제외했는지 남기지 않으면 다음 사람이 또 시도한다.**

## 프롬프트 변경은 스냅샷으로 관리

`backup_prompts_YYYYMMDD/` 폴더에 변경 대상 파일의 `*_BEFORE.*` / `*_AFTER.*`를 함께 넣고 README에 원본 경로와 롤백 명령을 적는다:

```
final_decision_BEFORE.py  →  src/final_decision.py
ground_truth_api_BEFORE.py → src/backend/controllers/ground_truth_api.py
```

프롬프트를 고치면 지표가 오르내린다. **되돌릴 수 있어야 실험을 과감하게 한다.** git이 있어도 "이 지표는 어느 프롬프트에서 나온 건지"를 한 폴더로 묶어두는 게 훨씬 빠르다.

## 불일치를 봐야 개선된다

`analyze_mismatches.py`가 LLM 예측(`extract_llm_prediction`)과 분석 근거(`extract_analysis_details`)를 뽑아 GT와 다른 케이스를 정리한다. `analyze_patient_detail.py`로 개별 환자를 파고든다.

**틀린 케이스를 안 읽고 프롬프트를 고치는 건 추측이다.** 지표는 어디가 틀렸는지 안 알려준다.

## 함정

- 오래 걸리는 배치는 재시도 스크립트를 따로 둔다(`scripts_tmp_rerun_errors.py`). 전체 재실행은 최후 수단.
- 병렬 실행(`scripts_tmp_parallel_run.py`) 시 온프렘 서버 부하를 확인해라 — 위 qwen 사례가 그 결과다.
- `app.db-shm`/`app.db-wal`이 남아 있다. SQLite WAL 모드이므로 DB를 복사할 때 세 파일을 함께 옮겨야 한다.

## 연관 skill

`[[tnm-staging-extract]]`·`[[pathology-llm-extract]]`(추출 대상), `[[excel-case-validator]]`, `[[llm-provider-switch]]`
SKILL_PAYLOAD_EOF
mkdir -p "$DEST/recurrence-detect/scripts"
cat > "$DEST/recurrence-detect/scripts/score.py" <<'SKILL_PAYLOAD_EOF'
#!/usr/bin/env python3
"""LLM 추출 결과를 Ground Truth와 대조해 채점한다.

출처: LungCancerRecurrence 의 scripts_tmp_eval_full.py / _modelcompare.py /
_selfconsist_all.py / _validate_prompt.py 에 네 번 재구현돼 있던 채점 코드를 합친 것.

재발 판정 전용이 아니다. 값·날짜·집합·자유텍스트를 GT와 비교하는 어떤 추출 과제에도 쓴다.

핵심 규칙 두 가지:
  1) GT나 예측이 없는 항목은 채점에서 뺀다. 0점으로 세면 정확도가 왜곡된다.
  2) 지표마다 n(비교한 건수)을 따로 세서 함께 보고한다. n 없는 정확도는 못 믿는다.

    python score.py --gt gt.json --pred pred.json --spec spec.json
    python score.py --self-check
"""
import argparse
import json
import sys
from collections import defaultdict
from datetime import datetime

DATE_FORMATS = ("%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d", "%Y%m%d")


# ── 비교 함수 ───────────────────────────────────────────────────
def parse_date(s):
    """여러 표기를 datetime 으로. 실패하면 None."""
    if not s:
        return None
    t = str(s).strip()[:10]
    for fmt in DATE_FORMATS:
        try:
            return datetime.strptime(t, fmt)
        except ValueError:
            continue
    return None


def jaccard(a, b):
    """집합 부분점수. 둘 다 비면 None(채점 제외), 한쪽만 비면 0.0."""
    a, b = set(a or ()), set(b or ())
    if not a and not b:
        return None
    if not a or not b:
        return 0.0
    return len(a & b) / len(a | b)


def date_diff_days(gt, pred):
    """|GT - 예측| 일수. 한쪽이라도 못 읽으면 None."""
    g, p = parse_date(gt), parse_date(pred)
    if not g or not p:
        return None
    return abs((p - g).days)


def yn(v):
    """Y/N·1/0·True/False 를 bool 로. 판단 불가면 None."""
    if v is None or v == "":
        return None
    if isinstance(v, bool):
        return v
    t = str(v).strip().lower()
    if t in ("y", "yes", "1", "1.0", "true", "t", "예", "있음"):
        return True
    if t in ("n", "no", "0", "0.0", "false", "f", "아니오", "없음"):
        return False
    return None


def text_match(g, p):
    """자유텍스트 일치. 한쪽이라도 비면 None."""
    if not g or not p:
        return None
    return str(g).strip().lower() == str(p).strip().lower()


def majority(values):
    """self-consistency 다수결. 같은 입력을 여러 번 돌린 결과를 하나로.
    LungCancerRecurrence 실측: 3회 다수결이 단일 호출 대비 날짜·부위 +2~3%p."""
    vals = [v for v in values if v is not None]
    if not vals:
        return None
    counts = defaultdict(int)
    for v in vals:
        counts[json.dumps(v, sort_keys=True, ensure_ascii=False)] += 1
    best = max(counts.items(), key=lambda kv: kv[1])[0]
    return json.loads(best)


# ── 채점기 ──────────────────────────────────────────────────────
class Scorer:
    """지표별로 점수합과 n을 따로 누적한다."""

    def __init__(self, date_tolerances=(0, 14, 30)):
        self.sum = defaultdict(float)
        self.n = defaultdict(int)
        self.tol = tuple(date_tolerances)

    def bool_field(self, name, gt, pred):
        g, p = yn(gt), yn(pred)
        if g is None or p is None:
            return
        self.n[name] += 1
        self.sum[name] += 1.0 if g == p else 0.0

    def date_field(self, name, gt, pred):
        """정확일치와 허용오차별 점수를 한꺼번에. n은 하나로 공유한다."""
        d = date_diff_days(gt, pred)
        if d is None:
            return
        self.n[name] += 1
        for t in self.tol:
            key = f"{name}(정확)" if t == 0 else f"{name}(±{t}일)"
            self.sum[key] += 1.0 if d <= t else 0.0

    def set_field(self, name, gt, pred):
        j = jaccard(gt, pred)
        if j is None:
            return
        self.n[name] += 1
        self.sum[name] += j

    def text_field(self, name, gt, pred):
        m = text_match(gt, pred)
        if m is None:
            return
        self.n[name] += 1
        self.sum[name] += 1.0 if m else 0.0

    def rows(self):
        """[(지표, 백분율, n)] — 날짜 파생 지표는 원 지표의 n을 쓴다."""
        out = []
        for key in sorted(self.sum):
            base = key.split("(")[0]
            n = self.n.get(key) or self.n.get(base) or 0
            if n:
                out.append((key, self.sum[key] / n * 100, n))
        return out

    def report(self, title="채점 결과", min_n=10):
        lines = [f"## {title}", "", "| 지표 | 정확도 | n |", "|---|---|---|"]
        warn = []
        for key, pct, n in self.rows():
            flag = " ⚠" if n < min_n else ""
            lines.append(f"| {key} | {pct:.1f}% | {n}{flag} |")
            if n < min_n:
                warn.append(key)
        if warn:
            lines += ["", f"⚠ 표본 {min_n}건 미만이라 신뢰도가 낮음: {', '.join(warn)}"]
        return "\n".join(lines)


def score_records(gt_list, pred_list, spec, key="id"):
    """레코드 목록 두 개를 spec 대로 채점.
    spec = {"bool": [...], "date": [...], "set": [...], "text": [...]}"""
    preds = {r.get(key): r for r in pred_list}
    s = Scorer()
    for g in gt_list:
        p = preds.get(g.get(key))
        if p is None:
            continue
        for f in spec.get("bool", []):
            s.bool_field(f, g.get(f), p.get(f))
        for f in spec.get("date", []):
            s.date_field(f, g.get(f), p.get(f))
        for f in spec.get("set", []):
            s.set_field(f, g.get(f), p.get(f))
        for f in spec.get("text", []):
            s.text_field(f, g.get(f), p.get(f))
    return s


# ── 자체 점검 ───────────────────────────────────────────────────
def _self_check():
    assert jaccard([], []) is None                  # 둘 다 없음 → 채점 제외
    assert jaccard(["a"], []) == 0.0                # 한쪽만 없음 → 0점
    assert jaccard(["a", "b"], ["a", "b"]) == 1.0
    assert abs(jaccard(["a", "b"], ["a", "c"]) - 1/3) < 1e-9

    assert date_diff_days("2026-01-01", "2026-01-15") == 14
    assert date_diff_days("2026.01.01", "2026/01/01") == 0
    assert date_diff_days("", "2026-01-01") is None
    assert date_diff_days("2026-01-01", "언제인지 모름") is None

    assert yn("Y") is True and yn("0") is False and yn("") is None and yn("아마도") is None
    assert text_match("A", " a ") is True and text_match("", "a") is None

    assert majority(["x", "x", "y"]) == "x"
    assert majority([{"a": 1}, {"a": 1}, {"a": 2}]) == {"a": 1}
    assert majority([None, None]) is None

    gt = [{"id": 1, "recur": "Y", "date": "2026-01-01", "site": ["Lung"], "note": "A"},
          {"id": 2, "recur": "N", "date": "",           "site": [],       "note": ""}]
    pr = [{"id": 1, "recur": "Y", "date": "2026-01-20", "site": ["Lung", "Bone"], "note": "a"},
          {"id": 2, "recur": "Y", "date": "2026-02-02", "site": ["Liver"], "note": "b"}]
    s = score_records(gt, pr, {"bool": ["recur"], "date": ["date"], "set": ["site"], "text": ["note"]})
    r = dict((k, (round(v, 1), n)) for k, v, n in s.rows())

    assert r["recur"] == (50.0, 2), r                # 1건 맞고 1건 틀림
    assert r["date(정확)"] == (0.0, 1), r            # 2번은 GT 날짜가 없어 제외 → n=1
    assert r["date(±14일)"] == (0.0, 1), r           # 19일 차이라 ±14 밖
    assert r["date(±30일)"] == (100.0, 1), r         # ±30 안
    # 1번은 Jaccard 1/2, 2번은 GT가 빈 집합인데 모델이 부위를 답했으므로 0점으로 센다
    assert r["site"] == (25.0, 2), r
    assert r["note"] == (100.0, 1), r                # 2번은 GT 없어 제외

    assert "⚠" in s.report(min_n=10)                 # n 작으면 경고
    assert "⚠" not in s.report(min_n=1)
    print("self-check OK")


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--gt"); p.add_argument("--pred")
    p.add_argument("--spec", help='{"bool":[...],"date":[...],"set":[...],"text":[...]} JSON 파일')
    p.add_argument("--key", default="id")
    p.add_argument("--min-n", type=int, default=10, help="이보다 적으면 신뢰도 경고")
    p.add_argument("--self-check", action="store_true")
    a = p.parse_args()

    if a.self_check:
        _self_check(); return 0
    if not (a.gt and a.pred and a.spec):
        p.error("--gt --pred --spec 필요")

    load = lambda f: json.load(open(f, encoding="utf-8"))
    s = score_records(load(a.gt), load(a.pred), load(a.spec), a.key)
    print(s.report(min_n=a.min_n))
    return 0


if __name__ == "__main__":
    sys.exit(main())
SKILL_PAYLOAD_EOF
chmod +x "$DEST/recurrence-detect/scripts/score.py"

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