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)