#!/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/llm-provider-switch"
cat > "$DEST/llm-provider-switch/SKILL.md" <<'SKILL_PAYLOAD_EOF'
---
name: llm-provider-switch
description: 온프렘 Ollama ↔ OpenAI ↔ Azure OpenAI를 설정만 바꿔 전환하고 장애 시 자동 폴백하는 LLM 제공자 계층을 설계·구현한다. 다중 Ollama 서버 로드밸런싱, health check, thinking 모델(Qwen3 등) JSON 파싱, Azure content filter 대응을 포함. TRIGGER - 병원 온프렘 환경에서 개발하다 클라우드로 검증하려 할 때, LLM provider를 바꾸거나 추가할 때, "Ollama", "Azure OpenAI", "provider 전환", "폴백", "모델 서버 여러 대", "thinking 태그", "JSON 파싱 실패"가 나올 때.
---

# LLM Provider 전환 계층

## 언제 쓰나

병원 프로젝트는 거의 항상 이 왕복을 한다: **온프렘 Ollama에서 개발 → Azure/OpenAI로 성능 검증 → 다시 온프렘 배포**. 매번 새로 짜면 프로젝트마다 provider 코드가 갈라진다. 실제로 `CRC-TNM-Agents` 7벌의 `llm_manager.py`가 서로 다르게 갈라져 있다.

## 정본 코드

```
~/Documents/IMOK/CRC-TNM-Agents_crc/src/llm/llm_providers/
  base_llm_provider.py   311줄  추상 인터페이스 + 예외 체계 + 재시도
  llm_manager.py         572줄  provider 선택·폴백·health check
  ollama_provider.py     783줄  다중서버 로드밸런싱 + thinking 대응
  openai_provider.py     464줄
  azure_provider.py      505줄
```

`_crc`가 이 계열에서 가장 최근에 손댄 버전(2026-07-14)이고 `ollama_http_client.py`를 유일하게 가지고 있다. 새 프로젝트는 **이 디렉토리를 복사해서 시작**한다. `src.setting.config`에 의존하므로 import 경로만 고치면 된다.

## 바로 쓰는 코드

```bash
# 추론 모델 응답에서 JSON 건지기 (4단 폴백)
echo '<think>{"category":"T3"}</think> 설명' | python3 scripts/thinking_json.py
```

`scripts/thinking_json.py` — `ollama_provider.py` 의 thinking 처리 4종을 provider 의존 없이 뺀 것.
`extract_thinking` · `remove_thinking_tags` · `extract_json`(json.loads 검증 포함) · `answer_to_json`.
`parse(content, value_pattern)` 이 본문 → thinking → 값 변환 순으로 물러나며 시도하고, 어느 단계에서 나왔는지 함께 돌려준다.

## 구조

3계층으로 나눈다. 이 경계를 지켜야 provider 추가가 한 파일 추가로 끝난다.

```
LLMManager          어느 provider를 쓸지 결정. 폴백·health check·통계
  └ BaseLLMProvider  추상 인터페이스 (ABC)
      ├ OllamaProvider     온프렘
      ├ OpenAIProvider     클라우드
      └ AzureOpenAIProvider 병원 계약 클라우드
```

`BaseLLMProvider`가 강제하는 5개 추상 메서드 — 새 provider를 붙일 때 이것만 구현하면 된다:

| 메서드 | 역할 |
|---|---|
| `initialize()` | 클라이언트 생성, 모델 확인 |
| `generate_response(LLMRequest) -> LLMResponse` | 단발 호출 |
| `generate_stream(LLMRequest)` | 스트리밍 |
| `validate_connection() -> bool` | health check용 |
| `get_available_models() -> List[str]` | 모델 목록 |

`LLMRequest` / `LLMResponse` 데이터클래스로 provider별 응답 형식을 흡수한다. **호출부는 provider를 절대 몰라야 한다** — 이게 깨지면 전환 계층의 의미가 없어진다.

## 폴백 동작

```
primary provider 시도
  → 실패하면 fallback_providers 순회
    → 성공한 provider로 응답 + 어느 provider가 응답했는지 메타데이터 기록
  → 전부 실패하면 LLMManagerError
```

메타데이터 기록(`_record_llm_metadata`)이 중요하다. **어느 응답이 폴백으로 나온 것인지 남기지 않으면 나중에 결과 품질 차이를 설명할 수 없다.** 의료 데이터에서 "이 케이스는 Ollama가 아니라 Azure가 답했다"는 재현성의 핵심 정보다.

health check는 5분 간격(`_health_check_interval = 300`)으로 lazy 실행 — 매 호출마다 하면 느리고, 안 하면 죽은 서버로 계속 보낸다.

## 함정

### 1. thinking 모델이 JSON을 thinking 안에 넣는다

Qwen3 등 reasoning 모델은 `<think>...</think>` 안에 정답 JSON을 넣고 본문에는 산문을 쓴다. 그대로 파싱하면 전부 실패한다. `ollama_provider.py`의 4단 대응을 그대로 가져다 쓴다:

```
_extract_thinking()         <think> 내용 추출
_remove_thinking_tags()     본문에서 태그 제거
_extract_json_from_thinking()  thinking 안의 ```json 블록 또는 { } 추출 + json.loads 유효성 검증
_convert_thinking_to_json()    "Final Answer: T2b", **T2b**, \boxed{T2b} 같은 텍스트 응답을 JSON으로 변환
```

**추출한 JSON은 반드시 `json.loads`로 검증하고, 실패하면 다음 패턴으로 넘어간다.** 정규식 매치만 믿으면 깨진 JSON이 통과한다.

### 2. Azure content filter가 의료 텍스트를 막는다

병리·수술 기록에 나오는 표현이 Azure content filter에 걸린다. `should_use_azure_safe_mode()`로 우회 모드를 켠다 — Azure provider이면서 `content_filter_enabled` 환경변수가 True일 때만 활성화. **환경변수로 끄고 켤 수 있어야 한다.** 코드에 하드코딩하면 다른 병원 계약에서 못 쓴다.

### 3. Ollama 서버 여러 대

`_GlobalServerIndexManager` 싱글턴이 round_robin으로 서버를 배분한다. 프로세스 전역이라 워커가 여러 개여도 한쪽 서버로 쏠리지 않는다. 서버별 healthy/unhealthy 마킹(`_mark_server_unhealthy`)으로 죽은 서버를 건너뛴다.

첫 호출 전에 `_warmup_models()`로 모델을 올려둔다. 안 하면 첫 케이스만 수십 초 걸려서 벤치마크가 왜곡된다.

### 4. 예외를 provider별로 뭉개지 마라

`base_llm_provider.py`가 정의하는 예외 체계를 유지한다 — `LLMConnectionError`(폴백 대상), `LLMAuthenticationError`(폴백해도 소용없음), `LLMRateLimitError`(재시도 대상), `LLMTimeoutError`, `LLMModelNotFoundError`. **전부 `Exception`으로 잡으면 인증 오류에도 폴백을 시도하며 시간을 낭비한다.**

## 연관 skill

`[[tnm-staging-extract]]`, `[[pathology-llm-extract]]`, `[[medical-code-extract]]`, `[[cancer-type-classify]]` — 모두 이 계층 위에서 동작한다.
SKILL_PAYLOAD_EOF
mkdir -p "$DEST/llm-provider-switch/scripts"
cat > "$DEST/llm-provider-switch/scripts/thinking_json.py" <<'SKILL_PAYLOAD_EOF'
#!/usr/bin/env python3
"""추론(thinking) 모델의 응답에서 JSON을 건져낸다.

출처: CRC-TNM-Agents_crc/src/llm/llm_providers/ollama_provider.py 의
_extract_thinking / _remove_thinking_tags / _extract_json_from_thinking /
_convert_thinking_to_json 을 provider 의존 없이 추출한 것.

Qwen3 같은 추론 모델은 정답 JSON을 <think>...</think> 안에 넣고 본문에는
산문을 쓴다. json.loads 한 번으로 끝내려 하면 전부 실패한다.

    echo '<think>{"a":1}</think> 설명' | python thinking_json.py
    python thinking_json.py --self-check
"""
import argparse
import json
import re
import sys

THINK_RE = re.compile(r"<think>(.*?)</think>", re.DOTALL)

# "Final Answer: T2b", **T2b**, \boxed{T2b} 처럼 값만 뱉은 응답을 건지는 패턴
ANSWER_PATTERNS = (
    r"\\boxed\{([^}]+)\}",
    r"\*\*Final Answer:\*\*\s*\*\*([^*]+)\*\*",
    r"Final Answer:\s*\*\*([^*]+)\*\*",
    r"\*\*Answer:\*\*\s*\*\*([^*]+)\*\*",
    r"Answer:\s*\*\*([^*]+)\*\*",
    r"Final Answer:\s*([^\n*]+)",
    r"(?:category|classification)\s*(?:is|:)\s*\*\*([^*]+)\*\*",
)


def extract_thinking(content):
    """<think> 안의 내용."""
    m = THINK_RE.search(content or "")
    return m.group(1).strip() if m else ""


def remove_thinking_tags(content):
    """본문에서 <think> 블록을 걷어낸다."""
    return THINK_RE.sub("", content or "").strip()


def extract_json(text):
    """텍스트에서 유효한 JSON 문자열만 골라낸다. 없으면 ''.

    정규식 매치만 믿으면 깨진 JSON이 통과하므로 **반드시 json.loads 로 검증**하고
    실패하면 다음 패턴으로 넘어간다."""
    if not text:
        return ""

    # 1) ```json ... ``` 블록
    m = re.search(r"```json\s*([\s\S]*?)\s*```", text)
    if m:
        s = m.group(1).strip()
        try:
            json.loads(s)
            return s
        except json.JSONDecodeError:
            pass

    # 2) 이름 없는 ``` 블록
    m = re.search(r"```\s*([\s\S]*?)\s*```", text)
    if m:
        s = m.group(1).strip()
        try:
            json.loads(s)
            return s
        except json.JSONDecodeError:
            pass

    # 3) 중괄호로 감싸인 덩어리 — 가장 바깥부터 좁혀가며 시도
    starts = [i for i, c in enumerate(text) if c == "{"]
    ends = [i for i, c in enumerate(text) if c == "}"]
    for a in starts:
        for b in reversed(ends):
            if b <= a:
                continue
            s = text[a:b + 1].strip()
            try:
                json.loads(s)
                return s
            except json.JSONDecodeError:
                continue
    return ""


def answer_to_json(thinking, value_pattern=None, confidence=0.7):
    """값만 뱉은 추론 응답을 JSON 으로 되살린다. 없으면 ''.

    value_pattern: 도메인 값의 정규식. 예: TNM 이면 r"\\b(T[0-4][a-c]?|N[0-3]|M[01][a-c]?)\\b" """
    if not thinking:
        return ""

    value = None
    for pat in ANSWER_PATTERNS:
        m = re.search(pat, thinking, re.IGNORECASE)
        if m:
            value = m.group(1).strip().strip("*").strip()
            break

    if not value and value_pattern:
        m = re.search(value_pattern, thinking)
        if m:
            value = m.group(1)

    if not value:
        return ""

    return json.dumps({
        "category": value,
        "reasoning": thinking[:500].replace('"', "'").replace("\n", " "),
        "confidence_score": confidence,
        "confidence_reasoning": "Extracted from thinking mode response",
        "key_findings": [],
        "evidence_text": "Response converted from thinking mode",
    }, ensure_ascii=False)


def parse(content, value_pattern=None):
    """추론 모델 응답 → dict. 4단계로 물러나며 시도하고 전부 실패하면 None.

    → (결과, 어느 단계에서 나왔는지)"""
    thinking = extract_thinking(content)
    body = remove_thinking_tags(content)

    s = extract_json(body)
    if s:
        return json.loads(s), "body"

    s = extract_json(thinking)
    if s:
        return json.loads(s), "thinking"

    s = answer_to_json(thinking or body, value_pattern)
    if s:
        return json.loads(s), "converted"

    return None, "failed"


# ── 자체 점검 ───────────────────────────────────────────────────
TNM = r"\b(T[0-4][a-c]?|N[0-3]|M[01][a-c]?)\b"


def _self_check():
    assert extract_thinking("<think> 고민 </think>답") == "고민"
    assert extract_thinking("태그 없음") == ""
    assert remove_thinking_tags("<think>x</think>  답  ") == "답"

    # 본문에 정상 JSON
    r, how = parse('<think>생각</think>```json\n{"category":"T2"}\n```')
    assert (r, how) == ({"category": "T2"}, "body"), (r, how)

    # 정답 JSON이 thinking 안에 들어간 전형적 실패 사례
    r, how = parse('<think>정리하면 {"category": "T3a", "confidence_score": 0.9} 이다</think>'
                   "따라서 T3a 로 판단됩니다.")
    assert how == "thinking" and r["category"] == "T3a", (r, how)

    # 값만 뱉은 경우
    r, how = parse("<think>여러모로 보아 **Final Answer:** **T2b**</think>", TNM)
    assert how == "converted" and r["category"] == "T2b", (r, how)
    r, how = parse(r"<think>계산 결과 \boxed{N1} 이다</think>", TNM)
    assert how == "converted" and r["category"] == "N1", (r, how)
    # 정해진 문구가 없어도 도메인 패턴으로 건진다
    r, how = parse("<think>침윤 깊이로 보아 T4a 에 해당한다</think>", TNM)
    assert how == "converted" and r["category"] == "T4a", (r, how)

    # 깨진 JSON 은 통과시키지 않는다
    assert extract_json('```json\n{"a": }\n```') == ""
    assert extract_json("{ 이건 JSON 이 아니다 }") == ""
    r, how = parse("<think>모르겠다</think>판단 불가")
    assert r is None and how == "failed", (r, how)

    # 뒤에 잡소리가 붙어도 JSON 만 건진다
    assert json.loads(extract_json('설명 {"a": 1} 끝')) == {"a": 1}
    print("self-check OK")


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--value-pattern", help="도메인 값 정규식 (예: TNM)")
    p.add_argument("--self-check", action="store_true")
    a = p.parse_args()

    if a.self_check:
        _self_check(); return 0

    result, how = parse(sys.stdin.read(), a.value_pattern)
    if result is None:
        print("파싱 실패", file=sys.stderr)
        return 1
    print(json.dumps(result, ensure_ascii=False, indent=2))
    print(f"(출처: {how})", file=sys.stderr)
    return 0


if __name__ == "__main__":
    sys.exit(main())
SKILL_PAYLOAD_EOF
chmod +x "$DEST/llm-provider-switch/scripts/thinking_json.py"

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