POSA_Copyrighter/docs/operations/offline-install.md
changukyu 4f140efd9b feat: 실사용 투입 전 T0 스탬프 4종 + 임베딩 정책 개정
두 갈래의 작업이 한 커밋에 들어 있다. llm_assistance.py 에서 T0-4 가 앞선
작업의 프롬프트 수정을 대체했기 때문에(프롬프트 리터럴이 llm_prompts.py 로
이동), 분리하려면 실제로 존재한 적 없는 중간 상태를 만들어야 한다.

## (1) 앞서 진행 중이던 review-intelligence 작업

- decision_stats(): 위험밴드 x 실제판정 교차표 + 캘리브레이션 지표
- cleared_reference 지식엔트리 타입: 이전에 클리어된 이미지 매칭은 +80 이
  아니라 0점 긍정 신호
- LLM 요약 본문 영속화(_evidence_payload 에 summary/sourceUrls) — 그전까지
  요약이 저장되지 않아 운영자에게 한 번도 보인 적이 없었다
- 운영자 GUI 반영

## (2) 실사용 투입 전 T0 — 첫 판정 이후에는 소급 불가한 것들

- T0-1 initialRiskScore/initialRiskBand/scorerVersion 각인.
  _rescore_submission 이 12곳에서 riskScore 를 덮어쓰므로 저장된 점수는 늘
  왕복 점수였고, 그것을 재채점과 비교하는 테스트는 항상 통과하는 빈 테스트다.
  레거시 행은 scorerVersion="legacy" 로 표시만 하고 점수를 날조하지 않는다.
- T0-2 얼굴검출 Haar -> YuNet, 인물 등급(identifiable/background) 도입.
  기존의 평평한 +35 는 사람이 찍힌 모든 사진을 medium 문턱 위로 올려 밴드
  분포를 붕괴시켰다(실측 high 4 / medium 2 / low 0). 등급은 payload 라운드
  트립으로 보존한다 — 잃으면 재채점마다 조용히 +35 로 되돌아간다.
  가중치는 models/(gitignore) 에 두고 offline-install.md 에 sha256 반입 절차.
  가중치가 없으면 Haar 로 강등하고 1회만 경고한다.
- T0-3 판정 시점 피처 스냅샷. 큐가 riskScore 내림차순이고 화면이 점수를 먼저
  보여준 뒤 판정을 받으므로, 스냅샷 없이 모은 라벨은 "이 근거가 반려를
  예측한다"가 아니라 "운영자가 내가 보여준 점수에 동의했다"를 기록한다.
- T0-4 모델/프롬프트 버전 + latency_ms 스탬프. latency_ms 는 _evidence_id
  해시에서 제외한다 — 포함하면 같은 요약이 재생성될 때마다 id 가 바뀐다.

## (3) 임베딩 저장 금지 정책 개정

금지의 취지는 생체 식별이지 이미지 유사도가 아니므로 범위를 좁힌다.
장면 임베딩(전체 이미지 의미 벡터)을 이미지 유사도 판정에 한해 허용하되
얼굴 마스킹 / 인물 지배 프레임 제외 / 목적 제한 / 별도 저장 클래스 /
연쇄 삭제 5조건을 assert_scene_embedding_allowed 로 강제한다. 마스킹 증명이
없으면 거부한다 — 누락은 마스킹을 건너뛴 것과 구별할 수 없다.
얼굴 임베딩 금지는 landmarks/face_descriptor/face_encoding 추가로 강화.

pytest 502 passed (착수 전 463).
2026-07-25 23:08:19 +09:00

5.4 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:

# Build machine (has internet):
ollama pull qwen2.5:0.5b-instruct        # 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:0.5b-instruct "hello"

Set OLLAMA_BASE_URL / OLLAMA_MODEL in .env. Leave them unset to run without the LLM (the llm provider then reports disabled).

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

  • 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.
  • COPYRIGHTER_AUTH_TOKEN should be set in .env for any multi-operator or networked deployment so the data routes require a shared token.