#!/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/tnm-staging-extract"
cat > "$DEST/tnm-staging-extract/SKILL.md" <<'SKILL_PAYLOAD_EOF'
---
name: tnm-staging-extract
description: 병리·영상 판독문에서 암 병기(TNM, cTNM/pTNM, AJCC stage)를 LLM 에이전트로 추출한다. T/N/M을 정보추출-분류 2단으로 쪼갠 에이전트 분할, 규칙 기반 힌트 주입, 근거(evidence) 동반 출력, LangGraph 워크플로, 재시도·보류(deferred) 정책. TRIGGER - "TNM", "병기 추출", "stage 판정", "cT/cN/pT/pN", "AJCC", 판독문에서 병기를 뽑거나 병기 판정 정확도를 올려야 할 때.
---

# TNM 병기 추출

## 언제 쓰나

병리 보고서·영상 판독문 → `pT3N1bM0, Stage IIIB` 같은 병기. 대장암(CRC)·폐암 구현이 있고, 다른 암종으로 확장할 때 이 구조를 그대로 쓴다.

## 정본 코드

```
~/Documents/IMOK/CRC-TNM-Agents/src/
  llm/tnm_agents/          에이전트 30여 개 (공통 + crc/ + lung/)
  llm/tnm_agent_factory.py AGENT_TYPE_MAP + 캐시 + 평가유형→에이전트 라우팅
  llm/prompt_composer.py   YAML 프롬프트 조립
  pipeline/                langgraph_tnm_workflow, orchestrator, retry_policy, result_merger
  config/workflows/        워크플로 정의 (YAML)
~/Documents/IMOK/NSCLC-ModularStageLLM/   폐암 모듈러 버전 (논문용, CITATION.cff 있음)
~/Documents/IMOK/LungCancerTNM/
```

**같은 코드가 7벌 존재한다**(`CRC-TNM-Agents`, `_1`, `_crc`, `-251217`, `crc-tnm-app`, `crc-tnm-registry-closed-hotfix`, `@GMP/crc-tnm-registry-closed-main`). 기능 전체는 `IMOK/CRC-TNM-Agents`(2026-03-12, 174 py)가 가장 넓고, LLM provider 계층만은 `_crc`(2026-07-14, 176 py)가 최신이다. **어느 쪽을 정본으로 삼을지는 아직 미정**이다 — `[[llm-provider-switch]]` 참조.

## 바로 쓰는 코드

```bash
# 추출 결과에서 근거 없는 필드 찾기
python3 scripts/evidence.py --check-json result.json
```

`scripts/evidence.py` — `base_evidence_agent.py` 의 `EvidenceField` / `EvidenceExtractionResult`.
평면·중첩 형식 양방향 변환, `<b>` 하이라이트 검증(`is_grounded`), `ungrounded()` · `low_confidence()` 로 검수 대상 선별, `defer()` 로 판정 불가 표시.

## 핵심: 에이전트를 잘게 쪼갠다

한 프롬프트로 "이 판독문의 TNM을 말해줘"라고 하면 정확도가 안 나온다. **T/N/M별로 나누고, 각각을 다시 정보추출 → 분류로 쪼갠다.**

```
pathological_t_information_agent      판독문에서 T 관련 사실만 추출 (침윤 깊이, 장막 침범…)
pathological_t_characteristics_agent  종양 특성
pathological_t_margins_agent          절제연
      ↓
pathological_t_classification_agent   위 사실들로 pT 값만 판정
```

N·M도 같은 구조(`pathological_n_information` → `pathological_n_classification`, `metastasis_information` → `metastasis_classification`). 마지막에 `final_stage_agent`가 T·N·M을 합쳐 AJCC stage를 낸다.

**이렇게 나누는 이유**: 사실 추출과 규칙 적용을 한 번에 시키면 LLM이 근거 없이 병기를 지어낸다. 나누면 어느 단계에서 틀렸는지 짚을 수 있고, 분류 단계는 AJCC 규칙만 보므로 프롬프트가 짧아진다.

암종별 차이는 `crc/`, `lung/` 하위 디렉토리로 분리한다. 공통 골격은 최상위에 두고 암종 특이 에이전트만 추가한다(예: CRC의 `lymph_node_count_agent`, `discrepancy_analyzer_agent`).

## 근거를 반드시 같이 받는다

`base_evidence_agent.py`의 `EvidenceField`가 모든 추출 필드에 강제하는 4종 세트:

```
value       추출값
evidence    출처 문장. 해당 부분을 <b>...</b>로 감쌈
reasoning   왜 그렇게 판단했는지
confidence  확신도
```

`<b>` 하이라이트가 중요하다 — 검수자가 원문 어디를 보고 판단했는지 바로 확인한다. **근거 없는 병기는 임상에서 못 쓴다.** 출력은 평면(`pT_evidence`)과 중첩(`{"value":…, "evidence":…}`) 둘 다 지원한다(`to_dict` / `to_nested_dict`).

## 규칙 기반 힌트 주입

`llm/tnm_agents/rule_based_hints/`와 `registry_v2_hint_generator.py`가 판독문에서 정규식·키워드로 확실한 것을 먼저 뽑아 프롬프트에 힌트로 넣는다. LLM이 처음부터 맨몸으로 읽는 것보다 정확하다. **규칙으로 확실한 건 규칙으로, 애매한 것만 LLM에게.**

## 프롬프트는 YAML 조립

`PromptComposer`가 공통 모듈 + 에이전트별 프롬프트를 YAML에서 읽어 시스템 프롬프트를 조립한다(`config/prompts/`). 캐시하고 `force_reload`로 갱신. 30여 개 에이전트가 AJCC 정의 같은 공통 블록을 공유하므로, 프롬프트를 코드에 박으면 규칙 하나 고칠 때 30군데를 고쳐야 한다.

## 워크플로·재시도·보류

`pipeline/`이 LangGraph로 에이전트 실행 순서를 만든다. `dependency_resolver`가 의존 관계를 풀고 `workflow_builder`가 그래프를 세운다. 워크플로 정의는 `config/workflows/` YAML — 코드 수정 없이 에이전트 조합을 바꾼다.

`RetryPolicy`(기본 `max_retries=2`)의 판단 기준이 중요하다:

- `has_llm_fallback()` — 결과가 LLM 폴백으로 나왔는지 감지
- `should_retry()` — 재시도할 가치가 있는지
- `should_abort()` — 포기
- `DeferredOutputBuilder` — **판정 불가를 "보류"로 명시 출력**

마지막이 핵심이다. 억지로 병기를 내놓는 것보다 "이 케이스는 판정 불가"가 안전하다. 의료 추출에서 **모른다고 말할 수 있는 경로를 반드시 만들어라.**

## 함정

- `type_detector.py`가 문서 종류(병리/영상/수술기록)를 먼저 판별한다. 이걸 건너뛰면 영상 판독문에 병리 에이전트를 돌리게 된다.
- `imaging_checker_agent`는 영상 소견이 병리와 어긋날 때 잡아낸다. CRC의 `discrepancy_analyzer_agent`도 같은 역할.
- 에이전트는 `TNMAgentFactory`가 `{agent_type}_{id(llm_manager)}` 키로 캐시한다. 테스트에서 매번 새 인스턴스가 필요하면 캐시를 인지해라.
- `isolate_context` 옵션 — 에이전트 간 컨텍스트 오염을 막는다. 정확도가 흔들리면 여기부터 본다.

## 연관 skill

`[[llm-provider-switch]]`, `[[pathology-llm-extract]]`(같은 판독문 입력), `[[excel-case-validator]]`(케이스 정확도 검증)
SKILL_PAYLOAD_EOF
mkdir -p "$DEST/tnm-staging-extract/scripts"
cat > "$DEST/tnm-staging-extract/scripts/evidence.py" <<'SKILL_PAYLOAD_EOF'
#!/usr/bin/env python3
"""추출 필드에 근거를 강제하는 컨테이너.

출처: CRC-TNM-Agents/src/llm/tnm_agents/base_evidence_agent.py 의
EvidenceField / EvidenceExtractionResult 를 프로젝트 의존 없이 추출.

근거 없는 병기는 임상에서 못 쓴다. 모든 추출 필드가 값과 함께
출처 문장·판단 이유·확신도를 들고 다니게 강제한다.

evidence 안의 <b>...</b> 하이라이트가 중요하다 — 검수자가 원문 어디를 보고
판단했는지 바로 확인한다.

    python evidence.py --self-check
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional


@dataclass
class EvidenceField:
    """값 하나 + 그 근거.

    value       추출값
    evidence    출처 문장. 해당 부분을 <b>...</b> 로 감쌈
    reasoning   왜 그렇게 판단했는지
    confidence  0.0 ~ 1.0
    """
    value: Optional[Any] = None
    evidence: Optional[str] = None
    reasoning: Optional[str] = None
    confidence: float = 0.0

    def to_dict(self, field_name):
        """평면 형식 — 엑셀 컬럼으로 바로 펼칠 때."""
        return {
            field_name: self.value,
            f"{field_name}_evidence": self.evidence,
            f"{field_name}_reasoning": self.reasoning,
            f"{field_name}_confidence": self.confidence,
        }

    def to_nested_dict(self):
        """중첩 형식 — JSON 으로 주고받을 때."""
        return {"value": self.value, "evidence": self.evidence,
                "reasoning": self.reasoning, "confidence": self.confidence}

    @classmethod
    def from_dict(cls, data, field_name):
        """평면·중첩 둘 다 읽는다. LLM 응답 형식이 흔들려도 받아낸다."""
        v = data.get(field_name)
        if isinstance(v, dict):
            return cls(
                value=v.get("value"),
                evidence=v.get("evidence") or data.get(f"{field_name}_evidence"),
                reasoning=v.get("reasoning") or data.get(f"{field_name}_reasoning"),
                confidence=v.get("confidence", data.get(f"{field_name}_confidence", 0.0)),
            )
        return cls(
            value=v,
            evidence=data.get(f"{field_name}_evidence"),
            reasoning=data.get(f"{field_name}_reasoning"),
            confidence=data.get(f"{field_name}_confidence", 0.0),
        )

    def highlighted_source(self):
        """evidence 에서 <b> 태그를 걷어낸 원문."""
        return re.sub(r"</?b>", "", self.evidence or "")

    def highlighted_part(self):
        """<b> 로 표시된 부분만."""
        return " ".join(re.findall(r"<b>(.*?)</b>", self.evidence or "", re.DOTALL)).strip()

    def is_grounded(self):
        """근거가 실제로 원문을 가리키는지. 하이라이트가 없으면 근거로 치지 않는다."""
        return bool(self.highlighted_part())


@dataclass
class EvidenceExtractionResult:
    """필드 여러 개의 추출 결과."""
    fields: Dict[str, EvidenceField] = field(default_factory=dict)
    deferred: bool = False          # 판정 불가로 보류했는지
    deferred_reason: str = ""

    def get_field(self, name):
        return self.fields.get(name)

    def set_field(self, name, ef):
        self.fields[name] = ef

    def defer(self, reason):
        """억지로 값을 내놓는 것보다 '판정 불가'가 안전하다.
        의료 추출에서는 모른다고 말할 수 있는 경로를 반드시 만들어라."""
        self.deferred = True
        self.deferred_reason = reason

    def to_flat(self):
        out = {}
        for name, ef in self.fields.items():
            out.update(ef.to_dict(name))
        if self.deferred:
            out["_deferred"] = True
            out["_deferred_reason"] = self.deferred_reason
        return out

    def to_nested(self):
        out = {n: ef.to_nested_dict() for n, ef in self.fields.items()}
        if self.deferred:
            out["_deferred"] = {"value": True, "evidence": None,
                                "reasoning": self.deferred_reason, "confidence": 1.0}
        return out

    def ungrounded(self):
        """근거 하이라이트가 없는 필드 이름. 검수 대상."""
        return [n for n, ef in self.fields.items() if ef.value is not None and not ef.is_grounded()]

    def low_confidence(self, threshold=0.7):
        """확신도가 낮은 필드 이름. 사람이 먼저 볼 순서."""
        return [n for n, ef in self.fields.items() if ef.confidence < threshold]

    @classmethod
    def from_dict(cls, data, field_names):
        r = cls()
        for n in field_names:
            r.set_field(n, EvidenceField.from_dict(data, n))
        if data.get("_deferred"):
            r.defer(str(data.get("_deferred_reason", "")))
        return r


# ── 자체 점검 ───────────────────────────────────────────────────
def _self_check():
    ef = EvidenceField(value="pT3", confidence=0.9,
                       evidence="Tumor invades <b>through the muscularis propria</b> into pericolic tissue.",
                       reasoning="고유근층 관통 소견")
    assert ef.to_dict("pT")["pT_confidence"] == 0.9
    assert ef.to_dict("pT")["pT"] == "pT3"
    assert ef.to_nested_dict()["value"] == "pT3"
    assert ef.highlighted_part() == "through the muscularis propria"
    assert "<b>" not in ef.highlighted_source()
    assert ef.is_grounded()

    # 하이라이트 없는 근거는 근거로 안 친다
    assert not EvidenceField(value="pT3", evidence="어딘가에 그렇게 써 있음").is_grounded()
    assert not EvidenceField(value="pT3").is_grounded()

    # 평면 형식 왕복
    flat = {"pT": "pT3", "pT_evidence": "<b>x</b>", "pT_reasoning": "r", "pT_confidence": 0.8}
    back = EvidenceField.from_dict(flat, "pT")
    assert (back.value, back.confidence, back.reasoning) == ("pT3", 0.8, "r")

    # 중첩 형식도 같은 결과
    nested = {"pT": {"value": "pT3", "evidence": "<b>x</b>", "reasoning": "r", "confidence": 0.8}}
    assert EvidenceField.from_dict(nested, "pT").to_nested_dict() == back.to_nested_dict()

    # 중첩에 없는 값은 평면 키로 보충한다
    mixed = {"pT": {"value": "pT3"}, "pT_evidence": "<b>y</b>", "pT_confidence": 0.5}
    m = EvidenceField.from_dict(mixed, "pT")
    assert m.evidence == "<b>y</b>" and m.confidence == 0.5

    # 키가 아예 없어도 죽지 않는다
    miss = EvidenceField.from_dict({}, "pN")
    assert miss.value is None and miss.confidence == 0.0

    r = EvidenceExtractionResult()
    r.set_field("pT", ef)
    r.set_field("pN", EvidenceField(value="pN1", evidence="근거 하이라이트 없음", confidence=0.4))
    assert r.ungrounded() == ["pN"], r.ungrounded()
    assert r.low_confidence(0.7) == ["pN"], r.low_confidence(0.7)
    assert r.to_flat()["pN"] == "pN1"
    assert "_deferred" not in r.to_flat()

    r.defer("영상과 병리 소견이 상충")
    flat = r.to_flat()
    assert flat["_deferred"] is True and "상충" in flat["_deferred_reason"]
    assert r.to_nested()["_deferred"]["confidence"] == 1.0

    back = EvidenceExtractionResult.from_dict(flat, ["pT", "pN"])
    assert back.deferred and back.get_field("pT").value == "pT3"
    assert json.loads(json.dumps(back.to_nested(), ensure_ascii=False))["pT"]["value"] == "pT3"
    print("self-check OK")


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--self-check", action="store_true")
    p.add_argument("--check-json", help="추출 결과 JSON — 근거 없는 필드를 찾아낸다")
    p.add_argument("--fields", nargs="*", default=[])
    a = p.parse_args()

    if a.self_check:
        _self_check(); return 0
    if not a.check_json:
        p.error("--check-json 또는 --self-check 필요")

    data = json.load(open(a.check_json, encoding="utf-8"))
    names = a.fields or [k for k in data if not k.endswith(("_evidence", "_reasoning", "_confidence"))
                         and not k.startswith("_")]
    r = EvidenceExtractionResult.from_dict(data, names)
    bad, low = r.ungrounded(), r.low_confidence()
    print(f"필드 {len(names)}개 · 근거 없음 {len(bad)}개 · 확신도 낮음 {len(low)}개")
    if bad:
        print("근거 없음:", ", ".join(bad))
    if low:
        print("확신도 낮음:", ", ".join(low))
    return 1 if bad else 0


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

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