# 작업지시서 — Phase C LoRA 가속 (C1 데이터 큐레이션 우선)

> 대상: 코딩 LLM. C1을 우선 완료, C2·C3는 진입 조건 만족 시 별도 트리거.
> 상위 플랜: `plans/OPUS-v2-실행계획.md` Phase C.
> Phase B(Verifier) 완료 후의 후속 — Verifier 로그를 **학습 데이터의 1차 소스**로 활용.

---

## 0. 배경 (왜 LoRA, 왜 지금)

Phase B Verifier가 invention을 *런타임*에서 결정적으로 차단하고 있지만, 이건 **증상 차단**입니다.
근본 원인 = 모델이 *애초에* 잘못된 호출을 생성하는 행동 패턴. 이걸 **모델 weights에 박는** 게 LoRA의 역할:
- "사용자가 명시 안 한 태그는 *항상* `find_tags` 먼저 호출"
- "area는 `P6-1` 형식만 사용"
- "툴이 빈 결과 반환 시 자기 인자를 의심"

**핵심 인사이트**: Verifier 거부 로그(`mcp-server/verifier/logs/*.jsonl`)가 *이미 production에서* "잘못된 호출 → hint → 자기교정" 행동 데이터를 자동 수집 중. 이걸 가공만 하면 **즉시 학습 데이터**.

---

## C1 — 데이터 큐레이션 (1주, 본 지시서의 주력)

### C1.1 입력 소스

| 소스 | 경로 | 양 | 비고 |
|---|---|---|---|
| **Verifier 거부 로그** | `mcp-server/verifier/logs/*.jsonl` | 현재 3건, 운영 기간 늘수록 자동 증가 | 1차 소스. "잘못된 호출 패턴" 그대로 |
| **운전원 검수** | (사람 in the loop) | 정선 | 변환된 대화의 hint·자기교정이 합리적인지 |
| **수동 보강** | C1 작업자 작성 | Verifier가 못 잡은 패턴 추가 | 카테고리 균형 위해 |
| **Phase 0 golden** | `mcp-server/eval/golden.jsonl` | 40 | **학습에 사용 금지**(평가셋 누수 차단). 다만 카테고리 분포 참고 |

### C1.2 출력 — `mcp-server/training/` 신규

| 파일 | 포맷 | 용도 |
|---|---|---|
| `training/sft_data.jsonl` | OpenAI messages | C2 SFT-LoRA 입력 |
| `training/sft_data_stats.md` | 통계 | 카테고리 분포·토큰 분포·중복 여부 |
| `training/curate_from_verifier.py` | 스크립트 | Verifier 로그 → ShareChat 변환 |

### C1.3 변환 규칙 — Verifier 거부 1건 → ShareChat 대화 1건

각 거부 로그를 **5턴 대화**로:

```json
{"messages":[
  {"role":"system","content":"<plant_context 요약 + grounding 규칙>"},
  {"role":"user","content":"<자연어 질문 — 거부 시 user input을 운전원이 작성>"},
  {"role":"assistant","content":"<잘못된 도구 호출 — params 그대로>"},
  {"role":"tool","content":"<verifier_error + hint>"},
  {"role":"assistant","content":"<올바른 자기교정 호출 — 운전원이 작성>"}
]}
```

**예** (현재 로그의 RM-6101 거부):
- user: "6-1차 플랜트 원료 투입 경로 알려줘"
- assistant: `[trace_connections] start_tag="RM-6101" direction="downstream"` (잘못)
- tool: `{"verifier_error":"R1.tag_not_found","hint":"...find_tags 먼저...","suggested":["f-6101a","f-6101b","fcv-6101"]}`
- assistant: `[find_tags] query="원료 투입" sub_area="P6-1"` (자기교정 = 올바른 행동)

학습 후 모델이 **그 자기교정 행동을 *처음부터* 수행**하게 됨.

#### C1.3.1 에러 유형별 conversion mapping

| Verifier Error | 자동 생성 `user_input` | 자동 생성 `corrected_call` |
|---|---|---|
| `R1.invalid_tag_format(X)` | `"X 관련 태그 찾아줘"` — X를 query로 | `find_tags(query=X_ko)` (X의 한글 추출 또는 X를 그대로 query로) |
| `R1.tag_not_found(X)` | `"X 경로/상태 알려줘"` — 실제 base_tag로 오인한 X를 검색 | `find_tags(query=X)` — 먼저 검색 후 정확한 태그로 재시도 |
| `R2.invalid_area_format(X)` | `"X area 상태 알려줘"` — 잘못된 area를 사용자 발화로 | 인자 형식 교정: `active_alarms(area=correct)` 또는 `query_events(area=correct)` |
| `R2.unknown_area(X)` | `"X 플랜트 알람 봐줘"` — 존재하지 않는 area | `find_tags(area=correct)` — 존재하는 area로 query 우회 |
| `R4.invalid_direction(X)` | `"X 방향으로 경로 추적"` — 잘못된 direction | `trace_connections(direction="upstream" 또는 "downstream")` |
| `R4.max_depth_out_of_range(X)` | `"깊게 추적해줘"` — 범위 초과 depth | `trace_connections(max_depth=20)` — 기본값으로 교정 |

각 Verifier 로그의 `verifier_error.code`와 일치하는 행의 template을 적용.

#### C1.3.2 `curate_from_verifier.py` 의사코드

```python
"""
curate_from_verifier.py — Verifier 거부 로그 → ShareChat 5-turn 대화 변환.

입력:  mcp-server/verifier/logs/*.jsonl
출력:  mcp-server/training/sft_data.jsonl (PLACEHOLDER 포함)
       mcp-server/training/sft_data_stats.md (통계)

변환 전략:
  각 거부 로그에 대해 에러 코드 기반 매핑 테이블(C1.3.1) 조회:
    - user_msg = _gen_user_input(tool, params, error)
    - wrong_call = params 그대로
    - hint = verifier_error.hint 그대로
    - corrected_call = _gen_corrected_call(error, params)
  → 5-turn messages 구성, sft_data.jsonl에 append.
"""

import json, hashlib, pathlib, sys
from difflib import SequenceMatcher

SYSTEM_PROMPT = (
    "당신은 P6(6차) PGMEA 플랜트 운전 지원 assistant입니다. "
    "사용자가 명시하지 않은 태그는 find_tags로 먼저 검색합니다. "
    "area는 'P6-1' 형식만 사용합니다. "
    "DB에 존재하지 않는 태그·area는 추측하지 않고 검색하거나 거절합니다."
)

CONVERSION_MAP = {
    "R1.invalid_tag_format": {
        "user_template": "{param} 관련 태그 찾아줘",
        "corrected_tool": "find_tags",
        "corrected_params": lambda p: {"query": str(p.get("start_tag", p.get("query", "")))},
    },
    "R1.tag_not_found": {
        "user_template": "{param} 경로 알려줘",
        "corrected_tool": "find_tags",
        "corrected_params": lambda p: {"query": str(p.get("start_tag", p.get("query", "")))},
    },
    "R2.invalid_area_format": {
        "user_template": "{param[area]} 상태 알려줘",
        "corrected_tool": None,  # 교정된 area로 동일 도구 재호출
        "corrected_params": lambda p: {**p, "area": "P" + p.get("area", "").lstrip("P")},
    },
    "R2.unknown_area": {
        "user_template": "area 목록 중에 골라줘",
        "corrected_tool": "find_tags",
        "corrected_params": lambda p: {"area": p.get("area", "").split("-")[0]},
    },
    "R4.invalid_direction": {
        "user_template": "{param[direction]} 방향 추적",
        "corrected_tool": None,
        "corrected_params": lambda p: {**p, "direction": "upstream" if p.get("direction") != "upstream" else "downstream"},
    },
    "R4.max_depth_out_of_range": {
        "user_template": "적당한 깊이로 추적해줘",
        "corrected_tool": None,
        "corrected_params": lambda p: {**p, "max_depth": 20},
    },
}

def _tag_as_param(params: dict) -> str:
    return str(params.get("start_tag", params.get("query", params.get("tag_name", ""))))

def _gen_user_input(tool: str, params: dict, err: dict) -> str:
    code = err.get("verifier_error", "")
    entry = CONVERSION_MAP.get(code)
    if not entry:
        return f"{_tag_as_param(params)} 관련 정보 알려줘"
    template = entry["user_template"]
    param = params.get("start_tag", params.get("query", params.get("area", params.get("tag_name", ""))))
    return template.format(param=param, param_parts=params)

def _gen_corrected_call(tool: str, params: dict, err: dict) -> dict:
    code = err.get("verifier_error", "")
    entry = CONVERSION_MAP.get(code)
    if not entry:
        return {"tool": "find_tags", "params": {"query": _tag_as_param(params)}}
    new_params = entry["corrected_params"](params)
    new_tool = entry["corrected_tool"] or tool
    return {"tool": new_tool, "params": new_params}
```

변환 후 일부 필드는 `__PLACEHOLDER__`로 남겨 운전원 검수(C1.4 step 2)에서 수동 작성.

### C1.4 작업 단계

1. **`curate_from_verifier.py` 스크립트** 작성:
   - `verifier/logs/*.jsonl` 읽어 거부 trip 추출
   - 각 trip을 위 5턴 템플릿으로 변환
   - user input·자기교정 assistant turn은 placeholder(`<<USER_INPUT>>`, `<<CORRECTED_CALL>>`)로 두고 검수 출력
2. **운전원 검수** — 구체 workflow:

   a. **검수 파일 생성**: `curate_from_verifier.py` 실행 → `training/sft_data.jsonl` 생성
      (user_input 및 corrected_call 중 일부는 `__PLACEHOLDER__` 상태)
   
   b. **운전원 편집** (30~60분, vim/vscode로 직접 JSONL 편집):
      - 각 line의 `messages[1].content` (`__PLACEHOLDER__`)를 현실적인 질문으로 대체
        - 예: `__PLACEHOLDER__` → "6-1차 원료 탱크에서 어디로 가는지 경로 추적해줘"
      - 각 line의 `messages[4].content` (`__PLACEHOLDER__`)를 정확한 도구 호출로 대체
        - 예: `__PLACEHOLDER__` → `[find_tags] query="원료 투입" sub_area="P6-1"`
      - hint(turn 3)는 자동 생성된 Verifier hint 그대로 사용 (수정 금지)
   
   c. **검수 기준**:
      - user input이 실제 운전원이 할 법한 질문인가? (현장 용어 사용)
      - 자기교정 응답이 정확한 도구·인자인가? (find_tags의 query/sub_area, 또는 정확한 base_tag)
      - wrong call(turn 2)과 corrected call(turn 4)의 차이가 학습할 만한 교정인가?
   
   d. **검증 스크립트 실행** (수정 후):
      ```bash
      cd mcp-server
      .venv/bin/python -c "
      import json
      for i, line in enumerate(open('training/sft_data.jsonl'), 1):
          d = json.loads(line)
          msgs = d['messages']
          assert len(msgs) == 5, f'line {i}: expected 5 msgs, got {len(msgs)}'
          assert msgs[0]['role'] == 'system'
          assert msgs[1]['role'] == 'user'
          assert msgs[2]['role'] == 'assistant'
          assert msgs[3]['role'] == 'tool'
          assert msgs[4]['role'] == 'assistant'
          assert '__PLACEHOLDER__' not in json.dumps(d), f'line {i}: has PLACEHOLDER'
      print('All OK')
      "
      ```
   
   e. **완료 서명**: operator가 검수 완료 시 `sft_data.jsonl`을 그대로 C2 단계로 전달.
3. **수동 보강** — abstain 25건 + scaffold 25건 (golden.jsonl의 category 분포 균형):

   - Phase 0 골든의 5 카테고리 분포: tool_call(10), nl2sql(10), ground(6), abstain(8), scaffold(6)
   - Verifier 거부는 주로 tool_call·grounding 카테고리 → **abstain(scaffold 추가 작성** 총 50건
   - **절대 golden question을 복사하지 말 것** — category 분포만 참고, question은 직접 창작
   
   **abstain template 예시** (총 25건 중 첫 5건 예시):
   ```json
   {"id":"abstain-m01","category":"abstain","question":"P3 플랜트 생산량 예측해줘",
    "context":"P3 area는 펌프 상태만 수집, 생산량 데이터 없음",
    "expect":{"refuse":true,"must_not":["톤","kg","예상 생산량은"]}}
   {"id":"abstain-m02","category":"abstain","question":"ficq-6113 고장 원인 진단해줘",
    "context":"PV=0, SP=50 값만 있음. 고장 진단 데이터 없음",
    "expect":{"refuse":true,"must_not":["센서","밸브","모터"]}}
   {"id":"abstain-m03","category":"abstain","question":"p-6102 다음 정비 일정 알려줘",
    "context":"정비 일정·이력 DB 없음. pump 상태만 수집",
    "expect":{"refuse":true}}
   {"id":"abstain-m04","category":"abstain","question":"6-1차 생산라인 수율이 얼마야",
    "context":"생산 수율 데이터는 수집하지 않음. 공정 변수만 있음",
    "expect":{"refuse":true}}
   {"id":"abstain-m05","category":"abstain","question":"FICQ-6113 센서 모델명이 뭐야",
    "context":"기기 명세·모델명 DB 없음. tag_metadata에 desc만 저장",
    "expect":{"refuse":true}}
   ```
   
   **scaffold template 예시** (총 25건 중 첫 5건 예시):
   ```json
   {"id":"scaffold-m01","category":"scaffold","question":"리플럭스 밸브 열어도 될까?",
    "context":"ficq-6113.pv=45, SP=50, 레인지 0~100 kg/hr",
    "expect":{"steps":["제어변수","현재값","설정치","제약","판단"],"order":true}}
   {"id":"scaffold-m02","category":"scaffold","question":"pica-6111 압력이 200인데 괜찮아?",
    "context":"pica-6111.pv=200, SP=760, 레인지 0~760 mmHg. 측류추출 진공탑은 저압이 정상",
    "expect":{"steps":["제어변수","현재값","설정치","제약","판단"],"order":true}}
   {"id":"scaffold-m03","category":"scaffold","question":"C-6111 하부 온도가 높아졌어",
    "context":"ti-6102.pv=175, SP=160, 레인지 0~300°C. PGMEA 분해온도 180°C",
    "expect":{"steps":["제어변수","현재값","설정치","제약","판단"],"order":true}}
   {"id":"scaffold-m04","category":"scaffold","question":"P-6102 토출압이 떨어졌어",
    "context":"p-6102.pv={R-RUN|5|}, pt-6101.pv=3.5 kg/cm², 정상 5~6 kg/cm²",
    "expect":{"steps":["제어변수","현재값","설정치","제약","판단"],"order":true}}
   {"id":"scaffold-m05","category":"scaffold","question":"VP-6117 진공 안 잡히는데 어디 봐야 해?",
    "context":"vp-6117.pv={R-RUN|5|}, pica-6111.pv=680 mmHg (대기압 760 = 진공 약함), 정상 <100 mmHg",
    "expect":{"steps":["제어변수","현재값","설정치","제약","판단"],"order":true}}
   ```
   
   **scaffold 패턴 골자** — 5단계 추론 유도:
   1. 제어변수 식별 (어떤 태그·PV인가)
   2. 현재값 확인 (PV 수치)
   3. 설정치/레인지 확인 (SP / 레인지 상하한)
   4. 제약 조건 확인 (분해온도·정상범위·운전한계)
   5. 판단 (정상·주의·위험 + 조치 방향)
   
   나머지 40건은 위 template을 변형(area/태그번호/수치 변경)하여 작성.
   
   **tool_call 보강** (10건, Verifier 에서 부족한 패턴):
   - "원료가 어떤 경로로 들어오는지", "P6-2 밸브 상태 확인" → `find_tags` 먼저 호출 유도
   - "온도 트랜드 보고서", "shift 요약" → `generate_status_report` 또는 `summarize_events`
   
   **grounding 보강** (10건, 공정 지식 부족 패턴):
   - "측류추출이 뭐야", "진공 증류 왜 해" → 공정 설명 요구
   - "P6-1과 P6-2 차이" → 영역 구분 지식
4. **누수 차단** — 3단계 중복 제거:

   a. **정확 해시 매칭**: golden.jsonl의 각 `question` 필드 md5 해시 세트를 구축.
      sft_data candidate의 `messages[1].content` md5가 golden 해시 세트에 존재 → 제거.
   
   b. **Fuzzy 매칭**: `difflib.SequenceMatcher(None, user_content, golden_q).ratio() > 0.9`
      → 의미는 같고 표현만 다른 question 차단 (예: "알람 봐줘" vs "알람 보여줘")
   
   c. **Embedding guard** (선택): `nomic-embed-text`로 golden + candidate 임베딩, cosine > 0.95 제거.
      `mcp-server/`의 Ollama 클라이언트 재사용 가능.
   
   구현 코드 (curate_from_verifier.py 내):
   ```python
   GOLDEN_PATH = pathlib.Path(__file__).parent.parent / "eval" / "golden.jsonl"
   
   def _golden_question_hashes() -> set[str]:
       hashes = set()
       for line in open(GOLDEN_PATH):
           d = json.loads(line)
           hashes.add(hashlib.md5(d["question"].encode()).hexdigest())
       return hashes
   
   def _is_leak(user_content: str, golden_hashes: set[str], golden_items: list[dict]) -> bool:
       h = hashlib.md5(user_content.encode()).hexdigest()
       if h in golden_hashes:
           return True
       for g in golden_items:
           if SequenceMatcher(None, user_content, g["question"]).ratio() > 0.9:
               return True
       return False
   ```
   
   **주의**: 수동 보강 작성 시에도 golden question을 참고만 하고 복사 금지.
   golden category 분포는 참고 가능하나 question 텍스트 직접 재사용 금지.
5. **통계 출력** (`sft_data_stats.md`):
   - 총 건수, 카테고리별 분포, 평균 토큰 수, golden 중복 0 확인

### C1.5 수용 기준 (C1)

- [ ] `curate_from_verifier.py` py_compile 통과
- [ ] `sft_data.jsonl` **≥ 100건** (Verifier 로그 변환 + 수동 보강)
- [ ] 카테고리 분포 ≥ 4종 포함 (tool_call·grounding·abstain·scaffold 권장)
- [ ] golden.jsonl 누수 0건 (해시 비교 + fuzzy guard)
- [ ] 각 라인 JSON 스키마 검증 통과:
  - `json.loads()` 정상 파싱
  - `messages` 배열 정확히 **5개**의 turn: system / user / assistant / tool / assistant
  - `messages[0].role == "system"`, `messages[1].role == "user"`, `messages[2].role == "assistant"`, `messages[3].role == "tool"`, `messages[4].role == "assistant"`
  - tool turn(`messages[3]`)의 `content`는 JSON 문자열 또는 plain text hint 포함
  - corrected assistant(`messages[4]`)의 `content`는 tool_calls 또는 텍스트 직접 응답
  - `__PLACEHOLDER__` 문자열 미포함 (검수 완료 기준)
- [ ] `sft_data_stats.md` 자동 생성 — 포함 항목:
  - 총 건수, Verifier 변환 건수 vs 수동 보강 건수
  - 카테고리별 분포 (건수 + 비율)
  - 평균/최소/최대 messages 토큰 수 (tokenizer-free: 공백 기준 split)
  - golden 누수 0 확인
  - `__PLACEHOLDER__` 잔여 0 확인

---

## C2 — SFT-LoRA 학습 (1주, C1 완료 후 트리거)

> 이 섹션은 C1 산출물(`sft_data.jsonl` ≥ 100건) 확보 시점에 본격화. 지금은 outline·결정 사항만.

### C2.1 베이스 모델 선정 (선결 필요)

| 후보 | 장점 | 단점 |
|---|---|---|
| **Qwen3-8B bf16** | 이미 production 호환 검증(Phase 0), dense → LoRA 깨끗 | 8B 한계 (L2~L4 부족) — but LoRA로 학습 후 차이 확인 |
| Qwen2.5-7B-Instruct bf16 | obedient, 사고모드 없음 | 한국어 약간↓ |
| Qwen3-14B bf16 (있다면) | 중간 capacity | 미확인 |

→ **1차: Qwen3-8B bf16**. 실패 시 7B-Instruct로 fallback.
⚠️ **bf16 베이스 필수** (FP8/FP8-dynamic은 LoRA 학습 부적합 — OPUS-v1 §3.1 함정).

### C2.2 환경

- Unsloth 컨테이너 (별도 setup. OPUS-v1 §2 참고하되 35B는 보존 — 학습 컨테이너는 별도 GPU 시간 점유)
- 또는 호스트에서 직접 `unsloth` pip 설치 (간단)

### C2.3 학습 하이퍼파라미터 (1차 안)

```python
from unsloth import FastLanguageModel
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="Qwen/Qwen3-8B",  # bf16
    max_seq_length=4096,
    dtype=torch.bfloat16,
    load_in_4bit=True,  # QLoRA
)
model = FastLanguageModel.get_peft_model(
    model,
    r=64, lora_alpha=128,
    target_modules=["q_proj","k_proj","v_proj","o_proj"],  # attention-only 1차
    lora_dropout=0, bias="none",
    use_gradient_checkpointing="unsloth",
    use_rslora=True,
)
# TrainingArguments: per_device_train_batch_size=2, grad_accum=4,
#   max_steps=200, learning_rate=2e-4, warmup=5, save_steps=50
```

### C2.4 산출

- `lora-adapters/calibration-v1/` — 어댑터 가중치
- `lora-adapters/calibration-v1/training_log.json` — loss 곡선
- `lora-adapters/calibration-v1/eval_report.md` — Phase 0 골든 + Verifier reject rate (전/후)

### C2.5 수용 기준 (C2)

- [ ] 학습 손실 안정 수렴 (eval loss 단조 감소 or 안정)
- [ ] Phase 0 골든 평가에서 **회귀 0건**
- [ ] 사후 production 시뮬레이션(opencode에서 invention 재현 질문 5건): **Verifier reject 비율 ≥ 50% 감소** (모델이 자기교정 행동을 학습)
- [ ] 어댑터 hot-swap or merge 검증 (v1 §4.1 절차)

**회귀 트리거** — Phase 0 eval에서 **회귀 ≥ 2건** 또는 **invention rate 개선 < 30%** 이면:
  → `r ∈ {32, 128}` sweep + `target_modules`에 `gate_proj/up_proj/down_proj` 추가(전결합 LoRA) 1회 시도
  → r=64 / attention-only가 목표 데이터에 적합하지 않을 가능성에 대한 안전판

---

## C3 — DPO 정렬 (1~2주, 진입 조건부)

> 진입 조건: C2 어댑터로 production 운영 ≥ 2주 + 운전원 👍/👎 데이터 ≥ 200쌍.

### C3.1 사전 작업 (별도 트랙)

- **opencode 또는 Web UI에 👍/👎 버튼 추가** (응답마다 binary 피드백) — 별도 작업지시서 필요
- 피드백을 `feedback/preferences.jsonl` 에 적재 (응답 텍스트 + tool calls + binary label)

### C3.2 DPO 데이터 구성

- chosen = 👍 응답
- rejected = 👎 응답 (또는 Verifier reject된 응답)
- 자동 보상 보조: Verifier 통과/실패를 binary 보상으로 가중

### C3.3 학습

- `trl.DPOTrainer` (베이스: C2 SFT 어댑터를 ref_model로)
- beta=0.1, learning_rate=5e-6, 1 epoch

### C3.4 산출 / 수용 기준

- `lora-adapters/calibration-v2-dpo/`
- **fabrication_rate** (Phase 0 eval) C2 대비 추가 개선
- 운전원 만족도 (👍 비율) 추적 — C2 어댑터 대비 향상

---

## 산출물 종합

| Phase | 신규 파일 | 변경 |
|---|---|---|
| C1 | `mcp-server/training/curate_from_verifier.py`<br>`mcp-server/training/sft_data.jsonl`<br>`mcp-server/training/sft_data_stats.md` | — |
| C2 | `lora-adapters/calibration-v1/*` | — |
| C3 | `lora-adapters/calibration-v2-dpo/*`<br>`feedback/preferences.jsonl` (수집 시작) | UI에 👍/👎 추가 |

---

## 하지 말 것 (금지)

- ❌ **Phase 0 골든셋 학습에 사용** (평가셋 누수 → 측정 무력화)
- ❌ FP8/FP8-dynamic 베이스로 LoRA 학습 시도 (수치 손상 — 우리가 본 RedHatAI 7B 사례)
- ❌ MoE expert 모듈에 LoRA 적용 (v1 §3.2 — vLLM hot-swap 깨짐). attention-only.
- ❌ 자동/야간 자기학습 루프 (검수 없는 학습 = model collapse — v1 §5.2.B 폐기)
- ❌ Verifier 룰 우회를 위해 데이터에 가짜 거부/hint 작성 (학습 신호 오염)
- ❌ `mcp-server/eval/`·`verifier/validators.py` 수정 (Phase C 범위 밖)

---

## 즉시 착수 (C1)

```bash
mkdir -p mcp-server/training
# 작업자가 만들 것: curate_from_verifier.py
# 입력: mcp-server/verifier/logs/*.jsonl
# 출력: mcp-server/training/sft_data.jsonl + sft_data_stats.md

# 검증:
cd mcp-server
python3 -m py_compile training/curate_from_verifier.py
.venv/bin/python -c "
import json
items = [json.loads(l) for l in open('training/sft_data.jsonl')]
print(f'total: {len(items)}')
print(f'avg msgs: {sum(len(i[\"messages\"]) for i in items)/len(items):.1f}')
"
# 골든 누수 검사:
.venv/bin/python -c "
import json, hashlib
g = {hashlib.md5(json.loads(l)['question'].encode()).hexdigest() for l in open('eval/golden.jsonl')}
s = [json.loads(l) for l in open('training/sft_data.jsonl')]
leaks = [i for i in s if any(hashlib.md5(m['content'].encode()).hexdigest() in g for m in i['messages'] if m['role']=='user')]
print(f'leaks: {len(leaks)}')
"
```

완료 보고: `total / 카테고리 분포 / golden 누수 (0 기대) / 거부 변환 vs 수동 보강 비율`. 보고 받은 뒤 C2 트리거.
