#!/bin/sh
# Claude Code Skill 설치 스크립트
# 생성: 2026-08-11 · Skill 1개 · 파일 3개
#
#   ./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/excel-case-validator"
cat > "$DEST/excel-case-validator/SKILL.md" <<'SKILL_PAYLOAD_EOF'
---
name: excel-case-validator
description: 원본 Excel 여러 개와 통합/처리 결과 Excel을 교차검증한다. 건수 일치, 키(PMID/DOI/시험번호) 중복, 필수 컬럼 완전성, 원본→통합 누락, 값 범위를 검사해 마크다운 리포트를 낸다. 제목 정규화 비교, 세대별 파일 관리, 중복 제거 패턴 포함. TRIGGER - "엑셀 검증", "데이터 무결성", "건수 안 맞음", "중복 확인", "누락 확인", "원본이랑 결과 비교", 처리 결과 Excel을 원본과 대조해야 할 때.
---

# Excel 케이스 교차검증

## 언제 쓰나

거의 모든 프로젝트에서 쓴다(Excel 취급 파일 1,816개). 원본 여러 개를 합치거나 LLM으로 처리한 뒤 **"몇 건이 어디로 샜는지"**를 확인해야 할 때. 검증 없이 넘어간 결과는 나중에 반드시 문제가 된다.

## 바로 실행

```bash
python ~/.claude/skills/excel-case-validator/scripts/validate.py \
  --sources wos.xlsx pubmed.xlsx koreamed.xlsx \
  --final consolidated.xlsx \
  --key PMID --key DOI \
  --required Title Year \
  --year-col Year --year-range 1963 2026
```

마크다운 표로 리포트를 내고, FAIL이 있으면 exit code 1. 원본을 절대 수정하지 않는다(read-only).

```bash
python .../validate.py --self-check   # 내장 테스트
```

`scripts/dedup.py` — 중복 제거와 세대 관리.

```bash
python3 scripts/dedup.py --file data.xlsx --key clinicExamSeq --latest-by 승인일 --out clean.xlsx
python3 scripts/dedup.py --latest-in ./excel_old   # 파일명 날짜로 최신 파일 찾기
```

`norm_title`(NFKC+공백 정규화) · `dedup`(승인일 최신 → 정보량 순 선택) · `diff_by_key`(회차 간 신규/제거) · `find_latest_by_date`.

## 검사 항목

| 검사 | 내용 |
|---|---|
| 건수 | 원본별 건수 + 통합 ≤ 원본 합계 |
| 키 중복 | `--key` 컬럼의 중복. **빈값은 중복으로 세지 않는다** |
| 필수 컬럼 완전성 | `--required` 컬럼의 채움 비율 |
| 누락 | 원본에는 있는데 통합에 없는 키 (예시 3건까지 표시) |
| 값 범위 | 연도 등 수치 범위 밖 값 |

**빈 키를 중복으로 세지 마라.** PMID 없는 논문이 여러 건인 건 정상이다. 이걸 놓치면 검증이 항상 FAIL로 나와 아무도 안 보게 된다.

## 비교는 정규화 후에 한다

`_norm()` — NFKC 정규화 → 앞뒤 공백 제거 → 연속 공백 축약 → 소문자.

제목으로 비교할 때는 공백까지 전부 제거하는 옵션이 필요하다. 정본 구현(`ctrial-auto/app/utils/compare_utils.py`의 `norm_title(s, remove_all_spaces=True)`)을 참고. **공백·괄호·전각문자 차이로 같은 레코드가 "신규"로 잡히는 사고**가 실제로 반복됐다.

## 프로젝트별 검증 항목은 별도로 정의한다

범용 스크립트로는 도메인 규칙을 못 잡는다. 프로젝트마다 검증 목록을 문서로 고정하고 전용 스크립트를 둔다. 좋은 예:

```
~/Documents/IMOK/AML260130_Korea AML MDS 30y/
  .claude/agents/data-validator.md    검증 10항목을 명시 (건수·중복·완전성·초록 보유율 93.5%·연도 1963~2026·색상 코딩)
  scripts/validate_data.py
  docs/VALIDATION_REPORT.md
```

**기대값을 숫자로 박아둔다**(WoS 1,679건 / PubMed 1,914건 / KoreaMed 761건 → 통합 2,583건). "대충 맞는 것 같다"로는 회귀를 못 잡는다.

## 중복 제거·세대 관리

`ctrial-auto/app/utils/compare_utils.py`에 실전에서 다듬어진 함수들이 있다:

| 함수 | 역할 |
|---|---|
| `dedup_file2_by_seq` | 신규 파일을 일련번호 기준 중복 제거 |
| `dedup_and_label_file1` | 기존 파일 중복 제거 + 라벨 부착 |
| `_pick_latest_by_approval` | 중복 그룹에서 승인일 최신 건 선택 |
| `_info_richness` | 정보가 더 많은 행을 남기는 기준 |
| `extract_date_from_filename` / `find_latest_file_by_date` | 파일명 날짜로 세대 관리 |
| `read_older_excels_newest_to_oldest` | 과거 파일을 최신순으로 순회 |

**중복 제거 시 어느 행을 남길지는 규칙이 필요하다.** 아무거나 남기면 정보가 적은 행이 살아남는다. `_info_richness`(채워진 필드 수)나 `_pick_latest_by_approval`(최신 승인일) 같은 명시적 기준을 써라.

## 함정

- `~$파일명.xlsx` 임시 파일이 있으면 Excel이 열려 있다는 뜻이다. 그 상태로 스크립트를 돌리면 깨진다. `신희정_교수님/pathology_processing/`에 실제로 남아 있다.
- 결과 파일명에 날짜시각을 박아라(`result_..._20260312_012900.xlsx`). 여러 번 돌리면 어느 게 어느 설정 결과인지 알 수 없어진다.
- 검증은 반드시 read-only. 검증 스크립트가 원본을 고치면 검증의 의미가 없다.

## 연관 skill

`[[ctrial-collect]]`(회차 비교), `[[biblio-analysis]]`(서지 통합 검증), `[[pathology-llm-extract]]`·`[[medical-code-extract]]`(추출 결과 검증)
SKILL_PAYLOAD_EOF
mkdir -p "$DEST/excel-case-validator/scripts"
cat > "$DEST/excel-case-validator/scripts/dedup.py" <<'SKILL_PAYLOAD_EOF'
#!/usr/bin/env python3
"""중복 제거와 날짜 기반 파일 세대 관리.

출처: ctrial-auto/app/utils/compare_utils.py 의 norm_title / _info_richness /
_pick_latest_by_approval / dedup_file2_by_seq / extract_date_from_filename /
find_latest_file_by_date 를 컬럼명 의존 없이 일반화한 것.

중복 제거에서 **어느 행을 남길지는 규칙이 필요하다.** 아무거나 남기면 정보가
적은 행이 살아남는다.

    python dedup.py --file data.xlsx --key clinicExamSeq --latest-by 승인일 --out clean.xlsx
    python dedup.py --latest-in ./excel_old
    python dedup.py --self-check
"""
import argparse
import re
import sys
import unicodedata
from pathlib import Path

DATE_IN_NAME = re.compile(r"(20\d{2})[-_.]?(\d{2})[-_.]?(\d{2})")


def norm_title(s, remove_all_spaces=False):
    """비교용 제목 정규화. 공백·괄호·전각문자 차이로 같은 레코드가
    '신규'로 잡히는 사고를 막는다."""
    if s is None or s != s:
        return ""
    t = unicodedata.normalize("NFKC", str(s)).strip()
    t = re.sub(r"\s+", " ", t).lower()
    return t.replace(" ", "") if remove_all_spaces else t


def same_title(a, b, remove_all_spaces=True):
    return norm_title(a, remove_all_spaces) == norm_title(b, remove_all_spaces)


def info_richness(row):
    """행의 정보량 — 비어있지 않은 값의 수."""
    return int(row.notna().sum())


def extract_date_from_filename(name):
    """파일명에 박힌 날짜 → 'YYYYMMDD'. 없으면 None."""
    m = DATE_IN_NAME.search(str(Path(name).name))
    return "".join(m.groups()) if m else None


def find_latest_by_date(directory, exts=(".xlsx", ".xls", ".csv")):
    """디렉터리에서 파일명 날짜가 가장 최근인 파일. 날짜가 없는 건 수정시각으로."""
    files = [p for p in Path(directory).iterdir() if p.suffix.lower() in exts and not p.name.startswith("~$")]
    if not files:
        return None
    dated = [(extract_date_from_filename(p), p) for p in files]
    with_date = [(d, p) for d, p in dated if d]
    if with_date:
        return max(with_date)[1]
    return max(files, key=lambda p: p.stat().st_mtime)


def dedup(df, key, latest_by=None, prefer="richness"):
    """key 중복을 제거한다. → (남긴 DF, 버린 DF)

    prefer:
      "richness"  정보가 가장 많은 행 (기본)
      "latest"    latest_by 컬럼이 가장 최신인 행
      "first"     그룹의 첫 행

    latest_by 를 주면 "latest" 로 먼저 고르고, 값이 없거나 동률이면 prefer 로 넘어간다."""
    import pandas as pd

    if key not in df.columns:
        return df.copy(), df.iloc[0:0].copy()

    keep_idx, drop_idx = [], []
    for _, g in df.groupby(key, dropna=True):
        if len(g) == 1:
            keep_idx.append(g.index[0]); continue

        chosen = None
        if latest_by and latest_by in g.columns:
            d = pd.to_datetime(g[latest_by], errors="coerce")
            if d.notna().any():
                top = d[d == d.max()].index
                chosen = top[0] if len(top) == 1 else None
                if chosen is None:
                    sub = g.loc[top]
                    chosen = max(top, key=lambda i: info_richness(sub.loc[i])) if prefer != "first" else top[0]

        if chosen is None:
            chosen = (g.index[0] if prefer == "first"
                      else max(g.index, key=lambda i: info_richness(g.loc[i])))

        keep_idx.append(chosen)
        drop_idx += [i for i in g.index if i != chosen]

    # key 가 비어 있는 행은 중복 판정에서 빼고 그대로 남긴다
    keep_idx += list(df.index[df[key].isna()])
    return df.loc[sorted(keep_idx)].copy(), df.loc[sorted(drop_idx)].copy()


def diff_by_key(old, new, key):
    """회차 간 변경. → dict(added, removed, common)"""
    o = {str(v) for v in old[key].dropna()} if key in old.columns else set()
    n = {str(v) for v in new[key].dropna()} if key in new.columns else set()
    return {"added": sorted(n - o), "removed": sorted(o - n), "common": sorted(o & n)}


# ── 자체 점검 ───────────────────────────────────────────────────
def _self_check():
    import pandas as pd

    assert norm_title(None) == "" and norm_title(float("nan")) == ""
    assert norm_title("  Ａ  Ｂ  ") == "a b"                    # 전각 → 반각
    assert norm_title("A  B", remove_all_spaces=True) == "ab"
    assert same_title("항암제 (제1상)", "항암제(제1상)")
    assert not same_title("위암 연구", "폐암 연구")

    assert extract_date_from_filename("list_20260403.xlsx") == "20260403"
    assert extract_date_from_filename("a_2026-04-03.xlsx") == "20260403"
    assert extract_date_from_filename("noname.xlsx") is None

    # 정보량 기준 — 값이 더 많은 행이 남는다
    df = pd.DataFrame({"seq": [1, 1, 2], "a": ["x", None, "z"], "b": ["y", None, None]})
    kept, dropped = dedup(df, "seq")
    assert len(kept) == 2 and len(dropped) == 1
    assert kept.loc[kept.seq == 1, "a"].iloc[0] == "x"

    # 승인일 기준 — 최신 행이 남는다 (정보량이 적어도)
    df2 = pd.DataFrame({"seq": [1, 1], "승인일": ["2026-01-01", "2026-05-01"],
                        "a": ["old", None], "b": ["old", None]})
    kept, _ = dedup(df2, "seq", latest_by="승인일")
    assert kept["승인일"].iloc[0] == "2026-05-01", kept

    # 승인일이 같으면 정보량으로 결정
    df3 = pd.DataFrame({"seq": [1, 1], "승인일": ["2026-01-01", "2026-01-01"],
                        "a": [None, "full"], "b": [None, "full"]})
    kept, _ = dedup(df3, "seq", latest_by="승인일")
    assert kept["a"].iloc[0] == "full", kept

    # 승인일이 전부 비면 정보량으로
    df4 = pd.DataFrame({"seq": [1, 1], "승인일": [None, None], "a": [None, "full"]})
    kept, _ = dedup(df4, "seq", latest_by="승인일")
    assert kept["a"].iloc[0] == "full"

    # key 가 빈 행은 중복으로 안 보고 남긴다
    df5 = pd.DataFrame({"seq": [None, None, 1], "a": ["p", "q", "r"]})
    kept, dropped = dedup(df5, "seq")
    assert len(kept) == 3 and len(dropped) == 0, (len(kept), len(dropped))

    # 없는 컬럼이면 원본 그대로
    kept, dropped = dedup(df5, "없는컬럼")
    assert len(kept) == 3 and len(dropped) == 0

    d = diff_by_key(pd.DataFrame({"k": [1, 2]}), pd.DataFrame({"k": [2, 3]}), "k")
    assert d == {"added": ["3"], "removed": ["1"], "common": ["2"]}, d
    print("self-check OK")


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--file"); p.add_argument("--key")
    p.add_argument("--latest-by", help="동률일 때 최신으로 볼 날짜 컬럼")
    p.add_argument("--prefer", choices=["richness", "latest", "first"], default="richness")
    p.add_argument("--out"); p.add_argument("--dropped-out")
    p.add_argument("--latest-in", help="이 디렉터리에서 가장 최근 파일을 찾는다")
    p.add_argument("--self-check", action="store_true")
    a = p.parse_args()

    if a.self_check:
        _self_check(); return 0
    if a.latest_in:
        f = find_latest_by_date(a.latest_in)
        print(f or "파일 없음")
        return 0 if f else 1
    if not (a.file and a.key):
        p.error("--file 과 --key 필요")

    import pandas as pd
    read = pd.read_csv if a.file.lower().endswith(".csv") else pd.read_excel
    df = read(a.file)
    kept, dropped = dedup(df, a.key, a.latest_by, a.prefer)
    print(f"{len(df):,}행 → {len(kept):,}행 (중복 {len(dropped):,}행 제거)")
    if a.out:
        (kept.to_csv if a.out.lower().endswith(".csv") else kept.to_excel)(a.out, index=False)
        print(f"저장: {a.out}")
    if a.dropped_out and len(dropped):
        (dropped.to_csv if a.dropped_out.lower().endswith(".csv") else dropped.to_excel)(a.dropped_out, index=False)
        print(f"제거분 저장: {a.dropped_out}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
SKILL_PAYLOAD_EOF
chmod +x "$DEST/excel-case-validator/scripts/dedup.py"
mkdir -p "$DEST/excel-case-validator/scripts"
cat > "$DEST/excel-case-validator/scripts/validate.py" <<'SKILL_PAYLOAD_EOF'
#!/usr/bin/env python3
"""원본 Excel들 ↔ 통합 Excel 무결성 교차검증. Read-only.

    python validate.py --sources a.xlsx b.xlsx --final merged.xlsx \
        --key PMID --key DOI --required Title Year --year-col Year --year-range 1963 2026

--self-check 로 내장 테스트 실행.
"""
import argparse
import sys

import pandas as pd


def _read(path):
    """엑셀/CSV를 DataFrame으로. 시트가 여럿이면 첫 시트."""
    if str(path).lower().endswith(".csv"):
        return pd.read_csv(path)
    return pd.read_excel(path)


def _norm(s):
    """비교용 정규화: NFKC, 연속공백 축약, 소문자. 빈값은 ''."""
    import re
    import unicodedata
    if pd.isna(s):
        return ""
    t = unicodedata.normalize("NFKC", str(s)).strip()
    return re.sub(r"\s+", " ", t).lower()


def check(sources, final, keys=(), required=(), year_col=None, year_range=None):
    """검증 수행 → (results, ok). results = [(항목, PASS/FAIL, 상세)]"""
    results = []

    def add(name, ok, detail):
        results.append((name, "PASS" if ok else "FAIL", detail))

    src_total = sum(len(df) for df in sources.values())
    for name, df in sources.items():
        add(f"원본 건수: {name}", True, f"{len(df):,}건")
    add("통합 건수 ≤ 원본 합계", len(final) <= src_total,
        f"통합 {len(final):,} / 원본합 {src_total:,}")

    # 키 중복 — 빈값은 중복 판정에서 제외 (PMID 없는 논문이 여럿인 건 정상)
    for key in keys:
        if key not in final.columns:
            add(f"키 컬럼 존재: {key}", False, "통합 파일에 컬럼 없음")
            continue
        vals = [_norm(v) for v in final[key]]
        nonblank = [v for v in vals if v]
        dupes = len(nonblank) - len(set(nonblank))
        add(f"{key} 중복 없음", dupes == 0,
            f"비어있지 않은 {len(nonblank):,}건 중 중복 {dupes}건")

    # 필수 컬럼 완전성
    for col in required:
        if col not in final.columns:
            add(f"필수 컬럼 존재: {col}", False, "컬럼 없음")
            continue
        filled = sum(1 for v in final[col] if _norm(v))
        add(f"{col} 완전성", filled == len(final),
            f"{filled:,}/{len(final):,} ({filled / len(final) * 100:.1f}%)" if len(final) else "0건")

    # 원본 → 통합 누락: 키 기준으로 원본에만 있는 값 탐지
    for key in keys:
        if key not in final.columns:
            continue
        final_vals = {_norm(v) for v in final[key] if _norm(v)}
        for name, df in sources.items():
            if key not in df.columns:
                continue
            src_vals = {_norm(v) for v in df[key] if _norm(v)}
            missing = src_vals - final_vals
            add(f"누락 없음: {name}.{key}", not missing,
                f"통합에 없는 {key} {len(missing)}건" + (f" 예: {list(missing)[:3]}" if missing else ""))

    if year_col and year_col in final.columns and year_range:
        lo, hi = year_range
        years = pd.to_numeric(final[year_col], errors="coerce").dropna()
        out = years[(years < lo) | (years > hi)]
        add(f"{year_col} 범위 {lo}~{hi}", out.empty,
            f"범위 밖 {len(out)}건" + (f" 예: {sorted(out.unique())[:5]}" if len(out) else ""))

    return results, all(r[1] == "PASS" for r in results)


def report(results, ok):
    """마크다운 표로 출력."""
    lines = ["| 항목 | 결과 | 상세 |", "|---|---|---|"]
    lines += [f"| {n} | {s} | {d} |" for n, s, d in results]
    lines.append("")
    lines.append(f"**{'전체 PASS' if ok else 'FAIL 있음'}** — {sum(1 for r in results if r[1] == 'FAIL')}건 실패")
    return "\n".join(lines)


def _self_check():
    src = pd.DataFrame({"PMID": ["1", "2", "3"], "Title": ["a", "b", "c"], "Year": [2020, 2021, 2022]})
    # 통합에서 PMID 3 누락 + PMID 1 중복 + Title 빈값 + 연도 범위 밖
    fin = pd.DataFrame({"PMID": ["1", "1", "2", "4"], "Title": ["a", "a", "", "d"], "Year": [2020, 2020, 2021, 1800]})
    results, ok = check({"src": src}, fin, keys=["PMID"], required=["Title"],
                        year_col="Year", year_range=(1963, 2026))
    got = {n: s for n, s, _ in results}
    assert not ok
    assert got["PMID 중복 없음"] == "FAIL", got
    assert got["Title 완전성"] == "FAIL", got
    assert got["누락 없음: src.PMID"] == "FAIL", got
    assert got["Year 범위 1963~2026"] == "FAIL", got

    # 정상 케이스는 전부 PASS
    fin2 = pd.DataFrame({"PMID": ["1", "2", "3"], "Title": ["a", "b", "c"], "Year": [2020, 2021, 2022]})
    _, ok2 = check({"src": src}, fin2, keys=["PMID"], required=["Title"],
                   year_col="Year", year_range=(1963, 2026))
    assert ok2

    # 빈 키는 중복으로 세지 않는다
    fin3 = pd.DataFrame({"PMID": ["", "", "1"], "Title": ["a", "b", "c"]})
    r3, _ = check({}, fin3, keys=["PMID"])
    assert dict((n, s) for n, s, _ in r3)["PMID 중복 없음"] == "PASS"
    print("self-check OK")


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--sources", nargs="*", default=[])
    p.add_argument("--final")
    p.add_argument("--key", action="append", default=[], help="중복·누락 검사 키. 여러 번 지정 가능")
    p.add_argument("--required", nargs="*", default=[], help="비어있으면 안 되는 컬럼")
    p.add_argument("--year-col")
    p.add_argument("--year-range", nargs=2, type=int)
    p.add_argument("--self-check", action="store_true")
    a = p.parse_args()

    if a.self_check:
        _self_check()
        return 0
    if not a.final:
        p.error("--final 필요")

    results, ok = check({s: _read(s) for s in a.sources}, _read(a.final),
                        a.key, a.required, a.year_col, tuple(a.year_range) if a.year_range else None)
    print(report(results, ok))
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())
SKILL_PAYLOAD_EOF
chmod +x "$DEST/excel-case-validator/scripts/validate.py"

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