POSA_Copyrighter/docs/operations/offline-install.md
changukyu 177e365b55 fix: 페르소나 리뷰 권고 1~3 — 판정 시점 내보내기, '위험'은 검토 순서, 운영 모드 문서
① 내부 내보내기가 현재 점수·현재 사용 표시 대신 decisionSnapshot 을 읽는다. 드리프트는
   currentRiskScore·scoredVersion 열로 드러낸다. 스냅샷 없는 옛 행은 no-snapshot.
② 큐 열 '위험' → '검토 우선'(침해 판정 아님), 점수 구성 패널에 범위 한 줄, 가이드 명시.
③ docs/operations/operating-modes.md: 전체 egress / API 호스트만 / 완전 폐쇄에서 점수가
   뜻하는 것. 완전 폐쇄는 키를 빼야 한다(남겨 두면 큐 전체가 실패 하한 30).

verify.ps1: ruff clean, 681 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-25 18:24:26 +09:00

6.5 KiB

Offline (air-gapped / 폐쇄망) install

The deployment target has no internet. All Python dependencies must be carried across the air gap as pre-downloaded wheels. The build/staging machine (which has internet) does the downloading; the target only installs.

Prerequisites

  • Same OS + CPU architecture + Python 3.13 on build and target machines (wheels are platform- and version-specific).
  • The repository itself transferred to the target.

1. On the build machine (has internet)

# Runtime only (--only-binary keeps sdists out so nothing has to build on the
# offline target; also carry pip/setuptools/wheel themselves):
pip download --only-binary=:all: -r requirements.txt -d wheelhouse/
pip download --only-binary=:all: pip setuptools wheel -d wheelhouse/

# Or runtime + test tooling:
pip download --only-binary=:all: -r requirements-dev.txt -d wheelhouse/

This fills wheelhouse/ with every wheel (including transitive deps).

Pin the full transitive tree with hashes so a tampered/substituted wheel is rejected at install time:

# On the build machine (pip-tools):
pip-compile --generate-hashes -o requirements.lock requirements.txt

Then install on the target with --require-hashes (step 3).

Model weights (models/)

Some analysis paths use pre-trained weights that are not pip packages and are not committed to the repository (models/ is gitignored, same treatment as wheelhouse/). Fetch them on the build machine:

mkdir -p models
# YuNet face detector (OpenCV Zoo, MIT). Used by face/person presence detection;
# cv2.FaceDetectorYN itself ships inside opencv-python-headless, only the weights
# are external.
curl -sSL -o models/face_detection_yunet_2023mar.onnx \
  https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx

Verify before transferring — a truncated or substituted model must not cross the air gap unnoticed:

file bytes sha256
face_detection_yunet_2023mar.onnx 232589 8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4
python - <<'PY'
import hashlib, pathlib
p = pathlib.Path("models/face_detection_yunet_2023mar.onnx")
print(len(p.read_bytes()), hashlib.sha256(p.read_bytes()).hexdigest())
PY

If models/ is absent on the target the server still starts: face detection falls back to the bundled Haar cascades and logs the downgrade once. It does not fail closed, because a missing optional weight must not stop an operator from reviewing.

2. Transfer

Copy the repository, the wheelhouse/ directory, and the models/ directory to the target via the approved air-gap transfer process (e.g. removable media).

3. On the target machine (no internet)

python -m venv .venv
. .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install --no-index --find-links wheelhouse/ -r requirements.txt

--no-index guarantees pip never reaches out to the internet; --find-links points it at the local wheelhouse.

4. Verify offline

# Confirm no network access is attempted and imports resolve:
python -c "import numpy, cv2, PIL; print('deps ok')"
python -m pytest -q          # if test deps were installed

Optional: Ollama LLM model (evidence narration)

The LLM assistant is optional. If you want it on the air-gapped target, the model weights must be carried across too — they are not part of the Python wheelhouse:

Carry the ollama binary itself as well as the weights — neither is in the Python wheelhouse.

Use qwen2.5:7b (about 4.7 GB), the code default. Do not substitute qwen2.5:0.5b-instruct: it was measured against this prompt at 55.7 s and it broke the output contract, reprinting the prompt's format example as if it were a finding. A 3B-class model is the floor for reading evidence and citing it.

# Build machine (has internet):
ollama pull qwen2.5:7b                   # or your pinned OLLAMA_MODEL
# Copy the model store across the air gap:
#   default: ~/.ollama/models  (or set OLLAMA_MODELS to a directory you transfer)

# Target machine (no internet):
export OLLAMA_MODELS=/path/to/transferred/models
ollama serve &
# Smoke test:
ollama run qwen2.5:7b "hello"

Set OLLAMA_BASE_URL / OLLAMA_MODEL in .env. Leaving them unset does not turn the LLM off — the client falls back to http://127.0.0.1:11434 and the code default model, so a target without Ollama spends about 2 s per case before writing a readable failure row (scores are unaffected either way). To stop the attempts, disable the llm provider in the operator console.

Hardware: the 7B default was timed on a GPU host (about 2 s warm, 7 s cold). On a CPU-only target, raise OLLAMA_TIMEOUT (default 120 s) and expect narration to dominate seed time.

Optional: Playwright Chromium (E2E tests)

The operator-GUI E2E suite needs a Chromium binary, which Playwright normally downloads. For the air gap, fetch it on the build machine and carry it:

# Build machine:
PLAYWRIGHT_BROWSERS_PATH=./pw-browsers playwright install chromium
# Transfer the pw-browsers/ directory to the target, then:

# Target machine:
export PLAYWRIGHT_BROWSERS_PATH=/path/to/pw-browsers
python -m pytest tests/operator_gui -q

Notes

  • Which network mode? Read operating-modes.md before deploying: what scores mean with full egress, API hosts only, or no egress at all (and why a fully closed site must remove the API keys rather than just block the network).
  • The operator GUI (web/operator-gui/) is fully self-contained: all scripts/styles are local and the Pretendard font is bundled (assets/fonts/PretendardVariable.woff2). No CDN fetch at runtime.
  • External search APIs (Google / Naver / Cloud Vision) are an allowed runtime exception and require outbound access to those providers. They are optional; missing credentials disable the provider (see the startup provider-readiness log).
  • Broader egress: beyond the API hosts, the image-comparison feature also fetches arbitrary public result-page / image / stylesheet URLs returned by those searches (SSRF-guarded: private/loopback/CGNAT/link-local ranges are blocked). Restricting egress to the API hosts only disables image comparison of external results; it does not break core review, but it weakens the web-copy signal silently (no failure row) - see operating-modes.md mode B.
  • COPYRIGHTER_AUTH_TOKEN should be set in .env for any multi-operator or networked deployment so the data routes require a shared token.