# 작업지시서 — Phase B Verifier MVP (R1·R2·R4 tool-arg validators)

> 대상: 코딩 LLM. 이 문서만 보고 독립 수행 가능.
> 상위 플랜: `plans/OPUS-v2-실행계획.md` Phase B.
> 예상 분량: 새 모듈 1개(~150줄) + 기존 server.py 진입부에 데코레이터 적용. 로직 변경 없음.

## 1. 배경 (왜)

production opencode 채팅에서 Qwen3-8B(및 가끔 35B)가 **존재하지 않는 태그·잘못된 area 형식으로 MCP 도구를 호출**하고, 서버가 silent fallback해 모델이 그 결과를 사용자에게 잘못 포장하는 패턴 관찰:
- `trace_connections(start_tag="raw_material_input")` ← 합성 (영어로 만든 가짜 태그)
- `trace_connections(start_tag="RM-6101")` ← Raw Material + 실재 p-6101 차용 합성
- `generate_status_report(area="6-1")` ← `P6-1` 형식 무시 → 서버가 무필터 전체 결과 반환 → "6-1차 결과"로 둔갑

**Phase 0 eval은 통제 조건이라 이 패턴을 못 잡음.** Verifier = 코드 결정적 검증을 MCP 도구 입구에 박아, 잘못된 호출 즉시 reject + 자기교정 hint 반환 → 모델이 `find_tags` 먼저 호출하는 행동으로 전환.

부수효과: 거부 로그가 **Phase C(LoRA) 학습 데이터 자동 수집** 역할 (잘못된 호출 → hint → 올바른 호출 trip).

## 2. 수정 대상

| 파일 | 동작 |
|---|---|
| `mcp-server/verifier/__init__.py` | 신규 (빈 패키지 마커) |
| `mcp-server/verifier/validators.py` | 신규 — R1·R2 validator 함수 + VerifierError + 로그 적재 |
| `mcp-server/verifier/test_validators.py` | 신규 — 단위 테스트 (각 룰별 PASS/FAIL 2건씩 이상) |
| `mcp-server/verifier/logs/.gitkeep` | 신규 (빈 디렉토리 유지) |
| `mcp-server/verifier/README.md` | 신규 — 룰 카탈로그·로그 포맷 |
| `mcp-server/server.py` | **수정** — import + 적용 대상 tool 함수 입구에 검증 호출 (return 분기) |

⚠️ **수정 금지**: `mcp-server/worker/*`, `mcp-server/eval/*`, 채점기·골든셋·모델 서빙 스크립트.

## 3. 룰 명세 (정확히 이대로)

### R1 — `tag-existence` (모든 tool 인자의 base_tag)
- 입력 패턴: 정규식 `^[a-z][a-z0-9]*-\d+[a-z]?(\.[a-z0-9]+)?$` (예: `ficq-6113`, `ficq-6113.pv`, `p-6102`, `vp-6117a`)
- 검증: `.` 앞부분(base_tag)이 `tag_metadata.base_tag` ∪ `pid_equipment.tag_no` (소문자 비교)에 존재해야 PASS
- 캐시: 모듈 전역 `set`, TTL **300초**(5분). 캐시 미스 시 두 테이블 한번에 로드
- 실패 시 hint에 **부분 매칭 top 3 suggested** 첨부 (split('-') 토큰 길이 2 초과만)

### R2 — `area-format` (`area`, `sub_area` 인자)
- 정규식: `^P\d+(-\d+)?$` (예: `P6`, `P6-1`). 빈 문자열/`None`은 PASS (선택 필터)
- 추가: `-` 앞 부분이 유효 area 코드여야 함:
  `{"P1","P2","P3","P4","P5","P6","P8","P9","P10","UTIL","PACKING"}` (P7 없음)
- 실패 hint: 형식 또는 미존재 모두 valid 코드 목록 포함

### R4 — `trace_connections` 보강
- `start_tag`: R1 적용 (위와 동일)
- `direction`: `{"upstream", "downstream"}` 외 reject
- `max_depth`: int, 1~50 외 reject

> ⚠️ **R3·R5는 이번 MVP 범위 밖** (응답 텍스트 후처리/LLM-judge 필요). `validators.py`에 stub 함수만 두고 `NotImplementedError` 또는 NO-OP로 표시. Phase B.2에서 구현.

## 4. 구현 골자 (정확히 따를 것)

### 4.1 `mcp-server/verifier/validators.py`

```python
"""Phase B Verifier MVP — tool 인자 결정적 검증.

룰: R1(tag-existence), R2(area-format), R4(trace_connections 보강).
응답 텍스트 검증(R3, R5)은 Phase B.2 — stub만.
"""
from __future__ import annotations
import json, re, time, pathlib
from typing import Optional, Any

TAG_RE  = re.compile(r'^[a-z][a-z0-9]*-\d+[a-z]?(\.[a-z0-9]+)?$')
AREA_RE = re.compile(r'^P\d+(-\d+)?$')
VALID_AREAS = {"P1","P2","P3","P4","P5","P6","P8","P9","P10","UTIL","PACKING"}
VALID_DIRECTIONS = {"upstream","downstream"}

_LOG_DIR = pathlib.Path(__file__).parent / "logs"

class VerifierError(Exception):
    def __init__(self, rule: str, code: str, hint: str, **extra):
        self.rule, self.code, self.hint, self.extra = rule, code, hint, extra
    def to_dict(self) -> dict:
        return {"verifier_error": f"{self.rule}.{self.code}", "hint": self.hint, **self.extra}

# ── 태그 캐시 ──
_tag_cache: set[str] | None = None
_tag_cache_at: float = 0.0

def _load_tag_set(get_conn) -> set[str]:
    global _tag_cache, _tag_cache_at
    if _tag_cache is not None and (time.time() - _tag_cache_at) < 300:
        return _tag_cache
    conn = get_conn()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT DISTINCT base_tag FROM tag_metadata WHERE base_tag IS NOT NULL")
            s = {r[0].lower() for r in cur.fetchall() if r[0]}
            cur.execute("SELECT DISTINCT tag_no FROM pid_equipment WHERE tag_no IS NOT NULL")
            s |= {r[0].lower() for r in cur.fetchall() if r[0]}
    finally:
        conn.close()
    _tag_cache, _tag_cache_at = s, time.time()
    return s

# ── R1 ──
def validate_tag(tag: str | None, get_conn) -> Optional[VerifierError]:
    if not tag:
        return None
    t = tag.lower()
    if not TAG_RE.match(t):
        return VerifierError("R1","invalid_tag_format",
            hint=f"태그 형식 비정상: '{tag}'. 예시: ficq-6113.pv, p-6102")
    base = t.split('.')[0]
    tags = _load_tag_set(get_conn)
    if base in tags:
        return None
    toks = [p for p in base.split('-') if len(p) > 2]
    suggested = sorted({x for x in tags if any(p in x for p in toks)})[:3]
    return VerifierError("R1","tag_not_found",
        hint=f"태그 '{tag}' 는 DB에 존재하지 않습니다. find_tags(query=..., sub_area=...) 로 먼저 검색하세요.",
        suggested=suggested)

# ── R2 ──
def validate_area(area: str | None, field: str = "area") -> Optional[VerifierError]:
    if not area:
        return None
    if not AREA_RE.match(area):
        return VerifierError("R2","invalid_area_format",
            hint=f"{field}='{area}' 형식 오류. 'P6' 또는 'P6-1' 형식 사용.",
            valid_areas=sorted(VALID_AREAS))
    base = area.split('-')[0]
    if base not in VALID_AREAS:
        return VerifierError("R2","unknown_area",
            hint=f"{field}='{area}' 미존재. valid: {sorted(VALID_AREAS)} (P7 없음)")
    return None

# ── R4 ──
def validate_direction(d: str | None) -> Optional[VerifierError]:
    if d and d not in VALID_DIRECTIONS:
        return VerifierError("R4","invalid_direction",
            hint=f"direction='{d}' 잘못. 'upstream' 또는 'downstream' 만 허용")
    return None

def validate_max_depth(n: Any) -> Optional[VerifierError]:
    if n is None: return None
    try:
        v = int(n)
    except Exception:
        return VerifierError("R4","invalid_max_depth", hint=f"max_depth='{n}' 은 정수여야 함")
    if not (1 <= v <= 50):
        return VerifierError("R4","max_depth_out_of_range", hint=f"max_depth={v} 범위 외 (1~50)")
    return None

# ── R3, R5 stub (Phase B.2) ──
def validate_response_text(text: str) -> Optional[VerifierError]:
    return None  # Phase B.2 구현 예정

# ── 로그 적재 (Phase C LoRA 입력) ──
def log_rejection(tool: str, params: dict, err: VerifierError) -> None:
    _LOG_DIR.mkdir(parents=True, exist_ok=True)
    today = time.strftime("%Y-%m-%d")
    rec = {"ts": time.time(), "tool": tool, "params": params,
           "verifier_error": err.to_dict()}
    with (_LOG_DIR / f"{today}.jsonl").open("a", encoding="utf-8") as f:
        f.write(json.dumps(rec, ensure_ascii=False) + "\n")
```

### 4.2 server.py 적용 — 각 tool 입구에 한 줄

DB 커넥션은 server.py에 이미 있는 헬퍼(예: `_get_db_connection`)를 `get_conn` 자리에 전달.

```python
from verifier.validators import (
    validate_tag, validate_area, validate_direction, validate_max_depth,
    log_rejection, VerifierError,
)

def _check(tool: str, params: dict, *errs) -> dict | None:
    """첫 번째 비-None 에러를 로그 + 반환(dict). 없으면 None."""
    for e in errs:
        if e:
            log_rejection(tool, params, e)
            return e.to_dict()
    return None
```

**적용 대상 (server.py의 기존 함수 시그너처는 그대로 두고, 본문 첫 줄에 `_check` 호출)**:

| Tool | 검증 |
|---|---|
| `find_tags` | `validate_area(area)`, `validate_area(sub_area, "sub_area")` |
| `active_alarms` | `validate_area(area)` |
| `query_events` | `validate_area(area)` + `tag_name`이 인자면 `validate_tag` |
| `summarize_events` | `validate_area(area)` |
| `generate_status_report` | `validate_area(area)` |
| `query_pv_history` | `tag_names` list 각각 `validate_tag` (첫 실패 반환) |
| `trace_connections` | `validate_tag(start_tag, conn)`, `validate_direction(direction)`, `validate_max_depth(max_depth)` |
| `upsert_pid_connection` | `validate_tag(tag_no)` |

> tool이 dict를 반환하는 경우(이미 그렇게 되어있음) 그대로 `return _check(...) or 기존_본문()` 패턴. tool이 str을 반환하면 dict를 json.dumps로 감싸서 일관 유지.

### 4.3 `test_validators.py` (필수, 최소 10케이스)

각 룰별 PASS 2 + FAIL 2 이상. 예:
```python
def test_R1_valid_tag(monkeypatch):
    monkeypatch.setattr(validators, "_load_tag_set", lambda gc: {"ficq-6113","p-6102"})
    assert validators.validate_tag("ficq-6113.pv", lambda: None) is None
def test_R1_unknown_tag(monkeypatch):
    monkeypatch.setattr(validators, "_load_tag_set", lambda gc: {"ficq-6113"})
    err = validators.validate_tag("rm-6101", lambda: None)
    assert err and err.code == "tag_not_found"
def test_R2_invalid_area():
    err = validators.validate_area("6-1")
    assert err and err.code == "invalid_area_format"
# ... R2 PASS, R4 direction/max_depth, etc.
```

### 4.4 README.md (간단)

룰 카탈로그 표 + 로그 jsonl 포맷 예시 1줄 + Phase B.2(R3/R5) 미구현 명시.

## 5. 검증 (순서대로)

```bash
cd mcp-server

# 1) 컴파일
python3 -m py_compile verifier/validators.py server.py

# 2) 단위 테스트
.venv/bin/python -m pytest verifier/test_validators.py -v

# 3) MCP 서버 재시작
# (사용자 환경: experioncrawler systemd 또는 uv run server.py)

# 4) opencode 재현 시도 — 다음 3개 호출 시 verifier_error 반환되는지
#    a) trace_connections(start_tag="raw_material_input", direction="downstream") → R1 tag_not_found
#    b) trace_connections(start_tag="RM-6101", ...)                                → R1 tag_not_found + suggested 포함
#    c) generate_status_report(area="6-1", hours=24)                                → R2 invalid_area_format
#    각 응답에 verifier_error.{rule, code, hint} 키 확인

# 5) 로그 적재
ls -la verifier/logs/
cat verifier/logs/$(date +%Y-%m-%d).jsonl | head -3  # 거부 3건 보여야 함

# 6) 정상 호출 회귀 0건 확인
#    a) trace_connections(start_tag="p-6102", direction="upstream") → 정상 결과
#    b) generate_status_report(area="P6-1", hours=24)                → 정상 결과
#    c) Phase 0 eval 재실행 (옵션) — 회귀 0
cd eval && ../.venv/bin/python run_eval.py --model Qwen3-8B --no-think \
  --baseline results/Qwen3-8B_20260526_103459.json
```

## 6. 수용 기준

- [ ] `py_compile` 통과 (validators.py, server.py)
- [ ] 단위 테스트 ≥ 10케이스 전부 PASS
- [ ] opencode 재현 §5.4 a/b/c **3건 모두 verifier_error 반환** (hint·suggested 포함)
- [ ] 정상 호출 §5.6 a/b 정상 결과 (회귀 없음)
- [ ] Phase 0 eval `--baseline` 비교에서 PASS→FAIL 회귀 0건
- [ ] `verifier/logs/YYYY-MM-DD.jsonl` 에 거부 3건 자동 적재 확인

## 7. 하지 말 것 (금지)

- ❌ 응답 텍스트(LLM 출력) 후처리 — Phase B.2 범위 (R3/R5는 stub만)
- ❌ LLM-judge 사용 (MVP는 코드 결정적만)
- ❌ tool 함수 시그너처·반환 타입 변경 (입구에 한 줄 추가만)
- ❌ 골든셋·평가 러너·채점기 변경
- ❌ 모델 서빙 설정·chat 템플릿 변경
- ❌ 캐시 TTL·매직 넘버 튜닝 (300초·top3·1~50 고정)
- ❌ 새 DB 객체(테이블/뷰) 생성 (read-only 검증)
- ❌ `worker/` 디렉토리 변경

## 8. 산출물

- 신규 6개: `verifier/{__init__.py, validators.py, test_validators.py, README.md, logs/.gitkeep}`
- 수정 1개: `mcp-server/server.py` (import + tool 8개 입구 `_check` 호출)
- 한 줄 보고: 적용된 tool 개수 / 재현 §5.4 결과 (3/3) / 회귀 (0) / 로그 샘플 1행
