From 02395ac3aa381e4ea3fa7ee123b677d83444f40e Mon Sep 17 00:00:00 2001 From: changukyu Date: Sun, 9 Aug 2026 01:23:21 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=8C=90=EC=A0=95=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=EC=86=8C=EC=8B=A4=20=C2=B7=20=EB=B9=84=EC=83=81=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20=EB=AF=B8=EC=9E=91=EB=8F=99=20=C2=B7=20=EC=83=9D?= =?UTF-8?q?=EC=B2=B4=20=ED=81=AC=EB=A1=AD=20=EB=B3=B4=EC=A1=B4=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=EC=B4=88=EA=B3=BC=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "이 도구가 실제 업무에 도움이 되는가"를 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), 린트 클린. --- docs/INDEX.md | 2 + docs/PROJECT_MAP.md | 3 +- docs/operations/offline-install.md | 23 ++- docs/reviews/2026-08-08-utility-assessment.md | 164 ++++++++++++++++++ src/rights_filter/server/sqlite_store.py | 35 +++- src/rights_filter/server/store_backup.py | 20 ++- src/rights_filter/server/store_enrichment.py | 30 ++++ .../rights_filter/server/test_sqlite_store.py | 65 +++++++ .../server/test_store_workflow.py | 31 ++++ 9 files changed, 363 insertions(+), 10 deletions(-) create mode 100644 docs/reviews/2026-08-08-utility-assessment.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 86538df..863d509 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # Project INDEX +- 2026-08-08 · 기능 실효성 평가(6축 조사 + 축별 적대적 반증, 에이전트 12) 후 P0 4종 수정 — 결론은 **"검색·기록 노동은 줄여주나 심사 건수는 못 줄인다"**(신호조합 224개 전수 시 high 77.7%·low 3.6%, 지배 동인은 match 스택이 아니라 **web entity 누적** — 이미지 매칭 0건이어도 entity 3개면 100/high, 웹에 없는 오리지널도 66/medium). 실수정: **폴더 정리가 판정 기록을 지우던 경로 차단**(판정 완료 가드 + `assetMissing` 1회 마킹; 실증으로 export 1행→0행 확인됨), **비상 차단 스위치 실배선**(`_external_calls_blocked` 신설 → 자동 Naver/CSE/Vision/얼굴크롭 5개 경로; 이전엔 눌러도 외부 호출 5회 동일), **생체 크롭의 보존기간 초과 생존 차단**(스냅샷 제외 + 레거시 아카이브 복구 스킵 — 복구가 mtime을 리셋해 90일을 재시작시켰음), **오프라인 설치 문서의 계약 위반 모델 정정**(0.5b→7b + ollama 바이너리·GPU 전제·거짓 disable 서술). DB 6건은 첫 커밋보다 2주 앞선 구빌드 스냅샷이라 검증 코퍼스가 아님을 확인. **pytest 565 passed**(착수 전 562), 린트 클린 → [docs/reviews/2026-08-08-utility-assessment.md](reviews/2026-08-08-utility-assessment.md) + - 2026-08-08 · 프로젝트 분석 후 인프라·구조·보안 정비 — **원격 백업 복구**(20커밋이 로컬에만 있었음: `main` 2 + 작업브랜치 18 → Forgejo push), **단일 verify 게이트 신설**(`verify.ps1` + `pytest.ini` + `ruff.toml`, ruff 0.12.0 dev 핀; 린트 18건 정리), **`app.js` 5,428줄 → `app-*.js` 15개 분할**(순수 라인범위 추출로 바이트 단위 라운드트립 검증, 로드 순서를 정적 테스트로 고정), **보안 2종 실수정**(SSRF DNS-rebinding TOCTOU — 단일 DNS 해석 후 그 답변으로만 연결하는 `_guarded_create_connection`; 실패 인증 스로틀 — 자격증명 제시 실패만 10회/60초, `429`+`Retry-After`), 7/11 체크리스트 재확인(6건은 이미 수정돼 있던 낡은 항목, Batch 8 은 Batch 6 과 중복이라 정리), `CLAUDE.md`·`docs/PROJECT_MAP.md` 신설. E2E 플레이크(업로드 대기 10초 → `UPLOAD_ANALYSIS_TIMEOUT_MS` 30초) 해소. **pytest 562 passed**(착수 전 533/1 failed), 린트 클린. 실렌더 확인: 큐·케이스 심사 정상 렌더, 스크립트 15개 전부 200, 새로고침 후 콘솔 메시지 0 - 2026-07-24 · 멀티에이전트 프로젝트 점검(에이전트 109, 적대적 검증 포함) — pytest 377/379, 런타임 스모크 9/9 통과, 신규 CONFIRMED 43건(HIGH 7) + 7/11 체크리스트 중 3건 실수정 확인 + 기능 제안 상위 5 선정 → [docs/reviews/2026-07-24-project-review.md](reviews/2026-07-24-project-review.md) diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 762a4db..0adbb51 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -173,5 +173,6 @@ WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore). |---|---|---| | `_perceptual_hash` 가 pHash가 아닌 aHash | `analysis/fingerprints.py:47` | 균일/저디테일 이미지 유사도 오탐. 교체 시 0.9 임계 재보정 필요 | | src 4파일이 800줄 초과 | `store_page_scrape.py`(972) `store_operations.py`(968) `sqlite_store.py`(904) `store_enrichment.py`(853) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 | -| 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증 | +| 점수가 high 로 포화 | `analysis/risk_scoring.py:159-174` | web entity 가 이름별 버킷이라 dedup 없이 `round(40*conf)` 씩 누적 — 이미지 매칭 0건이어도 entity 3개면 100/high. 신호조합 224개 전수 시 high 77.7%·low 3.6%. `test_risk_scoring.py:234-258` 이 이 동작을 고정하고 있어 함께 개정해야 한다 | +| 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 | | 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 | diff --git a/docs/operations/offline-install.md b/docs/operations/offline-install.md index 64bf52f..50a5237 100644 --- a/docs/operations/offline-install.md +++ b/docs/operations/offline-install.md @@ -101,9 +101,17 @@ 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:0.5b-instruct # or your pinned OLLAMA_MODEL +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) @@ -111,11 +119,18 @@ ollama pull qwen2.5:0.5b-instruct # or your pinned OLLAMA_MODEL export OLLAMA_MODELS=/path/to/transferred/models ollama serve & # Smoke test: -ollama run qwen2.5:0.5b-instruct "hello" +ollama run qwen2.5:7b "hello" ``` -Set `OLLAMA_BASE_URL` / `OLLAMA_MODEL` in `.env`. Leave them unset to run without -the LLM (the `llm` provider then reports disabled). +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) diff --git a/docs/reviews/2026-08-08-utility-assessment.md b/docs/reviews/2026-08-08-utility-assessment.md new file mode 100644 index 0000000..5bf479f --- /dev/null +++ b/docs/reviews/2026-08-08-utility-assessment.md @@ -0,0 +1,164 @@ +# 기능 실효성 평가 — "이 도구가 실제 업무에 도움이 되는가" + +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건. 미착수로 문서화된 항목이지 숨은 취약점이 아니다. diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index 3ced6dd..f68daa1 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -376,11 +376,18 @@ class CopyrighterStore( FingerprintService(), HeuristicFacePersonDetector(), ) + # An operator stop (per-provider toggle or the emergency switch) has to + # reach the seed path too — this is where the bulk of the outbound Vision + # traffic originates. + vision_allowed = ( + self.provider_runtime.google_adapter is not None + and not self._external_calls_blocked("google") + ) batch = BatchAnalyzer( repository=repository, internal_analyzer=analyzer, external_adapter=self.provider_runtime.google_adapter or CloudVisionWebDetectionAdapter(FakeWebDetectionClient()), - external_policy=self.provider_runtime.external_policy if self.provider_runtime.google_adapter else ExternalApiPolicy(disabled=True), + external_policy=self.provider_runtime.external_policy if vision_allowed else ExternalApiPolicy(disabled=True), scorer=RiskScorer(), ) submissions = [ @@ -469,13 +476,35 @@ class CopyrighterStore( ) -> int: public_prefix = image_store.public_prefix.rstrip("/") + "/" missing_ids: list[str] = [] + retained: list[dict[str, Any]] = [] for submission in self._all("submissions", queue_id=queue_id): asset = str(submission.get("asset", "")) if not asset.startswith(public_prefix): continue relative_path = asset.removeprefix(public_prefix) - if not image_store.media_path(relative_path).exists(): - missing_ids.append(str(submission["id"])) + if image_store.media_path(relative_path).exists(): + continue + # A decided case IS the record of that decision, and evidence rows + # cascade with the submission. Moving a reviewed image out of the + # queue folder is ordinary operator housekeeping — it must not erase + # the approval / rejection from the DB and from decision exports. + if str(submission.get("decisionStatus", "unreviewed")) != "unreviewed": + if not submission.get("assetMissing"): + retained.append(submission) + continue + missing_ids.append(str(submission["id"])) + + for submission in retained: + # Mark once. This sweep runs on every reload, so an unmarked retain + # would re-emit the same audit event until it filled the log. + submission["assetMissing"] = True + self._put("submissions", submission["id"], submission) + self.add_audit_event( + actor="system", + event="Submission retained", + object_id=str(submission["id"]), + change="source image file missing; decided case kept", + ) if not missing_ids: return 0 diff --git a/src/rights_filter/server/store_backup.py b/src/rights_filter/server/store_backup.py index a3108e7..3e7d9c9 100644 --- a/src/rights_filter/server/store_backup.py +++ b/src/rights_filter/server/store_backup.py @@ -23,10 +23,17 @@ from typing import Any # Store-owned media roots included in a snapshot, as (archive folder, attribute). # Submission source images are handled separately: they live in whatever folder # the active queue points at, which may be outside the data root. +# +# Face crops are deliberately NOT here. They are biometric derivatives under a +# retention deadline (purge_expired_face_crops, default 90 days) and snapshots +# are pruned by count, not by age — so an archived crop outlived the deadline +# inside an unencrypted zip. Restoring one was worse still: the files are +# rewritten, which reset every mtime and started the retention clock over. They +# are regenerable from the source image by re-analysis, so dropping them from +# the snapshot costs nothing that a reload does not rebuild. _MEDIA_SOURCES = ( ("knowledge", "knowledge_image_dir"), ("collections", "collection_image_dir"), - ("face-crops", "face_crop_image_dir"), ) DEFAULT_BACKUP_RETENTION = 10 @@ -185,6 +192,12 @@ def restore_backup(archive_path: Path | str, target_db: Path | str) -> dict[str, if not name.startswith("media/") or name.endswith("/"): continue relative = Path(name).relative_to("media") + if relative.parts[0] in _RETENTION_BOUND_MEDIA: + # Archives written before face crops were excluded still carry + # them. Restoring would re-mint their mtime and hand expired + # biometric data a fresh retention window; re-analysis rebuilds + # the crops if they are still wanted. + continue destination = data_root / _MEDIA_RESTORE_DIRS.get( relative.parts[0], relative.parts[0] ) @@ -197,12 +210,15 @@ def restore_backup(archive_path: Path | str, target_db: Path | str) -> dict[str, return {"database": str(db_path), "mediaFiles": restored_media} +# Archive folders whose contents must never be restored: their retention clock +# is file mtime, which a restore would reset. See _MEDIA_SOURCES. +_RETENTION_BOUND_MEDIA = frozenset({"face-crops"}) + # Archive folder -> path relative to the data root, so a restore lands where the # running server expects to find each media kind. _MEDIA_RESTORE_DIRS = { "knowledge": "knowledge/images", "collections": "collections/images", - "face-crops": "face-crops", # Submission images restore under the data root's default submissions folder. # If the queue pointed elsewhere, re-import that folder after restoring. "submissions": "submissions", diff --git a/src/rights_filter/server/store_enrichment.py b/src/rights_filter/server/store_enrichment.py index 381760a..ec6afa5 100644 --- a/src/rights_filter/server/store_enrichment.py +++ b/src/rights_filter/server/store_enrichment.py @@ -50,6 +50,26 @@ logger = logging.getLogger(__name__) class StoreEnrichmentMixin: + def _external_calls_blocked(self, provider_id: str) -> bool: + """True when an operator deliberately stopped this provider. + + Gate on `operatorDisabled`, not `enabled`: the latter also tracks + connectivity/health, so gating on it would suppress traffic the operator + never asked to stop. `operatorDisabled` is set only by the per-provider + toggle and by the emergency switch, and it persists across reloads. + + Every automatic outbound path must consult this. The emergency switch + used to write the flag and nothing else read it, so pressing it stopped + manual search only while folder reload / upload / rerun-enrichment kept + calling out — a stop button that reported success without stopping + anything is worse than no button at all. + """ + try: + provider = self._get("providers", provider_id) + except KeyError: + return False + return bool(provider.get("operatorDisabled")) + def _sync_provider_payloads(self) -> None: with self._write_lock: existing = {provider["id"]: provider for provider in self._all("providers")} @@ -307,6 +327,8 @@ class StoreEnrichmentMixin: ) -> list[Evidence]: if self.provider_runtime.google_adapter is None: return [] + if self._external_calls_blocked("google"): + return [] original_image = image_store.image_payload(submission_id) derivative = build_external_derivative(original_image) @@ -366,6 +388,10 @@ class StoreEnrichmentMixin: ) -> tuple[list[Evidence], int]: if self.provider_runtime.google_adapter is None: return [], 0 + # Gated again on its own: this path transmits biometric derivatives, so + # it must never depend on a caller upstream having checked. + if self._external_calls_blocked("google"): + return [], 0 if not self.provider_runtime.face_crop_web_detection_enabled: return [], 0 @@ -442,6 +468,8 @@ class StoreEnrichmentMixin: ) -> None: if self.provider_runtime.google_custom_search_adapter is None: return + if self._external_calls_blocked("google_search"): + return if self.provider_runtime.auto_google_custom_query_limit <= 0: return @@ -615,6 +643,8 @@ class StoreEnrichmentMixin: ) -> None: if self.provider_runtime.naver_adapter is None: return + if self._external_calls_blocked("naver"): + return if self.provider_runtime.auto_naver_query_limit <= 0: return diff --git a/tests/rights_filter/server/test_sqlite_store.py b/tests/rights_filter/server/test_sqlite_store.py index b14c7e3..1a520e7 100644 --- a/tests/rights_filter/server/test_sqlite_store.py +++ b/tests/rights_filter/server/test_sqlite_store.py @@ -1011,6 +1011,71 @@ def test_sqlite_store_prunes_submission_records_when_source_file_is_deleted(tmp_ assert conn.execute("select count(*) from evidence").fetchone()[0] == 0 +def test_sqlite_store_keeps_decided_submission_when_source_file_is_deleted(tmp_path: Path): + # Archiving a reviewed image out of the queue folder is ordinary operator + # housekeeping. It used to delete the submission row — and its evidence, via + # the cascade — taking the approval/rejection out of the DB and out of + # decision exports with no warning. + image_root = tmp_path / "submissions" + image_store = _image_store(image_root) + db_path = tmp_path / "copyrighter.sqlite3" + store = CopyrighterStore(db_path) + store.initialize() + store.seed_from_image_store(image_store) + submission_id = str(store.bootstrap()["submissions"][0]["id"]) + store.record_decision(submission_id, "rejected", memo="third-party photo") + + (image_root / "images" / "sample.svg").unlink() + store.seed_from_image_store(LocalSubmissionImageStore(image_root)) + + submissions = store.bootstrap()["submissions"] + assert [str(item["id"]) for item in submissions] == [submission_id] + assert submissions[0]["decisionStatus"] == "rejected" + assert submissions[0]["assetMissing"] is True + assert store.export_decisions(audience="internal")["rows"] + + # The retain notice is written once, not on every reload. + store.seed_from_image_store(LocalSubmissionImageStore(image_root)) + retained_events = [ + event + for event in store.audit_events() + if event.get("event") == "Submission retained" + ] + assert len(retained_events) == 1 + + +def test_sqlite_store_emergency_disable_stops_automatic_external_calls(tmp_path: Path): + # The switch used to write provider flags that no automatic path read, so + # folder reload / rerun-enrichment kept calling out after an operator had + # pressed stop. + transport = FakeJsonTransport({"items": []}) + runtime = build_provider_runtime( + env={ + "NAVER_CLIENT_ID": "naver-id", + "NAVER_CLIENT_SECRET": "naver-secret", + "GOOGLE_CLOUD_VISION_API_KEY": "google-key", + "GOOGLE_CUSTOM_SEARCH_API_KEY": "google-search-key", + "GOOGLE_CUSTOM_SEARCH_CX": "search-engine-id", + }, + transport=transport, + ) + image_root = tmp_path / "submissions" + image_store = _image_store(image_root) + store = CopyrighterStore(tmp_path / "copyrighter.sqlite3", provider_runtime=runtime) + store.initialize() + store.seed_from_image_store(image_store) + assert transport.calls, "seeding should reach the external providers" + + store.emergency_disable_external_providers() + calls_at_stop = len(transport.calls) + + submission_id = str(store.bootstrap()["submissions"][0]["id"]) + store.seed_from_image_store(LocalSubmissionImageStore(image_root)) + store.rerun_enrichment(submission_id, image_store) + + assert len(transport.calls) == calls_at_stop + + def test_sqlite_store_refreshes_existing_local_face_evidence_when_reloaded(tmp_path: Path, monkeypatch): class NoFaceDetector: def detect(self, image): diff --git a/tests/rights_filter/server/test_store_workflow.py b/tests/rights_filter/server/test_store_workflow.py index aa7210f..afe01a9 100644 --- a/tests/rights_filter/server/test_store_workflow.py +++ b/tests/rights_filter/server/test_store_workflow.py @@ -240,6 +240,37 @@ def test_backup_snapshot_round_trips_decisions(tmp_path: Path): assert decided[ids[0]] == "approved" +def test_backup_never_carries_face_crops_past_their_retention_deadline(tmp_path: Path): + # Face crops are biometric derivatives on a 90-day purge. Snapshots prune by + # count, not age, so an archived crop outlived the deadline inside an + # unencrypted zip — and restoring one rewrote the file, resetting the mtime + # the purge measures and handing it a fresh 90 days. + import zipfile + + store = _store(tmp_path) + crop_dir = Path(store.face_crop_image_dir) / "alpha" + crop_dir.mkdir(parents=True, exist_ok=True) + (crop_dir / "face-1.png").write_bytes(b"biometric-derivative") + + snapshot = store.create_backup() + + with zipfile.ZipFile(snapshot["path"]) as archive: + assert [name for name in archive.namelist() if "face-crops" in name] == [] + + # Archives written before the exclusion still carry crops; a restore must + # skip them rather than resurrect them. + legacy = tmp_path / "legacy-snapshot.zip" + with zipfile.ZipFile(snapshot["path"]) as source, zipfile.ZipFile(legacy, "w") as target: + for name in source.namelist(): + target.writestr(name, source.read(name)) + target.writestr("media/face-crops/alpha/face-1.png", b"biometric-derivative") + + restored_db = tmp_path / "restored" / "copyrighter.sqlite3" + restore_backup(legacy, restored_db) + + assert not (restored_db.parent / "face-crops").exists() + + def test_backup_retention_prunes_older_snapshots(tmp_path: Path): store = _store(tmp_path)