# server_llmwiki.py split — design memo (v0.4.x cycle, Stage 0)

> **Status**: design only. Stage 0 of an 8-PR sequence. No production
> code touched in this PR. Implementation starts at PR-A (auth router)
> in a follow-up PR.

## 1. Motivation

### 1.1 정량 측정 (2026-05-28, main `8ab06bc`)

| 측정 | 값 | 비교 |
|---|---|---|
| `server_llmwiki.py` 크기 | **242.4 KB** (248,179 bytes) | core/ cap (20 KB) 의 **12.1배** |
| 라인 수 | **6021 lines** | — |
| `@app.*` 데코레이터 (엔드포인트) | **129** | — |
| `def` + `async def` | 149 | — |
| 클래스 (대부분 Pydantic) | 38 | — |
| top-level `import` / `from` | 26 | — |
| `# ─` 섹션 마커 | 35 | 도메인 구분은 이미 존재 |
| `audit_log` / `_write_audit` 호출 | 24 | 분할 시 정확히 유지 필요 |

### 1.2 엔드포인트 도메인 분포 (89% admin 편중)

```
/admin/*    115 endpoints   ← 압도적
/jobs        11
/password    10
/code         8
/api-keys     8
/signup       6
/history      6
/upload       5
/query        5
/login        5
/analyze      4
/llm          3
/feedback     3
```

### 1.3 왜 지금인가

| 트리거 | 상태 |
|---|---|
| External polish 후 외부 리뷰어 지적 (2026-05-28 오전) | ✓ |
| Operator action queue clear — v0.4.1 release published / Zenodo DOI `10.5281/zenodo.20426719` minted / DOI bump PR #569 merged | ✓ |
| v0.4.2 (T3 Aging) 미진입 — 별 v0.4.x cycle 로 진행 가능 | ✓ |
| 22-PR closure 세션 cool-down — 새 세션 진입 | ✓ |

CLAUDE.md rule 5 글자대로는 `core/` 만 cap 20 KB 이므로 root 파일은 rule 위반 아님. 그러나 외부 contributor 가독성 + 머지 충돌 / merge-resolve 비용 / cognitive load 측면에서 분할이 건전. 이 메모는 그 분할을 **결정론적 + 회귀-제로** 로 수행하는 path 를 박는다.

## 2. Invariants — 모든 PR 가 만족해야 하는 6 가지

이 6 invariant 가 깨지면 그 PR 은 revert + 재설계.

### 2.1 URL byte-identical

모든 엔드포인트 URL 이 분할 전후 동일. 추가도 변경도 삭제도 0.
검증: `scripts/audit_endpoint_paths.py` (Stage 0 와 함께 도입 — PR-A 의
precondition) 가 main 의 endpoint 집합과 현재 브랜치의 endpoint 집합을
정확히 비교, 차이 1개라도 있으면 exit-code 1.

### 2.2 Middleware 등록 순서 (실행 ordering)

현재 등록 순서 (FastAPI/Starlette decorator-stack 기준):

| line | name | 등록 순서 | 실행 순서 (request → response) |
|---|---|---|---|
| 187 | `no_cache_static` | 1번째 데코레이트 | **inner** — response 단계에서 /static/* 헤더 mutation |
| 606 | `rate_limit_middleware` | 2번째 데코레이트 | **outer** — request 단계에서 429 단축 가능 |

Starlette: 후에 등록된 미들웨어가 outer (먼저 request 받음 + 마지막 response 가공).
분할 후에도 동일 invariant: `rate_limit_middleware` 가 outer, `no_cache_static` 이 inner.
검증: pytest로 mock request 두 경로 (정적 hit / `/login/` rate-limit hit) 모두 기존
동작 (`Cache-Control: no-cache,no-store,must-revalidate` + 429 동작) 재현.

### 2.3 audit_log emit 위치 (24 호출)

`_write_audit` 24 호출의 (a) emit 시점 — handler 내 vs middleware 내 — 와 (b)
emit field 집합 (`user_role`, `endpoint`, `security_event`, ...) 이 분할 전후 동일.
APIRouter 로 옮긴 핸들러는 `_write_audit` 의 import 경로만 바꿀 뿐 호출 timing /
값은 변경 0.
검증: `tests/test_emit_trace_step_stdout_mirror.py` + audit_log row 갯수 회귀
테스트 추가 (PR-A precondition).

### 2.4 Static mount path

`/static` mount 가 `frontend/static` 디렉토리를 동일 name (`"static"`) 으로 서빙.
APIRouter 분할 후에도 `app.mount("/static", ...)` 는 server_llmwiki.py 본체에
남는다 — APIRouter 는 routes 만 들고, mount 는 `app` 인스턴스 메서드.

### 2.5 Lifespan / startup event

`@app.on_event("startup")` (line 259, plugin pack loader 포함) 도 server_llmwiki.py
본체 잔류. 분할 중에 lifespan API (`@asynccontextmanager`) 로 마이그레이션 금지 —
별 PR 로 분리. (현재 FastAPI 이 deprecation 경고 없이 받아들이므로 기능 동일.)

### 2.6 Pytest 회귀 zero (3290 tests)

`python -m pytest tests/` 가 분할 전후 동일 (collected / passed / xfail / xpassed).
실패 0 / skip 차이 0 / collection error 0.
검증: 각 PR description 에 `pytest --collect-only -q | tail -3` + `pytest -q | tail -5`
출력 (3290 collected / passed N / xfailed M).

## 3. APIRouter pattern — FastAPI 표준 prior art

FastAPI 의 분할은 `APIRouter` 인스턴스 + `app.include_router(router, prefix=, tags=)`.
파라미터 / dependency / response_model 모두 동일하게 라우터에 전가됨. 이번 분할은
**prefix 사용 안 함** — URL byte-identical invariant 충족 위해 라우터 내부에
full path 그대로 (`@router.post("/login/")`, `@router.get("/admin/llm/installed")`).

### 3.1 Dependency injection

현재 `server_llmwiki.py` 에서 핸들러가 사용하는 module-level singletons:
- `rag_engine` (RAGEngine 인스턴스)
- `file_processor` (FileProcessor)
- `bearer_scheme` (HTTPBearer)
- `_rate_limiter`
- `default_engine` (PolicyEngine)

전략: **PR-A 와 함께 `routes/_deps.py` 모듈 신설** — 위 singleton 들을 `Depends()`-호환 getter 로 wrap. 라우터 모듈은 `from .deps import get_rag_engine` 등으로
의존. PR-A 가 도그푸드로 인증 라우터를 한 번 옮기면서 패턴 정착.

### 3.2 Module-level helpers

`get_client_ip(request)`, `_write_audit(...)`, `_require_admin(...)` 등 helper
함수는 **`routes/_helpers.py`** 로 별도 추출 (server_llmwiki.py 에서도 import).
PR-A 의 안에 포함.

## 4. 8-PR roadmap (A → H)

각 PR 은 **하나의 도메인** 만 추출 + 6 invariants 검증 + Quality Delta exempt
(label: `chore` — `core/` 무관). PR 간 의존: 직선 순서.

| PR | 도메인 | 추출 대상 | 추정 line | 잔류 server line |
|---|---|---|---|---|
| **A** | 인증 / 사용자 / 업로드 | `/login/`, `/signup/`, `/password/*`, `/api-keys/*`, `/admin/user-*`, `/upload/` + `routes/_deps.py` + `routes/_helpers.py` 신설 | ~400 | ~5621 |
| **B** | LLM / Ollama | `/llm/*`, `/admin/llm/*` (install / progress / active / recommend / pull / delete / select / modes) | ~300 | ~5321 |
| **C** | Workspace jobs / scheduler | `/jobs/*`, `/admin/jobs/*` (W8 cycle) | ~500 | ~4821 |
| **D** | Data artifacts | `/artifacts/*`, `/admin/artifacts/*`, `/admin/data/*` (W7-A) | ~400 | ~4421 |
| **E** | 자기진화 / 멀티모달 | `/admin/proposals/*`, `/admin/learn/*`, `/admin/eval/*`, `/admin/vid-*`, `/admin/scr-*`, `/admin/vis-*` (Phase 7 + P7-VIS/VID/SCR) | ~700 | ~3721 |
| **F** | 코딩 에이전트 | `/code/*` (Phase 5.5) | ~200 | ~3521 |
| **G** | 잔여 admin | `/admin/*` 의 나머지 (feature matrix / cognitive toggles / change request / 기타) | ~1500 | ~2021 |
| **H** | server_llmwiki.py 정리 | app 부트 + Pydantic 모델 + middleware + lifespan + `include_router` 모음 만 잔류 | — | **~500** |

### 4.1 잔류 server 의 최종 형태 (PR-H 후)

```python
# server_llmwiki.py — ~500 lines after split
# 1. imports + utf-8 console
# 2. config / globals (UPLOAD_DIR, etc.)
# 3. shared deps singletons (rag_engine, file_processor, _rate_limiter)
# 4. RateLimiter class (or move to core/)
# 5. app = FastAPI(...)
# 6. app.mount("/static", ...)
# 7. @app.middleware("http") × 2 (no_cache_static, rate_limit)
# 8. @app.get("/") / "/healthz" / "/admin" / "/workspace" / "/admin/graph"
#    (HTML 셸 5개만 — App-level concern)
# 9. @app.on_event("startup") — plugin pack loader
# 10. include_router(auth_router) / llm_router / ...  ← 7 router
# 11. if __name__ == "__main__": uvicorn.run(...)
```

목표 cap: **20 KB** (CLAUDE.md rule 5 정합). 6021 → ~500 lines, 248 KB → ~20 KB.

## 5. Per-PR contract

각 PR 의 description 에 다음을 paste:

```
## Invariant verification (server-split rule)
- URL diff:    `python scripts/audit_endpoint_paths.py main` → exit 0
- Middleware:  routing-order test 2 cases pass (static + 429)
- Audit:       _write_audit row count delta = 0 on smoke fixture
- Static:      manual GET /static/admin.css → Cache-Control 헤더 동일
- Startup:     server boot stdout banner identical (mtime ignored)
- Pytest:      3290 collected / N passed / M xfailed — delta 0

## Quality Delta
Quality delta: exempt (label: chore)
```

PR-merge 룰: 6 invariants 어느 하나라도 미충족 시 → revert. **부분 통과 + TODO 잔류 금지** — invariant는 binary.

## 6. 회귀 보호

### 6.1 `scripts/audit_endpoint_paths.py` (PR-A 와 함께 도입)

```python
# usage: python scripts/audit_endpoint_paths.py [base-branch]
# diff = (current endpoints) Δ (base-branch endpoints), exit 0 only on empty diff
import sys, subprocess, importlib, re

def collect_endpoints(branch: str) -> set[str]:
    # checkout branch in detached HEAD (or git show server_llmwiki.py)
    # parse @app.{get,post,put,delete,patch}("path", ...) decorators
    # return set of (method, path) tuples
    ...

if __name__ == "__main__":
    base = sys.argv[1] if len(sys.argv) > 1 else "main"
    cur, ref = collect_endpoints("HEAD"), collect_endpoints(base)
    added, removed = cur - ref, ref - cur
    if added or removed:
        print(f"FAIL: +{len(added)} -{len(removed)}")
        for e in sorted(added): print(f"  + {e}")
        for e in sorted(removed): print(f"  - {e}")
        sys.exit(1)
    print(f"OK: {len(cur)} endpoints identical")
```

GitHub Actions workflow 추가 (`server-split-invariant.yml`) — 머지 게이트로 박힘.
PR-H 종료 후 workflow 는 빈 diff 보장 모드로 영구 유지.

### 6.2 Test boot smoke

`tests/test_server_boot_smoke.py` (PR-A 와 함께) — `from server_llmwiki import app`
이후 `app.routes` 목록 + middleware stack + `_state` 가 분할 전 snapshot 와 동일.
snapshot 은 main `8ab06bc` 에서 캡처 + 커밋 (json 파일).

## 7. Rollback

각 PR 은 squash 1 commit. revert 비용 = 1 commit revert. PR-H 까지 누적 8 squash —
중간 revert 도 직선 가능.

치명적 회귀 (e.g. middleware 순서 미묘하게 깨짐 + 운영 환경에서만 surface) 시
**전체 sequence revert** = 8 squash 역순 revert. main 은 분할 전 상태로 복원.

## 8. 진입 결정 (LOCKs)

| Decision | LOCK |
|---|---|
| **Prefix 사용** | OFF — APIRouter 내부에 full path 그대로. URL byte-identical invariant. |
| **lifespan API 마이그레이션** | DEFER — `@app.on_event("startup")` 그대로 유지. 별 PR 로 분리. |
| **`/static` mount 이동** | NO — `app.mount` 는 server_llmwiki.py 본체에 영구 잔류. |
| **DI 패턴** | PR-A 와 함께 `routes/_deps.py` Singleton-getter 도입 + 도그푸드 검증. |
| **module-size CI** | PR-H 종료 후 `server_llmwiki.py` 도 CLAUDE.md rule 5 (20 KB cap) 적용 — `core/` 와 동일. |
| **Quality Delta exemption** | 모든 PR `label: chore` — `core/` 무관 + URL 정합 invariant 가 oracle 역할. |
| **PR 간 간격** | 직선 머지. 사이에 다른 cycle (T3 Aging 등) 끼우는 것 가능. |

## 9. Out of scope (이 메모 이후 별 cycle)

- **`server_llmwiki.py` 의 Pydantic 모델 (~38 클래스) 별 모듈 분리** — 분할 PR 안에서는 `routes/<domain>.py` 와 함께 살게 둔다 (응집도 우선). 별 cycle 에서 `core/api_models/` 로 모을지 평가.
- **lifespan API 마이그레이션** — `@app.on_event("startup")` 가 deprecation 경고 없이 동작하므로 분할 사이클과 분리.
- **Endpoint API 버저닝 (`/v1/`, `/v2/`)** — 외부 contributor 가 진짜로 들어오는 시그널 (e.g. 3-party API 클라이언트) 발생 시. 지금은 NO.
- **OpenAPI 태그 재구성** — PR 별로 라우터에 `tags=["auth"]` 등 자연 추가 가능하나, 그게 분할의 oracle 아님.

## 10. Anti-rush (다시 한 번)

이 메모는 **8 PR 직선 머지** 를 정의하지, "한 번에 분할" 을 정의하지 않는다.
선례 `pipeline.py` 19→16 KB split (PR #518) 은 ~300 lines 단위라 1 PR 로 가능했지만,
`server_llmwiki.py` 는 6021 lines 단위 + 129 endpoints + 24 audit emit + 2
middleware + DI singletons 가 얽혀있어 **1 PR 분할 = invariant 검증 불가능**.

PR-A 가 머지된 시점이 가장 위험. 패턴 검증이 부족할 수 있으므로 PR-A 직후
operator-run smoke test (server 띄우고 /login/ + /upload/ + /static/admin.css
수동 GET) 권장. PR-B 부터는 패턴이 박힌 후라 cadence 가능.
