POSA_Copyrighter/docs/reviews/2026-08-08-utility-assessment.md
changukyu 02395ac3aa fix: 판정 기록 소실 · 비상차단 미작동 · 생체 크롭 보존기간 초과 차단
"이 도구가 실제 업무에 도움이 되는가"를 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), 린트 클린.
2026-08-09 01:23:21 +09:00

164 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 기능 실효성 평가 — "이 도구가 실제 업무에 도움이 되는가"
2026-08-08 · 6축 조사 + 축별 적대적 반증(에이전트 12). 모든 수치는 실코드·실DB·실모델 실행 결과다.
## 결론
**검색·기록 노동은 실제로 줄여준다. 심사할 건수는 전혀 줄여주지 못한다.**
현재 이 도구는 위험 판별기가 아니라 증거 수집·기록기다.
| 축 | 판정 |
|---|---|
| 증거 수집 자동화 | **작동** — 단, 탐지력 이득이 아니라 배치·영속화·감사 이득 |
| 위험 점수 판별력 | **미작동** — 전건 high 수렴, 우선순위 정보 0비트 |
| LLM 증거 판독 | **격리는 성공, 산출물 가치는 미검증** — 운영 DB 기준 한 번도 표시된 적 없음 |
| 콘솔 작업 경제성 | **승인/보류 루프만 빠름** — 반려·일괄·감사는 병목 |
| 운영 준비도 | **폐쇄망 반입·백업은 견고, 신원·처리량은 미구현** |
## 1. 실제로 도움이 되는 것 (반증 통과)
- **Cloud Vision 웹탐지가 신규 제출 전건에 자동 1회** 실행되고 결과가 케이스에 영구 저장 + 감사기록으로 남는다
(`integrations/cloud_vision_web_detection.py:39-98`, `jobs/batch_analyzer.py:77-84`, `sqlite_store.py:430-446`).
탐지 인덱스는 운영자가 Lens에 직접 넣는 것과 동일하므로 **이득은 탐지력이 아니라 배치·영속화·감사**다.
- **승인/보류 키보드 1타 + 자동 다음 케이스 이동** (`app-keyboard-batch.js:182-200`). 저위험 대량 승인 루프는 실제로 빠르다.
- **중복 탐지가 실증적으로 일한다** — 재저장(JPEG q90)/80%축소/50%축소/밝기+10% 변형 24케이스 중 **24건 그룹핑**,
같은 24케이스에서 SHA-256 완전일치는 **0건**. 지각 해시 레이어가 exact 해시가 못 잡는 재제출을 잡는다.
갭은 5% 크롭 6/6 미탐(해밍 2~5비트 vs 임계 0.97의 1비트 예산).
- **판정 시점 스냅샷 · 메모 없는 반려 차단(서버+클라이언트) · 대상별 내보내기 화이트리스트**
(`store_workflow.py:43-85`, `:21-37` APPLICANT 5필드 vs INTERNAL 10필드).
- **백업/복구 왕복 실증 성공** (`Connection.backup` + pre-restore 구제 사본 + 사이드카 제거).
- **폐쇄망 Python 반입 절차 완결** — 런타임 서드파티 3개 핀 고정, GUI CDN 참조 0건, Pretendard 동봉,
YuNet 가중치 실물이 문서 표의 232,589 bytes와 일치.
- **SSRF 가드 실동작**, 쿼터 원자 예약(try_reserve/release), 쿼리 시그니처 dedup으로 재분석 시 Naver/CSE 재소모 0.
- **LLM이 점수·밴드를 절대 움직이지 못하도록 격리** (`risk_scoring.py:89, 101-102`). Ollama 부재 시 읽을 수 있는
failure 행 1건으로 끝나고 점수 영향 없음(실측 2.01s / 0.19s).
## 2. 판별력 붕괴 — 이 도구가 "심사 건수"를 못 줄이는 이유
신호조합 224개 전수 계산: **high 174건(77.7%) / medium 42건(18.8%) / low 8건(3.6%)**, 126건(56.2%)이 정확히 100점.
실 DB 6건도 100×4 / 45×2, **low 0건**.
지배적 동인은 match 타입 스택이 아니라 **web entity 누적**이다 (`analysis/risk_scoring.py:159-160, 168-174`).
버킷이 entity 이름별로 분리돼 서로 dedup되지 않고 `round(40 × confidence)`씩 쌓인다. Vision은 `maxResults=10`
(`cloud_vision_web_detection.py:47`)이라 모든 이미지가 라벨 10행을 받는다. 실측:
| 입력 | 결과 |
|---|---|
| entity 2개만 (이미지 매칭 0건) | 72 / high |
| entity 3개만 (이미지 매칭 0건) | **100 / high** |
| 웹에 전혀 없는 오리지널 사진 (dog/pet/grass) | 66 / medium |
| full+partial+visual+page 각 1건 (평범한 웹 존재) | 100 / high |
| entity4 + full + partial + visual + page + 얼굴 (명백 침해) | 100 / high |
**이 누적은 사고가 아니라 테스트가 고정한 동작이다** — `test_risk_scoring.py:234-258`이
"서로 다른 엔티티 2개는 반드시 high로 쌓여야 한다"를 assert한다. 따라서 기존 테스트로는 절대 잡히지 않는다.
파생 결과:
- **큐 기본 정렬이 riskScore 내림차순**(`app-selectors.js:304`)인데 전건 100이면 정렬이 no-op다.
- **low 밴드가 사실상 도달 불가** — low 8건은 전부 "아무것도 못 찾음" 조합뿐이라 "안 봐도 된다"는 결론을 못 준다.
- **신호 우선순위 역전** — 법적으로 가장 결정적인 EXIF/IPTC 권리 표기가 0점이라
`(c) Getty Images` 단독 제출은 0점/low인데, Vision의 추측 라벨 3개는 100/high다
(`risk_scoring.py:80-88` PROVENANCE는 reason만 남기고 continue).
- **운영자 되먹임이 순증 노동** — irrelevant 표시는 버킷당 "점수 매기는 첫 미표시 행"만 밀어내므로,
실 DB 제출물 4번을 100→35로 내리려면 google 증거 51행을 **개별로** 표시해야 한다. 일괄 API가 없다(개별 `/api/evidence/{id}/status` 뿐).
- **cleared_reference가 재심사를 못 막는다** — FINGERPRINT 분기에서만 0점이라
[cleared_reference + 평범한 Vision 응답] = 100 high. 외부 호출 쿼터도 그대로 소모한다.
**정답 라벨 0건**: submissions 6건 전부 `unreviewed`, corrections 0행, `Operator decision created` 감사 이벤트 0건.
밴드 임계 70/30이 실제 판정과 한 번도 대조된 적이 없다.
## 3. 검증 코퍼스가 없다 (전제 정정)
`data/copyrighter.sqlite3`의 evidence 292행 중 288행이 **2026-05-26**, 이 리포의 첫 커밋은 2026-06-09이다.
즉 DB는 git 이력보다 2주 앞선 구빌드 스냅샷이며 6~8월 두 달치 변경이 반영돼 있지 않다:
- 저장 entity 행 40개 전부 `entity=null` → 리스코어 시 0점 (현행 코드는 저장함)
- 저장 entity confidence가 14.862 (현행 코드는 [0,1] 클램프)
- 증거 292행 전부 `summary`/`domainSource`/`faceTier`/`verified`/`groundedClaims` 키 부재
- submissions 6건 전부 `scorerVersion` 부재, `submittedEpoch=0`
**판별력 판정을 확정하려면 실이미지 30~50장을 현행 코드로 새로 돌려야 한다.** 그것이 밴드 캘리브레이션의 출발점이기도 하다.
## 4. 이번에 수정한 것 (P0)
### (1) 폴더 정리가 판정 기록을 지우던 문제 — 확정 후 수정
실증: `record_decision('3','rejected')` → export 1행 → 원본 파일 제거 → `_prune_missing_submission_files` →
submissions 0행 / evidence 0행(cascade) / **export 0행**.
`sqlite_store.py:_prune_missing_submission_files`에 판정 완료 가드를 넣었다. 판정된 케이스는 원본 파일이 없어도
보존하고 `assetMissing` 마커 + 감사 이벤트를 **1회만** 남긴다(매 리로드마다 재기록되면 500행 감사 캡을 먹는다).
미판정 케이스의 프루닝 동작은 그대로다.
### (2) 비상 차단 스위치가 장식이던 문제 — 확정 후 수정
대조실험: 스텁 어댑터로 계수 → CONTROL 5회 / `emergency_disable_external_providers()` 후 **5회로 동일**.
원인은 스위치가 DB 플래그만 쓰고 자동 보강 경로가 그 플래그를 아예 읽지 않은 것. 게이트는 수동 검색에만 있었다.
`_external_calls_blocked(provider_id)`를 신설(`store_enrichment.py`)하고 자동 외부 호출 4개 경로 전부에 배선했다:
| 경로 | provider |
|---|---|
| `_auto_naver_search` | `naver` |
| `_auto_google_custom_search` | `google_search` |
| `_rerun_google_image_search` | `google` |
| `_google_face_crop_web_detection` | `google` (생체 전송이라 상위 게이트에 의존하지 않고 독립 게이트) |
| 시드 경로 Vision 배치 (`sqlite_store.py`) | `google` → `ExternalApiPolicy(disabled=True)` |
게이트는 `enabled`가 아니라 **`operatorDisabled`** 를 본다. 전자는 연결성/헬스에도 반응하므로 운영자가 멈추라고
한 적 없는 트래픽까지 죽인다. 후자는 개별 토글과 비상 스위치만 세우는 의도적 정지이고 리로드에도 유지된다.
### (3) 생체정보(얼굴 크롭)가 보존기간을 초과 생존하던 문제 — 확정 후 수정
두 경로가 있었다: (a) 스냅샷이 `face-crops`를 통째로 담는데 프루닝은 나이가 아니라 **개수 10개** 기준이라
90일에 파기됐어야 할 크롭이 암호화 없는 zip 안에 남았다. (b) 복구는 `copyfileobj`로 새로 쓰므로 **mtime이 리셋**되고,
purge가 mtime을 보므로 파기 대상이 복구 직후부터 다시 90일을 산다.
`store_backup.py`의 `_MEDIA_SOURCES`에서 face-crops를 제외하고, 복구 시에도 `_RETENTION_BOUND_MEDIA`로
레거시 아카이브의 크롭을 건너뛴다. 크롭은 원본 이미지에서 재분석으로 재생성되므로 손실 비용이 없다.
### (4) 오프라인 설치 문서가 계약 위반 모델을 지목하던 문제
`offline-install.md`가 `qwen2.5:0.5b-instruct` 반입을 지시했다 — 같은 리포가 55.7초 + 프롬프트 형식 예시 반향으로
**출력 계약 위반** 기록을 남긴 모델이다. 문서대로 반입하면 타깃에서 요약이 조용히 죽는다.
`qwen2.5:7b`로 정정하고, ollama 바이너리 반입 누락·GPU 전제·"env 비우면 LLM 꺼진다"는 거짓 서술
(실측: `build_provider_runtime({})` → `enabled=True`, compliance 문자열까지 참으로 표시)을 함께 고쳤다.
검증: `.\verify.ps1` — ruff 클린, **pytest 565 passed**(착수 전 562, 신규 회귀 테스트 3건).
## 5. 남은 우선순위
**P1 — 판별력 복구 (이것이 "업무 도움" 여부를 가르는 축)**
1. web entity를 지식DB/워치리스트와 대조된 것만 점수화 + 서술 라벨 누적에 캡.
`test_risk_scoring.py:234-258`이 현 동작을 고정하고 있으므로 그 테스트도 함께 개정해야 한다. **`SCORER_VERSION` 을 올린다.**
2. PROVENANCE(EXIF/IPTC 권리 표기)에 배점. 단 실이미지 56장에서 증거 행 0건이었고,
방송캡처 분기는 `MIN_CAPTURE_CATEGORIES=2`에서 geometry가 1개 카테고리라 **자막 없는 방송 캡처는 구조적으로 도달 불가**다
(`provenance.py:85-88`). 배점 전에 이 분기부터 고칠 것.
3. 매칭 호스트 기반 신뢰출처 구분. 호스트 데이터는 이미 전 매칭 행에 저장돼 있고(실측 105행에서 도메인 추출 가능) 점수만 안 쓴다.
4. 밴드 재캘리브레이션은 판정 30~50건이 쌓인 뒤. `decision_stats` 교차표는 이미 구현돼 있다.
**P2 — 운영**
5. 운영자 신원. 현재 감사 actor가 전부 하드코딩 리터럴(`rights.ops`/`admin.ops`/`system`)이라 "누가 반려했나"에 답할 수 없다.
6. 레거시 llm 행 마이그레이션. `summary` 키가 없는 9행이 `_provider_item_has_result`를 통과해
`only_if_missing` 경로를 막으므로 **6개 제출물 어디에도 요약이 영원히 생성되지 않는다**. 커버리지 패널은 "했다"고 보고한다.
7. LLM 프롬프트의 `domain`이 실호스트가 아니라 provider 이름(`store_serialization.py:298`이
`data['provider']`를 우선). 저작권 심사에서 가장 중요한 "어디에 떠 있나"를 모델이 원천적으로 말할 수 없다.
같은 케이스 55행 중 41행이 url을 갖고 있는데 프롬프트 12줄 전부 `domain=google`이다.
8. 자동 쿼리 3슬롯을 페이지 제목이 전부 선점(우선순위 100 vs entity 88/82)해 권리 지향 쿼리가 구조적으로 실행되지 않음
+ `_clean_title`이 말줄임표를 제거하지 않아 저장 제목 39개 중 13개(33%)가 잘린 채 검색어가 된다.
9. 판정당 bootstrap 전량 왕복 2회. 300케이스 실측 9.58MB, 유효 델타는 36KB.
POST `/decision` 응답이 이미 완결된 케이스 페이로드인데 버리고 `/api/bootstrap`을 다시 친다.
10. 반려 경로의 키보드 단절(제출 단축키 없음), 감사 로그 500건 고정 상한 + 필터·내보내기 부재.
**적대적 검증에서 반증된 항목** (되돌리려는 시도 방지용 기록)
- ~~폐쇄망에서 증거 썸네일이 안 보인다~~ → 런타임 외부 호출은 허용된 설계이고 `http_app.py:510-511`에 근거 주석이 있다.
다만 서버 egress와 브라우저 egress는 다른 문제이므로 **납품 전 타깃망에서 썸네일 1장 실로드 확인**은 남긴다.
- ~~중복 그룹핑 backfill이 미작동~~ → reload/import-folder/upload 3개 라우트 전부가 backfill을 거친다. 배지가 안 뜬 건 테스트 이미지 6장이 서로 다른 사진이라서다.
- ~~재분석이 Naver/CSE 쿼터를 재소모~~ → 시그니처 dedup으로 0콜. 실제 재소모는 Vision 쪽(dedup 없음 + 얼굴크롭 최대 3콜).
- ~~쿼터로 스킵된 쿼리가 영구 등재돼 재시도 불가(7/24 H-3)~~ → `_RETRYABLE_SKIP_DOMAIN_SOURCES`로 이미 수정됨.
- ~~판정 내보내기가 decidedAt/memo를 빈 값으로 낸다~~ → 현행 경로는 정상. 실 DB의 공백 63행은 전부 비판정 이벤트다.
- ~~TLS 부재가 취약점~~ → 기본 바인드 127.0.0.1, `0.0.0.0` 참조 0건. 미착수로 문서화된 항목이지 숨은 취약점이 아니다.