"이 도구가 실제 업무에 도움이 되는가"를 6축으로 조사하고 축마다 결론을 반증시켰다(docs/reviews/2026-08-08-utility-assessment.md). 결론은 "검색·기록 노동은 줄여주나 심사 건수는 못 줄인다"이고, 그 과정에서 실행으로 확정된 P0 결함 4건을 여기서 고친다. 판별력 붕괴(P1)는 별도 작업이다. 폴더 정리가 판정 기록을 지웠다 (sqlite_store.py) _prune_missing_submission_files 가 원본 파일이 없는 submission 행을 판정 여부와 무관하게 delete 했고, foreign_keys=on 이라 evidence 가 cascade 로 함께 사라졌다. 심사 끝난 이미지를 아카이브로 옮기는 평범한 운영 행위가 승인/반려를 DB 와 내보내기 양쪽에서 지운다. 실증: rejected 판정 후 export 1행 -> 파일 제거 -> submissions 0행 / evidence 0행 / export 0행. 판정된 케이스는 파일이 없어도 보존하고 assetMissing 마커를 남긴다. 마커가 필요한 이유는 이 스윕이 매 리로드마다 돌기 때문이다 — 표시 없이 보존만 하면 같은 감사 이벤트가 리로드마다 쌓여 500행 캡을 먹는다. 비상 차단 스위치가 아무것도 막지 않았다 (store_enrichment.py) emergency_disable_external_providers 는 DB 의 enabled/operatorDisabled 만 쓰는데 자동 보강 경로가 그 플래그를 아예 읽지 않았다. 게이트가 걸린 곳은 수동 검색뿐이라, 버튼을 눌러도 폴더 리로드·업로드·rerun-enrichment 가 그대로 Naver/Google 로 나갔다. 스텁 어댑터 대조실험에서 CONTROL 5회 / 차단 후 5회로 완전히 동일했다. _external_calls_blocked(provider_id) 를 신설해 자동 외부 호출 5개 경로에 배선했다: auto naver, auto CSE, rerun Vision, 얼굴 크롭 웹탐지, 시드 배치 (후자는 ExternalApiPolicy(disabled=True) 로 전환). enabled 가 아니라 operatorDisabled 를 본다. enabled 는 연결성/헬스에도 반응하므로 운영자가 멈추라고 한 적 없는 트래픽까지 죽인다. 얼굴 크롭은 생체 파생물을 전송하므로 상위 게이트에 의존하지 않고 독립 게이트를 건다. 생체 크롭이 보존기간을 넘겨 살아남았다 (store_backup.py) 스냅샷이 face-crops 를 통째로 담는데 프루닝은 나이가 아니라 개수 10개 기준이라, 90일에 파기됐어야 할 크롭이 암호화 없는 zip 안에 남았다. 복구는 더 나빴다 — copyfileobj 로 새로 쓰므로 mtime 이 리셋되고, purge 가 mtime 을 보므로 파기 대상이 복구 직후부터 다시 90일을 살았다. 스냅샷에서 제외하고, 복구 시에도 레거시 아카이브의 크롭을 건너뛴다. 크롭은 원본에서 재분석으로 재생성되므로 손실 비용이 없다. 오프라인 설치 문서가 계약 위반 모델을 지목했다 (offline-install.md) qwen2.5:0.5b-instruct 반입을 지시했는데, 같은 리포가 이 모델을 55.7초 + 프롬프트 형식 예시 반향(출력 계약 위반)으로 기록해 두고 기본값을 7b 로 고정해 뒀다. 문서대로 반입하면 타깃에서 요약이 조용히 죽는다. 모델명을 정정하고, 반입물 목록에 없던 ollama 바이너리와 GPU 전제를 추가했으며, "env 를 비우면 LLM 없이 돈다"는 서술을 실측으로 정정했다 — 실제로는 127.0.0.1:11434 로 폴백해 provider 가 enabled=True 로 보고된다. 회귀 테스트 3건 추가. pytest 565 passed(착수 전 562), 린트 클린.
165 lines
6.2 KiB
Markdown
165 lines
6.2 KiB
Markdown
# 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)
|
|
|
|
```bash
|
|
# 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).
|
|
|
|
### Hash-verified lock (recommended)
|
|
|
|
Pin the full transitive tree with hashes so a tampered/substituted wheel is
|
|
rejected at install time:
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```bash
|
|
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` |
|
|
|
|
```bash
|
|
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)
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
# 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.
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```bash
|
|
# 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.
|