<!-- ─────────────────────────────────────────────
  모델 API 서버(models/app.py · FastAPI) — 외부·내부 서비스가 HTTP 로 모델을 부르는 방법.
  왜 필요한가: 웹서비스 말고 다른 서비스도 같은 모델을 쓰게 하려면 파이썬 import 가 아니라 HTTP 계약이 필요하다.
  주로 보는 사람: 연계 개발자(외부 서비스) · 운영 담당 · Claude(검수)
  대화형 문서: https://p4.sumzip.com/model-api/docs  (Swagger, 브라우저에서 바로 호출해 볼 수 있다)
  공개 규약: models/modelapi/팀-모델API-외부공개-학생절차.md — N팀 = pN.sumzip.com · 포트 954N · 경로 /model-api
  ───────────────────────────────────────────── -->

# 모델 API 연계 가이드 — 설치 · 엔드포인트 · 외부 연계

**모델 API** 는 LoL 10분 시점 경기 상태(블루−레드 차이 피처 13개)를 JSON 으로 받아
블루팀 승리 확률·승리요인·이상탐지·코칭을 돌려주는 독립 HTTP 서버다.
웹서비스(`web/`)와 별개 프로세스·별개 가상환경으로 돌고, 어느 언어에서든 HTTP 로 부를 수 있다.

| 항목 | 값 |
|---|---|
| 코드 | `models/app.py` (FastAPI) · 계약 `models/schemas.py` · 팀 엔드포인트 `models/routes_team.py` |
| 포트 | **9544** = 4팀 규약(954N). `127.0.0.1` 에만 바인드해 프록시 뒤에 둔다 — `models/.env` 의 `PORT` · `HOST` |
| 공개 주소 | **`https://p4.sumzip.com/model-api`** — Swagger `/model-api/docs` · ReDoc `/model-api/redoc` · OpenAPI `/model-api/openapi.json` |
| 중계 | 프런트(9504)가 `/model-api/*` 의 접두를 떼고 `127.0.0.1:9544` 로 넘기고, uvicorn 은 `--root-path /model-api` 로 접두를 안다 |
| 운영 스크립트 | `./check_api.sh {setup|start|stop|restart|status|logs|health|test}` |
| 확률의 정본 | `lolwin.predict` — 웹 화면의 예측·코칭·복기도 **이 API 를 거쳐** 같은 함수를 부르므로 두 확률은 같다 (2026-09-22 전환) |
| 화면 안내 | **https://p4.sumzip.com/#api** — 서비스의 «API 연계» 탭. 이 문서의 요약·엔드포인트·입력·응답·언어별 예시를 보여주고 바로 호출해 볼 수 있다 |
| 내려받기 | `/model-api-kit/lol-model-api-kit.zip` — 이 문서 + `openapi.json`(그때그때 서버에서) + 예시 입력 2 + 파이썬 클라이언트 + README. 낱개는 `/model-api-kit/<이름>` (`web/frontend.py` 의 `KIT_FILES`) |

---

## 1. 설치와 기동 (팀 서버 · 다른 기계 공통)

```bash
git clone https://github.com/wpalswpa/lol-win-prediction.git && cd lol-win-prediction
./check_api.sh setup     # models/.venv 생성 + models/requirements.txt 설치 + models/.env 복사 (처음 한 번)
./check_api.sh start     # 9544 기동 → health 200 이면 "시작됨"
./check_api.sh test      # 단위 테스트 10건 + HTTP 스모크 10건
./check_api.sh status    # 실행 중 · pid · health · 모델 버전
```

`start` 가 실행하는 명령은 학생절차 2-1 그대로다:

```bash
models/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 9544 --root-path /model-api
```

`--root-path` 는 uvicorn 에서만 준다. 코드의 `FastAPI(root_path=...)` 와 둘 다 주면 `/model-api/model-api` 가 된다.

필요한 것: Python 3.11 이상 (모델을 만든 scikit-learn 1.9.0 이 3.11 에서 검증됨).
모델 파일(`models/model/artifacts/model.joblib` · 이상탐지 `models/model/anomaly_detect/model.joblib`)은 저장소에 들어 있어 따로 받을 것이 없다.
운영 웹서비스의 `venv311` 에는 FastAPI 를 넣지 않는다 — 두 서비스의 의존성을 섞지 않기 위해서다.

설정은 `models/.env` 로 바꾼다 (코드는 안 고친다).

| 키 | 기본값 | 뜻 |
|---|---|---|
| `PORT` · `HOST` · `ROOT_PATH` | `9544` · `127.0.0.1` · `/model-api` | 포트를 밖에 직접 열지 않는다(학생절차 5장). 바깥 호출은 전부 프런트의 `/model-api` 를 거친다 |
| `CLOSE_MARGIN` | `0.10` | 확률이 0.5±이 값 안이면 «판단보류» |
| `INCLUDE_ANOMALY` | `true` | 응답에 이상탐지 결과를 넣을지 |
| `CORS_ORIGINS` | `*` | 브라우저에서 직접 부르는 외부 서비스의 오리진. 쉼표로 여러 개. 비우면 CORS 헤더 없음 |
| `API_KEYS` | (비움) | 쉼표 구분 허용 키. 값이 있으면 POST 요청에 `X-API-Key` 헤더가 없을 때 401 |

서버 재부팅 뒤 자동으로 살아나지 않는다 — `./check_api.sh start`. 로그는 `logs/api.log`, pid 는 `run/api.pid`.

## 2. 엔드포인트

모든 요청·응답은 JSON 이다. 응답마다 `X-Request-ID` 헤더가 붙어 로그(`logs/api.log`)의 `req_id` 와 맞춰 볼 수 있다.

| 메서드 · 경로 | 무엇을 하나 | 입력 | 응답(200) |
|---|---|---|---|
| `GET /health` | 판단할 준비가 됐는지 | — | `status`(ok/loading) · `model_name` · `model_version` · `load_seconds` |
| `GET /schema` | 입력 피처 13개의 뜻·형·학습 범위 | — | `artifacts/schema.json` 원문 — 입력 폼을 만들 때 |
| `GET /examples` | 예시 입력 3건 | — | `[{id: close/blue/red, label, payload}]` |
| `POST /predict` | 한 경기를 판단 | 피처 13개 객체 | 아래 «응답 필드» |
| `POST /predict/batch` | 여러 경기를 한 번에 (1~32건) | `{"items": [피처 13개, ...]}` | `{"results": [...], "count": n}` — 입력 순서 유지 |
| `POST /coach` | 이 상태에서 무엇을 했다면 승률이 얼마나 올랐나 | 피처 13개 (+ 선택 `verdict`) | `win_prob` · `actions`(상승폭 큰 순 3개) · `how_to_read` · `verdict_advice` |
| `GET /metrics` | 운영 지표 다섯 | — | `requests_total` · `error_rate` · `latency_p95_ms` · `abstain_rate` · `label_distribution` |
| `GET /docs` · `GET /redoc` · `GET /openapi.json` | Swagger UI · ReDoc · OpenAPI 3 명세 | — | HTML · JSON |

### 입력 — 피처 13개 (전부 블루 − 레드 차이, 양수 = 블루 우세)

| 피처 | 뜻 | 형 · 받는 범위 |
|---|---|---|
| `FirstBlood` | 첫 킬을 블루가 가졌나 | 0 또는 1 |
| `KillsDiff` | 킬 차이 | 정수 −50~50 |
| `GoldDiff` | 골드 차이 | 정수 −30000~30000 |
| `ExpDiff` | 경험치 차이 | 정수 −30000~30000 |
| `WardsPlacedDiff` | 와드 설치 차이 | 정수 −500~500 |
| `WardsDestroyedDiff` | 와드 제거 차이 | 정수 −100~100 |
| `AssistsDiff` | 어시스트 차이 | 정수 −100~100 |
| `DragonsDiff` | 드래곤 차이 | 정수 −1~1 |
| `HeraldsDiff` | 전령 차이 | 정수 −1~1 |
| `TowersDestroyedDiff` | 타워 파괴 차이 | 정수 −11~11 |
| `AvgLevelDiff` | 평균 레벨 차이 | 실수 −10~10 |
| `TotalMinionsKilledDiff` | 미니언(CS) 차이 | 정수 −500~500 |
| `TotalJungleMinionsKilledDiff` | 정글 몬스터 차이 | 정수 −300~300 |

13개가 전부 있어야 하고, 없는 키를 넣으면 거부한다(`extra=forbid`).
«받는 범위» 밖이면 422 로 거부하고, 범위 안이지만 **학습 범위**(`GET /schema` 의 `train_min`~`train_max`) 밖이면 200 으로 답하되 `warnings` 에 적는다 — 이례적인 경기를 아예 못 보게 하지 않기 위해서다.

### 응답 필드 — `POST /predict`

```json
{
  "label": "블루 승리 예측",
  "win_prob_blue": 0.9461,
  "pred": 1,
  "top_factors": [
    {"feature": "GoldDiff", "name": "골드(돈) 차이", "value": 4500.0, "contribution": 1.9541, "direction": "블루에 유리"}
  ],
  "anomaly": {"is_anomaly": false, "score": 0.0412, "percentile": 71.3, "label": "정상 범위", "unusual_features": []},
  "warnings": [],
  "model_version": "lolwin-1.0"
}
```

| 필드 | 보장 |
|---|---|
| `label` | `블루 승리 예측` · `레드 승리 예측` · `판단보류`(확률이 0.5±`CLOSE_MARGIN` 안인 접전). 보류여도 `win_prob_blue`·`pred` 는 그대로 온다 |
| `win_prob_blue` | 0~1, 소수 4자리 |
| `pred` | `win_prob_blue >= 0.5` 이면 1, 아니면 0 |
| `top_factors` | **5개**, 기여도 절대값 내림차순. `contribution` 은 다른 지표를 통제한 뒤 남은 몫이라 킬이 음수로 나올 수 있다 — «킬을 하면 진다»가 아니다 ([serving.md](serving.md) 3장) |
| `anomaly` | 학습 데이터에서 보기 드문 경기 상태인지. `percentile` 98 이상이면 `is_anomaly=true`. `INCLUDE_ANOMALY=false` 면 `null` |
| `warnings` | 문자열 목록. 비어 있으면 정상 |

### 오류 규약

| HTTP | 언제 | 본문 |
|---|---|---|
| 422 | 피처 누락 · 모르는 키 · 형이 다름 · 받는 범위 밖 · 배치 33건 이상 | `{"detail": [{"loc": ["body", "GoldDiff"], "msg": "Field required", "type": "missing"}, ...]}` |
| 401 | `API_KEYS` 를 켰는데 `X-API-Key` 가 없거나 틀림 | `{"detail": "..."}` |
| 503 | 모델 적재 중 (기동 직후 1~2초) | `{"detail": "모델을 준비 중입니다..."}` — 잠시 후 재시도 |
| 500 | 서버 내부 오류 | `{"detail": "서버에서 판단에 실패했습니다"}` — `X-Request-ID` 로 로그 추적 |

## 3. 외부 서비스에서 부르기

가장 빠른 길은 화면의 **API 연계 탭**(https://p4.sumzip.com/#api)에서 **연계 키트(zip)** 를 받는 것이다 — 이 문서, OpenAPI 명세, 예시 입력, 표준 라이브러리만 쓰는 파이썬 클라이언트(`lol_model_api_client.py`)가 들어 있어 `python lol_model_api_client.py request.json` 으로 바로 확인된다.

주소는 하나다 — **`https://p4.sumzip.com/model-api`**. 포트 9544 는 127.0.0.1 에만 열려 있어 밖에서 직접 닿지 않는다.
팀 서버 안에서 확인할 때만 `http://127.0.0.1:9544` 를 쓴다(이때 Swagger 는 `--root-path` 때문에 명세를 못 찾으므로 curl 로만).

### curl

```bash
BASE=https://p4.sumzip.com/model-api
curl -s $BASE/health
curl -s -X POST $BASE/predict -H 'Content-Type: application/json' \
  -d '{"FirstBlood":1,"KillsDiff":5,"GoldDiff":4500,"ExpDiff":3000,"WardsPlacedDiff":5,"WardsDestroyedDiff":2,"AssistsDiff":6,"DragonsDiff":1,"HeraldsDiff":1,"TowersDestroyedDiff":1,"AvgLevelDiff":1.2,"TotalMinionsKilledDiff":30,"TotalJungleMinionsKilledDiff":10}'
curl -s -X POST $BASE/predict/batch -H 'Content-Type: application/json' -d @models/examples/request_batch.json
```

### Python (표준 라이브러리만)

```python
import json, urllib.request

BASE = "https://p4.sumzip.com/model-api"

def predict(state: dict, api_key: str | None = None) -> dict:
    headers = {"Content-Type": "application/json"}
    if api_key:
        headers["X-API-Key"] = api_key
    req = urllib.request.Request(f"{BASE}/predict", data=json.dumps(state).encode(), headers=headers)
    with urllib.request.urlopen(req, timeout=10) as r:      # 422/401 은 HTTPError 로 온다
        return json.loads(r.read())

r = predict({"FirstBlood": 1, "KillsDiff": 5, "GoldDiff": 4500, "ExpDiff": 3000,
             "WardsPlacedDiff": 5, "WardsDestroyedDiff": 2, "AssistsDiff": 6,
             "DragonsDiff": 1, "HeraldsDiff": 1, "TowersDestroyedDiff": 1,
             "AvgLevelDiff": 1.2, "TotalMinionsKilledDiff": 30, "TotalJungleMinionsKilledDiff": 10})
print(r["label"], r["win_prob_blue"], r["top_factors"][0]["name"])
```

### JavaScript (브라우저 · Node)

```js
const BASE = "https://p4.sumzip.com/model-api";   // 팀 화면 안에서는 "/model-api" 처럼 경로만 쓴다(같은 도메인 → CORS 불필요). 다른 도메인에서 부르면 CORS_ORIGINS 에 그 오리진이 있어야 한다
const state = await (await fetch(`${BASE}/examples`)).json();
const r = await fetch(`${BASE}/predict`, {
  method: "POST", headers: {"Content-Type": "application/json"},
  body: JSON.stringify(state[1].payload),
});
if (r.status === 422) console.error(await r.json());   // 입력 문제 — detail 에 어느 필드인지 있다
else { const d = await r.json(); console.log(d.label, d.win_prob_blue); }
```

### 연계 규칙 (외부 개발자가 지킬 것)

1. `GET /health` 가 `ok` 일 때만 판단 요청을 보낸다. `loading` 이면 잠시 후 재시도.
2. 화면에 `win_prob_blue` 를 띄울 때 `label` 이 `판단보류` 면 «접전»이라고 함께 알린다. 확률만 보여주면 63% 가 확신처럼 읽힌다.
3. `warnings` 가 비어 있지 않으면 사용자에게 그대로 보여준다 — 믿을지 말지는 쓰는 쪽이 정한다.
4. `top_factors[].contribution` 을 «이 지표를 올리면 이긴다»로 옮기지 않는다. 처방이 필요하면 `POST /coach` 를 쓴다.
5. 배당·베팅 산출에 쓰지 않는다 ([../model_card.md](../model_card.md) 금지 항목).
6. 여러 건은 `/predict/batch` 32건씩. 시험셋 1,976판 기준 건당 약 10 ms 다.

## 4. 웹서비스와의 관계 · 운영

(전체 구성도 · 요청 흐름 · 단계별 설명은 [architecture_model_api_web.md](architecture_model_api_web.md))

```
브라우저·외부 서비스 ─ https://p4.sumzip.com ─▶ web/frontend.py (9504) ─┬─ /api/*       ─▶ web/app.py (9524) ──HTTP──▶ models/app.py (127.0.0.1:9544)
                                                                        └─ /model-api/* ─▶ 접두 제거 ─────────────────────▶ 〃  (--root-path /model-api)
```

- 프런트가 `/model-api` 접두를 떼고 넘기고 uvicorn 이 `--root-path` 로 접두를 알므로, FastAPI 코드의 경로(`/predict` `/health`)는 그대로이고 Swagger 는 `/model-api/openapi.json` 을 찾는다. `/model-api/docs` 가 뜨는데 «Failed to load API definition» 이면 `--root-path` 가 빠진 것이다.
- **웹 백엔드도 이 API 를 부른다** (2026-09-22 전환). `web/app.py` 는 `lolwin.predict` 를 직접 부르지 않고 `/predict` `/predict/batch` `/coach` 에 HTTP 로 넘긴다 — 승패 예측·코칭·시험셋 복기·소환사 복기 전부. 주소는 환경변수 `MODEL_API_URL`(기본 `http://127.0.0.1:9544`). 그래서 **모델 API 가 꺼져 있으면 웹의 예측·코칭은 503** 이고, `./check_project.sh start` 가 모델 API 를 먼저 띄운다 (`stop` 은 건드리지 않는다 — 끄려면 `./check_api.sh stop`).
- 응답 모양: 백엔드는 이 API 의 `PredictResponse` 를 웹 계약([serving.md](serving.md) 3장: `pred_label`·`meta` 포함)으로 바꿔 돌려주고, `label`(판단보류)·`anomaly`·`model_version` 은 그대로 얹는다. 422 는 400 으로, 503·연결 실패는 503 으로 옮긴다.
- 두 경로의 확률이 같은지는 두 겹으로 검사한다 — `models/test_app.py::test_golden_parity` (골든 50건, 서버 없이) 와 백엔드 기동 시 파리티 실측(`/api/health` 의 `parity`, 예시 3건을 API 와 `lolwin.predict` 로 비교).
- 모델을 재학습하면 `models/model/artifacts/` 와 루트 `artifacts/` 를 **같이** 바꾸고, `MODEL_VERSION` 을 올린다. 사본이 어긋나면 웹과 API 가 다른 답을 낸다.
- 지연 실측(2026-09-22, 팀 서버 맥): 단건 p50 10.9 ms · p95 11.7 ms, 동시 10 에서 p95 130 ms, 오류 0.

## 5. 검증 — 무엇을 고치든 이것이 통과해야 한다

```bash
./check_api.sh test          # 단위 10건(계약·골든 50건 파리티·코치·프리픽스) + 서버가 떠 있으면 HTTP 스모크 10건
./check_project.sh test      # 웹서비스 쪽 — 모델 API 가 떠 있어야 한다 (예측·코칭이 API 를 거치므로). 파리티 4경로(직접·모델 API·백엔드·프런트) + 스모크
```
