#!/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/cancer-type-classify"
cat > "$DEST/cancer-type-classify/SKILL.md" <<'SKILL_PAYLOAD_EOF'
---
name: cancer-type-classify
description: 임상시험 제목·대상질환·연구목적 텍스트를 암 여부 + 암종(폐암/위암/담도암/유방암 등 20여종)으로 분류한다. 하드필터 + LLM 2단계 + 과거 피드백 RAG 보정의 3단 하이브리드. TRIGGER - "암종 분류", "무슨 암인지 구분", "oncology 여부", "담도암 임상시험 찾기", 임상시험/논문/문서를 암종별로 나눠야 할 때, 분류 결과에 사람 피드백을 반영해 개선하려 할 때.
---

# 암종 분류

## 언제 쓰나

텍스트 한 덩어리(임상시험 제목, 논문 제목, 공고문)를 **암 여부 → 암종**으로 나눠야 할 때. 담도암 환자에게 어떤 임상시험이 있는지 찾는 것 같은 조합 작업의 핵심 부품이다.

조합 예: `[[ctrial-collect]]`로 임상시험을 긁고 → 이 skill로 암종을 붙이고 → 담도암(`isBileDuctCancer`)만 필터 → `[[regimen-extract]]`로 레지멘 대조.

## 정본 코드

```
~/Documents/IMOK/ctrial-auto/app/
  services/clinical_classification_service.py   296줄  메인 로직
  utils/oncology_constants.py                   SHEET_MAPPING, CANCER_FIELDS
  prompt/oncology_detection.md                  1단계: 암 여부
  prompt/cancer_typing.md                       2단계: 암종 분류
  prompt/apply_feedback.md                      3단계: 피드백 보정
```

## 바로 쓰는 코드

`scripts/feedback_guard.py` — 피드백 보정이 분류를 전부 지우는 사고를 막는다.
`parse_json_result` · `cosine` · `find_similar`(임계 0.6, 상위 3건) · `apply_feedback`(전면삭제 원복 + Rule 9 의도적 비움 예외).

```python
from feedback_guard import apply_feedback
result, rolled_back, why = apply_feedback(original, improved, CANCER_FIELDS)
```

## 3단 하이브리드

**LLM을 언제 안 부를지가 설계의 절반이다.**

```
1) 하드 필터   대상질환 카테고리 ∈ {Oncology, Hemato-oncology} → 암 확정, GPT 호출 생략
2) LLM 1단계   그 외에만 oncology_detection 프롬프트로 암 여부 판정
3) LLM 2단계   암일 때만 cancer_typing으로 전체 암종 JSON
4) 피드백 보정  유사 과거 사례를 찾아 apply_feedback으로 수정
```

공공데이터 API가 이미 `TRGT_DISS_CD_NM`으로 암 여부를 알려주는데 그걸 다시 LLM에 묻는 건 낭비다. 하드필터로 걸러진 건은 GPT 1단계를 통째로 건너뛴다.

## 입력을 풍부하게 넣어라

제목만 주면 분류가 흔들린다. `_build_input_block()`이 있는 것만 골라 붙인다:

```
Clinical trial title (Korean): ...
English title: ...
Target disease name: ...
Target disease category (MFDS hint, may be inaccurate): ...
Research purpose: ... (500자로 자름)
```

카테고리 힌트에 **"may be inaccurate"를 명시**한다. 안 쓰면 LLM이 부정확한 MFDS 카테고리를 맹신한다. 연구목적은 길어서 500자로 자른다.

## 출력 형식: `[판정, 확신도, 근거]`

각 암종 필드가 3원소 배열이다.

```json
{"isBileDuctCancer": ["1", "0.9", "담도암 환자 대상 2상 시험"],
 "isLungCancer":    ["0", "1.0", "암 관련이 아님"]}
```

**근거를 같이 받는 게 핵심이다.** 의료 데이터에서 분류만 있고 근거가 없으면 검수를 못 한다. 확신도는 낮은 것만 사람이 보게 정렬하는 데 쓴다.

최종적으로 `SHEET_MAPPING`(영문 필드 → 한글)으로 `"위암;담도암"` 형태 문자열을 만들어 DB에 넣는다. 이 매핑은 분류·내보내기가 공유하는 **단일 소스**여야 한다 — 두 군데 두면 시트명이 어긋난다.

## 피드백 RAG 자가개선

사람이 분류를 고치면 `change_history` 테이블에 (제목, 변경사유, 이전분류, 새분류, **제목 임베딩**)을 남긴다. 다음 분류 때:

```
현재 제목 임베딩 → 저장된 임베딩과 코사인 유사도
  → 0.6 초과만 채택, 상위 3건
  → apply_feedback 프롬프트에 유사사례로 주입
```

**임베딩은 저장 시점 것을 재사용한다** — 매번 다시 만들면 느리고, 저장·질의가 다른 임베딩 모델을 쓰면 유사도가 무의미해진다. 그래서 `_embed()`를 공용 헬퍼 하나로 일원화했다.

### 가드가 필수다

피드백이 분류를 **전부 0으로 지워버리는 사고**가 실제로 났다. 그래서:

```
피드백 적용 전 양성(1) 암종 스냅샷 저장
  → 적용 후 양성이 하나도 안 남았고
  → 사유에 "진단/이식/표식/supportive/의료기기/신약 아님/관찰 연구" 키워드가 없으면
  → 스냅샷으로 원복 + WARNING 로그
```

키워드가 있으면 **의도적 비움**(진단용·기기 평가 등 신약 연구가 아닌 경우)이라 그대로 둔다. LLM 보정 루프를 넣을 때는 "보정이 모든 결과를 지울 수 있다"를 항상 가정하고 되돌림 경로를 만들어라.

## 현재 상태 주의

정본에서 피드백 단계가 꺼져 있다:

```python
sims = []  # 피드백 비활성화 (복원: sims = _find_similar_feedbacks(trial_title))
```

복원하려면 이 한 줄을 되돌리면 된다. `clinical_classification_service.py.bak_feedback_off` 백업 파일도 같은 디렉토리에 있다.

## 함정

- **JSON 파싱**: `_parse_json_result()`가 ` ```json ` 블록과 생 JSON을 모두 처리하고 실패 시 `None`을 반환한다. `or {}`로 받아 빈 dict 폴백. LLM 응답을 `json.loads` 한 번으로 끝내려 하지 마라.
- **`temperature=0` 고정**: 분류는 재현성이 전부다.
- **암 아님으로 판정되면 2·3단계를 건너뛴다**: 비용의 대부분이 여기서 줄어든다.

## 연관 skill

`[[ctrial-collect]]`(입력 공급), `[[regimen-extract]]`, `[[lit-relevance-classify]]`(같은 피드백 RAG 패턴), `[[llm-provider-switch]]`
SKILL_PAYLOAD_EOF
mkdir -p "$DEST/cancer-type-classify/scripts"
cat > "$DEST/cancer-type-classify/scripts/feedback_guard.py" <<'SKILL_PAYLOAD_EOF'
#!/usr/bin/env python3
"""LLM 보정이 분류 결과를 통째로 지우는 사고를 막는다.

출처: ctrial-auto/app/services/clinical_classification_service.py 의
apply_feedback 가드와 유사 피드백 검색(_parse_json_result / _cos_sim / _find_similar_feedbacks).

실제로 난 사고: 과거 피드백을 반영시켰더니 LLM이 양성 암종을 전부 0으로 지웠다.
그래서 보정 전 스냅샷을 떠두고, 전부 지워졌으면 되돌린다.
단 '진단·이식·의료기기·신약 아님' 같은 **의도적 비움**은 그대로 둔다.

LLM 보정 루프를 넣을 때는 "보정이 모든 결과를 지울 수 있다"를 항상 가정하고
되돌림 경로를 만들어라.

    python feedback_guard.py --self-check
"""
import argparse
import json
import re
import sys

# 의도적으로 암종을 비우는 정당한 사유 (원본 apply_feedback Rule 9)
INTENTIONAL_EMPTY_KEYWORDS = (
    "진단", "이식", "형광", "표식", "supportive", "의료기기", "기기 평가",
    "신약연구가 아니", "신약 연구가 아니", "암신약연구아님", "신약이 아니",
    "관찰 연구", "추적 관찰",
)

SIMILARITY_THRESHOLD = 0.6   # 이보다 낮은 과거 사례는 참고하지 않는다
TOP_N = 3


def parse_json_result(text):
    """LLM 응답에서 JSON 을 꺼낸다. 실패하면 None.

    ```json 블록과 생 JSON 을 모두 처리한다.
    LLM 응답을 json.loads 한 번으로 끝내려 하지 마라."""
    if not text:
        return None
    try:
        if "```" in text:
            for block in text.split("```"):
                b = block.strip()
                if not b:
                    continue
                if b.startswith("json"):
                    b = b[4:].strip()
                try:
                    return json.loads(b)
                except Exception:
                    continue
        return json.loads(text)
    except Exception:
        return None


def cosine(v1, v2):
    """코사인 유사도. 길이가 다르거나 비면 0."""
    if not v1 or not v2 or len(v1) != len(v2):
        return 0.0
    dot = sum(a * b for a, b in zip(v1, v2))
    n1 = sum(a * a for a in v1) ** 0.5
    n2 = sum(b * b for b in v2) ** 0.5
    return dot / (n1 * n2) if n1 and n2 else 0.0


def find_similar(current_vec, history, threshold=SIMILARITY_THRESHOLD, top_n=TOP_N):
    """과거 교정 이력에서 비슷한 사례를 고른다.

    history: [{"title":…, "embedding":[…], "old":[…], "new":[…], "reason":…}]

    저장 시점 임베딩을 그대로 쓴다 — 매번 다시 만들면 느리고, 저장·질의가
    다른 모델을 쓰면 유사도가 무의미해진다."""
    out = []
    for h in history:
        s = cosine(current_vec, h.get("embedding"))
        if s <= threshold:
            continue
        out.append({**{k: v for k, v in h.items() if k != "embedding"}, "similarity": s})
    out.sort(key=lambda x: x["similarity"], reverse=True)
    return out[:top_n]


def positive_fields(classification, fields):
    """양성(1)으로 판정된 필드만. 값은 [판정, 확신도, 근거] 3원소 배열."""
    return {k: v for k, v in classification.items()
            if k in fields and isinstance(v, list) and v and str(v[0]) == "1"}


def is_intentional_empty(classification, keywords=INTENTIONAL_EMPTY_KEYWORDS):
    """비움이 의도적인지 — 근거 텍스트 전체를 훑어 판단."""
    blob = str(classification.get("_analysis", "")) + " " + " ".join(
        str(v[2]) for v in classification.values()
        if isinstance(v, list) and len(v) >= 3)
    return any(kw in blob for kw in keywords)


def apply_feedback(original, improved, fields, is_positive_case=True,
                   keywords=INTENTIONAL_EMPTY_KEYWORDS):
    """보정 결과를 적용하되 전면 삭제는 되돌린다.

    → (최종 분류, 되돌렸는지, 사유)"""
    result = dict(original)

    # 3원소 배열 형태만 반영한다. 형식이 깨진 값은 무시.
    for k, v in (improved.get("modified_classification") or {}).items():
        if k in fields and isinstance(v, list) and len(v) >= 2:
            result[k] = v
    if "analysis" in improved:
        result["_analysis"] = improved.get("analysis", "")

    pre = positive_fields(original, fields)
    post = positive_fields(result, fields)

    if is_positive_case and pre and not post:
        if is_intentional_empty(result, keywords):
            return result, False, "의도적 비움(비신약·진단성 시험)으로 판단 → 유지"
        for k, v in pre.items():
            result[k] = v
        return result, True, f"보정이 양성 판정을 전부 제거 → 원복 (복원={sorted(pre)})"

    return result, False, ""


# ── 자체 점검 ───────────────────────────────────────────────────
FIELDS = {"isLungCancer", "isBileDuctCancer", "isGastricCancer"}
POS = ["1", "0.9", "담도암 대상 2상 시험"]
NEG = ["0", "1.0", "암 관련이 아님"]


def _self_check():
    assert parse_json_result('```json\n{"a": 1}\n```') == {"a": 1}
    assert parse_json_result('```\n{"a": 2}\n```') == {"a": 2}
    assert parse_json_result('{"a": 3}') == {"a": 3}
    assert parse_json_result("이건 JSON 아님") is None
    assert parse_json_result("") is None

    assert cosine([1, 0], [1, 0]) == 1.0
    assert abs(cosine([1, 0], [0, 1])) < 1e-9
    assert cosine([1, 0], [1, 0, 0]) == 0.0     # 길이 불일치
    assert cosine([], [1]) == 0.0
    assert cosine([0, 0], [1, 1]) == 0.0        # 0 벡터

    hist = [{"title": "A", "embedding": [1, 0], "reason": "r1"},
            {"title": "B", "embedding": [0, 1], "reason": "r2"},
            {"title": "C", "embedding": [0.9, 0.1], "reason": "r3"}]
    sim = find_similar([1, 0], hist)
    assert [h["title"] for h in sim] == ["A", "C"], sim   # B는 임계값 미달
    assert "embedding" not in sim[0]                      # 임베딩은 돌려주지 않는다
    assert find_similar([1, 0], []) == []

    orig = {"isBileDuctCancer": POS, "isLungCancer": NEG}
    assert set(positive_fields(orig, FIELDS)) == {"isBileDuctCancer"}

    # 1) 정상 보정 — 암종이 바뀌는 건 그대로 반영
    res, rolled, _ = apply_feedback(
        orig, {"modified_classification": {"isGastricCancer": ["1", "0.8", "위암"],
                                           "isBileDuctCancer": NEG},
               "analysis": "위암으로 정정"}, FIELDS)
    assert not rolled and set(positive_fields(res, FIELDS)) == {"isGastricCancer"}
    assert res["_analysis"] == "위암으로 정정"

    # 2) 사고 재현 — 전부 0으로 지워지면 되돌린다
    res, rolled, why = apply_feedback(
        orig, {"modified_classification": {"isBileDuctCancer": NEG}, "analysis": "암 아님"}, FIELDS)
    assert rolled and res["isBileDuctCancer"] == POS, (rolled, res)
    assert "원복" in why

    # 3) 의도적 비움은 유지 — 진단용·기기 평가 등
    res, rolled, why = apply_feedback(
        orig, {"modified_classification": {"isBileDuctCancer": NEG},
               "analysis": "진단 목적 의료기기 평가로 암 신약 연구가 아님"}, FIELDS)
    assert not rolled and str(res["isBileDuctCancer"][0]) == "0", (rolled, res)
    assert "의도적" in why

    # 4) 애초에 양성이 없었으면 되돌릴 것도 없다
    res, rolled, _ = apply_feedback({"isLungCancer": NEG}, {"modified_classification": {}}, FIELDS)
    assert not rolled

    # 5) 암 확정이 아닌 케이스는 가드를 걸지 않는다
    res, rolled, _ = apply_feedback(
        orig, {"modified_classification": {"isBileDuctCancer": NEG}}, FIELDS,
        is_positive_case=False)
    assert not rolled

    # 6) 형식이 깨진 보정값은 무시
    res, _, _ = apply_feedback(orig, {"modified_classification": {"isLungCancer": "1"}}, FIELDS)
    assert res["isLungCancer"] == NEG
    print("self-check OK")


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--self-check", action="store_true")
    a = p.parse_args()
    if a.self_check:
        _self_check(); return 0
    p.error("--self-check 로 동작을 확인하거나 모듈로 import 해서 쓰세요")


if __name__ == "__main__":
    sys.exit(main())
SKILL_PAYLOAD_EOF
chmod +x "$DEST/cancer-type-classify/scripts/feedback_guard.py"

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