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).
This commit is contained in:
changukyu 2026-07-25 23:08:19 +09:00
parent 8b5b3f5cf9
commit 4f140efd9b
30 changed files with 2434 additions and 53 deletions

4
.gitignore vendored
View file

@ -12,6 +12,10 @@ htmlcov/
# Offline dependency wheelhouse (built on staging machine, not committed)
wheelhouse/
# Local model weights (fetched on the build machine, carried across the air gap
# with the wheelhouse — never committed). See docs/operations/offline-install.md.
models/
# Secrets & local environment (keep .env.example)
.env
.env.local

View file

@ -1,4 +1,6 @@
# Project INDEX
- 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)
- 2026-07-25 · 실사용 투입 전 T0 4종 + 임베딩 정책 개정 구현 — (T0-1) `initialRiskScore`/`initialRiskBand`/`scorerVersion` 각인, (T0-2) YuNet 얼굴검출 교체 + 인물 등급(identifiable/background) 도입 및 라운드트립 보존, (T0-3) 판정 시점 피처 스냅샷(라벨 순환 차단), (T0-4) 모델·프롬프트 버전 + `latency_ms` 스탬프. Scene Embedding Policy 신설로 임베딩 금지 범위를 생체 식별로 축소(얼굴 임베딩 금지는 landmark·descriptor 추가로 강화). YuNet 가중치는 `models/`(gitignore) + `offline-install.md`에 sha256 반입 절차. **pytest 502 passed**(착수 전 463). 실측: 6장 기준 검출 378ms(YuNet) vs 654ms(Haar), 2장에서 등급 판정이 갈림
- 2026-07-25 · AI 기능 확장 브레인스토밍(에이전트 14: 5렌즈 발산 30건 → 적대적 실현가능성 검증 6건 REFUTED → 심사 3인 채점) — 1위 「LLM 예산·계측·입력 경계」 7.90(만장일치). 검증 중 실측으로 확인: 판정 라벨 0건·`operatorStatus` 292행 전부 None·밴드 high 4/medium 2/low 0(4건 100점 포화)·`_perceptual_hash`는 pHash가 아닌 8x8 평균해시. 문서의 WAL 미설정 주장은 검증 결과 오류로 정정(이미 적용됨) → [docs/brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md](brainstorms/2026-07-25-ai-capability-expansion-brainstorm.md)
- 2026-07-24 · 위 점검의 전체 remediation + 신규 기능 5종 구현 — HIGH 7·MEDIUM 15·LOW 21 및 F1~F5(키보드 심사·일괄 판정/메모 프리셋·지문 중복 인덱스·판정 내보내기·백업 복구) 완료. cv2 환경 드리프트 복구(OpenCV 5.0.0 제거). **pytest 457 passed**(착수 전 377/2 failed), 실서버 스모크 및 백업 복구 왕복 검증. GUI 스펙의 "일괄 승인/반려 금지(v1)" 제약은 가드레일 조건부로 개정 이력 남기고 해제 → [docs/reviews/2026-07-24-implementation-checklist.md](reviews/2026-07-24-implementation-checklist.md)

View file

@ -0,0 +1,395 @@
# 2026-07-25 AI 기능 확장 브레인스토밍
> 세션: 5렌즈 발산 **30건** → 항목별 적대적 실현가능성 검증(코드·DB 실측 동반, **6건 REFUTED 탈락**) →
> 생존 24건을 심사 3인(운영자·기술리드·제품책임자)이 0~10 채점.
> 기준 HEAD: 8b5b3f5 (fix/2026-07-11-review-remediation). 작성일 2026-07-25.
이 시스템에서 "AI"라고 부를 수 있는 것은 사실상 하나뿐이다 — `analysis/llm_assistance.py`의 Ollama `qwen2.5:0.5b-instruct` 요약이고, 그것마저 `risk_scoring.py`가 `EvidenceSource.LLM_SUMMARY`를 명시적으로 0점 처리해 판정에 기여하지 않는다. 나머지 지능은 전부 손으로 쓴 규칙이다: 검색 쿼리 생성 321줄 템플릿(`analysis/search_query_generation.py`), 위험 점수 174줄 정수 가중치(`analysis/risk_scoring.py`), 2001년식 Haar cascade 얼굴 검출(`analysis/face_person_detection.py`). 이 문서는 "더 큰 모델을 붙이자"가 아니라 **지금 이미 켜져 있는 AI가 조용히 고장나 있는 지점을 먼저 고치고**(프롬프트가 증거의 90%를 잘라먹는 문제, 시드 경로가 쓰기 락을 잡은 채 LLM을 도는 문제), 그 위에 의존성 0짜리 낭독형 신호(EXIF/IPTC 권리 필드, YuNet 검출 신뢰도)를 얹은 뒤, 판정 라벨이 실제로 쌓인 다음에야 학습·자동화로 간다는 3단계 순서를 제안한다. 심사 3인은 각각 다른 렌즈(운영자·기술리드·제품책임자)에서 출발했지만 **1순위가 만장일치로 같았다** — 「LLM 예산·계측·입력 경계」(평균 7.90, 3인 중 최고점 단독 1위).
**이 심사가 준 가장 중요한 신호**: "새 AI 모델을 붙이는" 계열은 전부 하위권으로 내려갔고(의미지문 인덱스 2.70, 판정예측 트리아지 2.30, 워터마크 검출기 4.50), 상위권은 **이미 있는 AI를 정직하게 만드는 것**과 **모델 없이 얻는 신호**(EXIF 권리필드 7.23, YuNet 7.30, 방송캡처 포렌식 6.53)가 차지했다. "AI를 적극 활용한다"의 이 코드베이스에서의 실제 번역은 *더 큰 모델*이 아니라 **① 지금 켜져 있는 LLM이 근거를 실제로 읽게 만들고 ② 모델 없이 얻을 수 있는 신호를 먼저 전부 줍고 ③ 판정 라벨이 쌓인 뒤에 학습을 켠다**는 순서다.
## 현재 AI 표면 진단
| 축 | 현재 구현 | 실제 성격 | 사각지대 |
|---|---|---|---|
| 생성형 LLM | `analysis/llm_assistance.py:22` `OllamaGenerateLlmClient` (qwen2.5:0.5b, temp 0.1, timeout 30s) | 요약 1종. `risk_scoring.py`에서 0점 | `options`에 `num_ctx` 없음 → 증거 대부분이 모델에 도달 전 절단. 저장된 llm 증거 9행에 **`summary` 키 자체가 없다** — 요약 본문이 영속화되지 않아 운영자에게 한 번도 보인 적이 없다 ※ |
> ※ **이 한 줄은 미커밋 작업 트리에서 이미 고쳐지고 있다.** `store_serialization.py`의 `_evidence_payload`에 `summary`/`sourceUrls`가 추가돼 있다(현재 `git diff`에 존재). DB의 9행이 비어 있는 것은 2026-05-26에 생성된 **과거 데이터**다. 다만 이 수정은 문제의 방향을 바꾼다 — 요약이 **드디어 화면에 보이기 시작하므로**, 잘린 증거로 만든 요약의 품질 결함이 이제 운영자 눈앞에 노출된다. 1위 항목(입력 경계)의 시급성은 이 수정으로 **낮아지는 게 아니라 높아진다.**
| 프롬프트 | `llm_assistance.py:133 _evidence_prompt` | `data={item.data}` 딕셔너리 통째 f-string | 제출건당 13,733~62,291자(최대 63행). 근거 링크는 잘리지 않은 전체 목록에서 뽑혀 붙음 |
| 위험 점수 | `analysis/risk_scoring.py` 174줄 | 손튜닝 정수 가중치 + 밴드 70/30 하드코딩 | 학습·캘리브레이션 0. 실측 밴드 분포 high 4 / medium 2 / low 0 — 트리아지 변별력 붕괴 |
| 쿼리 생성 | `analysis/search_query_generation.py` 321줄 | 한/영 템플릿 규칙 + generic 블록리스트 | `plan()`의 `priority`가 API 표면에 안 나옴. `_is_generic_local_query` 필터 결과가 GUI 추천에 반영 안 됨 |
| 로컬 CV(얼굴) | `analysis/face_person_detection.py:_detect_with_opencv` | Haar frontal + profile 2종 | `person_count = face_count` — 사람 축이 사실상 부재. confidence는 `internal_analyzer.py`에서 0.8 하드코딩. 제출 1건당 detect가 **2회** 호출됨(`internal_analyzer.py:78`, `store_enrichment.py:184`) |
| 로컬 CV(지문) | `analysis/fingerprints.py:_perceptual_hash` | 이름은 pHash, 실제는 8x8 average hash | 크롭 한 번에 무력화. 실측: 무관한 제출 이미지 쌍이 이미 0.25~0.781을 냄 |
| 텍스트/로고 | 없음 | — | 이미지 표면에 저작권자가 직접 써 놓은 워터마크·ⓒ를 읽는 경로가 0줄 |
| 원본 메타데이터 | `analysis/preprocessing.py:169-171` | "raw pixel 재구성으로 EXIF/GPS/ICC 전량 파기" | 파기 **전에 읽는 코드가 없음**. `getexif`/`getiptcinfo` src 전역 0건 |
| 사람 라벨 활용 | `risk_scoring.py:15` 하드 스킵 + export 컬럼 1개 | operator_status를 점수에서 빼는 용도만 | 통계로 되먹이는 코드 0줄 |
| 계측·재현성 | 없음 | — | model/prompt/latency 필드가 증거 payload 40여 개 중 0개. `COPYRIGHTER_LLM_DAILY_LIMIT`은 화면 표시용이고 아무것도 막지 않음 |
| 죽은 코드 | `analysis/evidence_enrichment.py:EvidenceEnricher`, `jobs/review_enrichment_job.py` | 테스트에서만 인스턴스화 | 실제 LLM 호출 지점은 `server/store_operations.py:471 _ensure_llm_summary` 한 곳 — 발산 단계 아이디어 3건이 이 죽은 코드를 수정 대상으로 오지목했다 |
**검증 단계에서 확정된 데이터 실측치** (이 문서의 모든 라벨 의존 판단의 근거):
- `submissions` 6건 — **전부 `decision_status='unreviewed'`**. `'Operator decision created'` 감사 이벤트 **0건**.
- `evidence` 292행 — **`operatorStatus` 전부 `None`**. 백업 DB에 `false_positive` 2행이 전 이력.
- `queryStrategy` 유의미 값 0행(None 288 / '' 4). `entity` 0행, `promoted` 0행, `domainSource` 전부 None.
- 즉 **"사람이 만든 라벨과 근거 페어가 이미 축적 중"이라는 전제는 이 레포에서 확인되지 않는다.**
## 제약 조건
1. **폐쇄망 번들** — 런타임 서드파티는 `numpy` / `opencv-python-headless` / `pillow` 3개뿐이고 `requirements.txt` 헤더가 그것을 명시 선언한다. 4번째 의존성 추가(onnxruntime 40~55MB, defusedxml 25KB)는 문서 한 줄 수정이 아니라 **반입 심사 안건**이다. 모델 자산은 별도: 3B Q4_K_M +약 1.9GB, 7B +4.7GB, OCR ONNX 56~72MB(제안된 "12MB"는 약 5배 과소), 롤백 태그 유지 시 +379MiB.
2. **CPU 지연** — 발산 단계 6개 아이디어가 전부 **프리필(prompt eval)을 무시**했다. 생성 속도만 대역폭으로 추정했으나 프리필은 연산 바운드다. 실측: 현행 프롬프트·증거 10건에서 20스레드 5.9초 / 2스레드 20.6초, 증거 20건이면 28.2초 / 72.7초로 `timeout=30`을 확정적으로 초과. 3B Q4는 압축 프롬프트에서도 60~180초. **7B는 현재 근거로 인터랙티브 불가.**
3. **거버넌스** — (a) `governance/policies.py:86 assert_no_biometric_template`은 top-level 키 3개만 보는 얕은 검사지만, 하드 바운더리는 코드가 아니라 사양 문서 두 곳(`docs/operations/image-rights-risk-filter.md:16`, `docs/project-introduction-and-technical-implementation.md:209`)의 "Do not store face embeddings ... or biometric templates" 문구다. 전용 테이블 우회는 코드는 통과해도 사양은 통과하지 못한다. (b) `DataClass.LLM_SUMMARY`의 근거링크 강제는 **현재 형식적으로만 통과 중** — 링크는 전체 증거에서, 요약은 잘린 증거에서 나온다. (c) 신청자 노출 경계: `memo`가 `APPLICANT_EXPORT_FIELDS`에 포함되므로 LLM 출력이 `decision_memo_presets`에 착지하면 신청자에게 그대로 나간다.
4. **판정 주체는 사람** — 프롬프트 계약(`llm_assistance.py:43-49`)에 명시. 개별 판정뿐 아니라 **판정 규칙 생성**도 이 계약에 걸린다(규칙은 판정의 집합 형태).
5. **배관 비용** — 새 `EvidenceSource` 1종을 추가하려면 `records.py` / `_ui_source` / `_ui_group` / `_evidence_payload` / `_domain_evidence_from_ui` / `_domain_source_from_ui` / `risk_scoring` 분기 / `app.js` 3곳 / `store_operations.py:488` LLM 입력 필터 / `operator-labels.js` — **12곳을 한 커밋에** 고쳐야 하고, 라운드트립 점수 보존 테스트가 선행돼야 한다. `_ui_source`의 폴백이 `"fingerprint"`라 누락 시 조용히 오분류된다.
6. **인프라 부재** — `src` 전역에 백그라운드 스레드 0개, 잡 큐·상태 테이블·폴링 엔드포인트 0개. 463개 테스트 중 스레드를 다루는 것 0개. SQLite는 WAL이 아니다(`store_persistence.py:19`). `rerun_enrichment`는 명시적으로 동기다. **"비동기 경로에 태운다"는 경로 변경이 아니라 없는 서브시스템을 만드는 것이다.**
*(정정: 발산·검증 단계에서 "SQLite가 WAL이 아니다"라는 서술이 나왔으나 **사실이 아니다.** `sqlite_store.py:162 initialize()`가 `pragma journal_mode = wal`을 설정하고 이 설정은 DB 파일에 영속되므로 `_connect()`가 매번 다시 걸 필요가 없다 — 실제 `data/copyrighter.sqlite3`도 `wal`을 반환한다. 워커 쓰기가 HTTP 읽기를 블록하는 위험은 **이미 해소돼 있다.**)*
## 후보 전체 (심사 점수순)
심사 3인(운영자 / 기술리드 / 제품책임자)이 각각 독립으로 0~10 채점한 평균. 점수 인플레 금지 조건을 걸어 전체 평균은 4.7이다.
| 순위 | 아이디어 | 렌즈 | 평균 | 운영자 | 기술 | 제품 | 검증 verdict | 공수(원안→수정후) | 오늘 착수 |
|---:|---|---|---:|---:|---:|---:|---|---|---|
| 1 | **LLM 예산·계측·입력 경계** | 비용·쿼터·감사 | **7.90** | 7.5 | 8.2 | 8.0 | **FEASIBLE** | M (입력경계 단독 S) | 가능 |
| 2 | **YuNet 초상권 정밀화** | 로컬 비전 | **7.30** | 7.0 | 7.4 | 7.5 | FWC | M | 가능 |
| 3 | **원본 메타 권리추출** | 로컬 비전 | **7.23** | 7.2 | 7.0 | 7.5 | FWC | S→M (EXIF+IPTC만 S) | 가능 |
| 4 | 방송캡처 포렌식 | 로컬 비전 | 6.53 | 6.8 | 6.8 | 6.0 | FWC | S→M | 가능(0점 라벨) |
| 5 | 모델 번들 무결성 검증 | 폐쇄망 번들링 | 6.10 | 5.0 | 6.8 | 6.5 | FWC | M | 가능 |
| 6 | AI 선연산 잡 큐 | 지연·배치 | 6.07 | 6.2 | 6.0 | 6.0 | FWC | M | 부분(락 이동만) |
| 7 | 근거공백 액션 플래너 | 자동 다음 액션 | 6.00 | 5.8 | 6.2 | 6.0 | FWC | M→**S** | 가능 |
| 8 | 작업별 모델 라우터 | LLM 파이프라인 | 5.73 | 5.5 | 6.2 | 5.5 | FWC | M→L (1단계 S) | 부분 |
| 9 | 판정 골든셋 회귀게이트 | 사람 판정 학습 | 5.63 | 4.5 | 6.4 | 6.0 | FWC | M | 부분(각인만) |
| 10 | 모델·프롬프트 버전 스탬프 | 재현성·감사 | 5.37 | 3.5 | 6.6 | 6.0 | **FEASIBLE** | S | 가능 |
| 11 | 증거 영역 오버레이 | 증거 하이라이트 | 5.33 | 5.0 | 6.0 | 5.0 | FWC | M→L | 좌표계 수정 선행 |
| 12 | 골든 케이스 회귀 하네스 | 오프라인 평가 | 5.03 | 3.8 | 5.8 | 5.5 | FWC | M→**S** | 가능 |
| 13 | 요약 상태 배지(5단→3단) | AI 실패 UX | 4.90 | 4.8 | 5.4 | 4.5 | FWC | M | 가능 |
| 14 | 인용형 증거팩 요약 | LLM 파이프라인 | 4.50 | 4.2 | 4.8 | 4.5 | FWC | M→L | 1위 선행 필수 |
| 15 | 워터마크·ⓒ 검출기 | 로컬 비전 | 4.50 | 4.8 | **3.2** | **5.5** | FWC | L→**XL** | 모델 반입 심사 |
| 16 | 밴드 임계값 스윕·섀도 리스코어 | 사람 판정 학습 | 4.30 | 2.8 | 4.6 | 5.5 | **검증 미수행** | M(원안) | 라벨 대기 |
| 17 | 페이지 권리표기 추출 | LLM 파이프라인 | 3.87 | 4.0 | 3.6 | 4.0 | FWC | L→**XL** | 페치 재설계 선행 |
| 18 | 근거 신뢰도 재보정 | 사람 판정 학습 | 3.57 | 3.2 | 4.0 | 3.5 | FWC | S | 가능(n=0 안전) |
| 19 | 문장별 근거 인용 검증기 | 그라운딩 검증 | 3.03 | 2.3 | 3.8 | 3.0 | FWC | M→L | 1위 선행 필수 |
| 20 | 근거한정 케이스 Q&A | LLM 파이프라인 | 2.97 | 2.5 | 3.4 | 3.0 | FWC | L | 라우터·캐시 선행 |
| 21 | AI 수용률 계기판 | AI 신뢰도 측정 | 2.87 | 2.2 | 3.4 | 3.0 | FWC | M→**S** | 라벨 대기 |
| 22 | 의미지문 인덱스 | 로컬 비전 | 2.70 | 3.0 | 2.6 | 2.5 | FWC | L→**XL** | **거버넌스 승인 필요** |
| 23 | 판정예측 트리아지 레인 | 트리아지 | 2.30 | 2.7 | 2.2 | 2.0 | FWC | L→**XL** | 라벨 대기 |
| 24 | 근거프로파일 일관성 감시 | 사람 판정 학습 | 2.27 | 2.0 | 2.8 | 2.0 | FWC | L | 라벨 대기 |
FWC = FEASIBLE_WITH_CHANGES(수정 조건부 실현가능). 24건 중 verdict가 무조건 FEASIBLE인 것은 1위와 10위 둘뿐이다. 16위는 검증 단계에서 판정이 기록되지 않았다 — **미검증 상태**로 표기하며, 착수 전 별도 검증이 필요하다.
**심사자 의견이 갈린 항목**(참고용 — 착수 결정 시 이 갈림이 곧 리스크다):
- **15위 워터마크·ⓒ 검출기** — 제품 5.5 vs 기술 **3.2**. 제품 렌즈는 "이미지 표면의 저작권 표기를 못 읽는 것이 가장 큰 탐지 공백"으로 보고, 기술 렌즈는 실측 템플릿 매칭 2,347ms/장과 로고 수에 선형인 비용을 근거로 반대했다.
- **10위 모델·프롬프트 버전 스탬프** — 기술 6.6 vs 운영자 **3.5**. 운영자에게는 화면에 글씨 하나 느는 일이고, 기술 렌즈에는 이후 모든 모델 변경 판단의 전제다.
- **12위 골든 케이스 회귀 하네스 / 9위 판정 골든셋 회귀게이트** — 두 항목 모두 기술·제품 대비 운영자 점수가 1.5~2.0점 낮다. 품질 기반 작업의 전형적 패턴으로, 운영자 체감이 0이라는 뜻이지 가치가 없다는 뜻은 아니다.
## 상위 후보 상세
> 배열 순서는 **점수순이 아니라 착수 순서**(선행조건 의존 관계)다. 각 제목에 심사 순위와 평균을 함께 표기했다.
> 점수 상위권 중 이 절에 상세가 없는 3건(4위 방송캡처 포렌식, 5위 모델 번들 무결성 검증, 8위 작업별 모델 라우터)은
> 바로 아래 「상위권 중 상세 미수록 3건」에 요약했고, 로드맵 2단계에 항목으로 들어가 있다.
### 1. LLM 예산·계측·입력 경계 (**심사 1위 · 평균 7.90 · 1순위 만장일치 3/3**, 공수 M → 입력경계 단독 S)
**무엇을** — 세 가지 묶음이지만 **입력 경계만 떼어 먼저 낸다**(검증자 권고). (a) `_evidence_prompt`가 `data` 딕셔너리를 통째로 f-string에 박는 것을 화이트리스트(`reason`/`match`/`similarity`/`page_title`/`domain`/`query`)로 교체, (b) 증거 상한(위험 기여 상위 12건), (c) `options`에 `num_ctx` 명시. 이어서 별건으로 예산 집행(LlmCallPolicy)과 계측(p95 링버퍼).
**왜** — 지연 감소가 아니라 **거버넌스 결함 수정**이다. 프롬프트는 잘리는데 `source_urls`/`source_evidence_ids`는 잘리지 않은 전체 증거에서 뽑혀 붙는다. 결과적으로 `assert_operator_evidence_payload_allowed(LLM_SUMMARY)`는 통과하지만 화면의 "연결 근거 N건"에서 N은 거짓이다. 모델이 본 적 없는 근거를 근거라고 표시하는 상태이며, 다른 23건 중 어느 것도 이 구멍을 닫지 않는다.
**AI 구성요소·모델·지연** — 모델 변경 없음(qwen2.5:0.5b 유지). 추론 추가 0. **24건 중 유일하게 지연을 줄인다**: 실측 20.6초 → 1.5~8.3초(중앙값 2~3초). 부수 효과로 축약 프롬프트에서 모델이 한국어 2단('요약:'/'확인 필요:') 형식을 지키기 시작했다(현행 프롬프트에서는 형식·언어 계약을 전부 무시하고 영어로 답했다).
**코드 근거** — `llm_assistance.py:133 _evidence_prompt`(data 통째 덤프) · `llm_assistance.py:52-53`(options에 temperature만) · `env_clients.py:57`(`COPYRIGHTER_LLM_DAILY_LIMIT`이 파싱만 되고 naver/google과 달리 `daily_limit=`으로 배선되지 않음) · `store_persistence.py:231-246 _apply_provider_usage_delta`(usage 누적만, 상한 대조 없음) · `store_operations.py:471 _ensure_llm_summary`(실제 유일한 호출 지점).
**폐쇄망 번들** — 증분 0바이트, 신규 의존성 0, 스키마 마이그레이션 0, 새 EvidenceSource 0. 텔레메트리 송출 없음(전부 로컬 SQLite).
**가드레일** — `face_crop_search`/`crop_index`/`privacy_note`/`thumbnail_url`을 모델 입력에서 제외해 초상 경계를 프롬프트 레벨에서도 지킨다(`assert_no_biometric_template`과 정합). 쿼터 소진은 오류가 아니라 강등(`SEARCH_SKIPPED` 처리 방식과 동일).
**검증자가 지적한 조건** — (1) 세 가지를 한 커밋에 묶지 말 것. (2) 화이트리스트만으로 부족 — 축약해도 56건이면 3,000토큰대라 **증거 상한이 필수**이고, `num_ctx`만 올리고 상한을 안 두면 2스레드 기준 프리필에만 약 17초가 들어 **순증가**한다. (3) `promotion_reason`은 화이트리스트에서 뺀다 — 모델이 그대로 베껴 요약이 증거 목록 복붙이 된다(실측 출력이 정확히 그랬다). (4) 예산 카운터의 진실원은 `ExternalApiPolicy` 복제가 아니라 SQLite provider payload로 — 복제하면 프로세스 메모리 카운터라 재시작 시 0으로 리셋돼 화면 usage와 집행 기준이 갈라진다. (5) p95 표본은 최근 200건 링버퍼로 상한 명시.
**함께 넣을 것** — 심사자 3인이 공통 지목한 5줄 수술: `_seed_from_image_store_locked`(`sqlite_store.py:310`) 안의 `_ensure_llm_summaries_for_existing_source_evidence` 호출을 `_write_lock` **밖으로** 이동. `record_decision`도 같은 락을 쓰므로, 지금은 폴더 재적재 중에 운영자가 판정 버튼을 눌러도 락 대기에 걸린다. `store_operations.py:457-458`이 이미 "락 밖에서 LLM 호출" 선례를 갖고 있다.
### 2. 원본 메타 권리추출 (**심사 3위 · 평균 7.23**, 공수 S→M / EXIF+IPTC만이면 S)
**무엇을** — 외부 전송용 파생본을 만들며 메타데이터를 파기하기 **전에**, 원본 바이트에서 권리 필드만 화이트리스트로 읽어 증거로 만든다. EXIF `0x8298 Copyright` / `0x013B Artist` + IPTC `CopyrightNotice`(2:116)/`Credit`(2:110)/`Source`(2:115).
**왜** — 저작권자가 파일 안에 직접 써 넣은 정답을 지금 100% 버리고 있다. 추론이 아니라 낭독이라 오탐 가능성이 사실상 없고, 히트하면 외부 API 왕복(수 초 + 쿼터) 없이 판정 근거가 선다. 미스하면 섹션이 아예 안 뜬다 — **리스크 구조가 이 목록에서 가장 깨끗하다**(운영자 심사자 평).
**AI 구성요소·모델·지연** — AI 모델 없음(컨테이너 파싱). 실측 0.1ms 미만/장. 픽셀 기반 AI-생성 판별기는 의도적으로 배제 — CPU/폐쇄망에서 도메인 시프트에 취약하고 반증 가능성이 없다.
**코드 근거** — `preprocessing.py:169-171 _upright_derivative`("Rebuild from raw pixels so NO metadata survives") · `preprocessing.py:build_external_derivative` · `image_store.py:60-68`(metadata가 submission_id/title/file/format 4개뿐) · `governance/policies.py:82 policy_for`(미등록 DataClass는 KeyError → 신규 `RIGHTS_METADATA` 등록 강제).
**폐쇄망 번들** — EXIF+IPTC는 pillow 12.2.0에 이미 포함, 증분 0. **단 XMP는 폐쇄망에서 조용히 죽는다**: Pillow의 `Image.getxmp()`는 `defusedxml`이 없으면 경고와 함께 빈 dict를 반환하는데(직접 차단 실측), 이 개발 머신의 defusedxml은 anaconda/nbconvert/Scrapy가 끌고 온 것이고 `requirements.txt`에는 없다. **납품 타깃에서 XMP `dc:rights`·`digitalSourceType`·C2PA 선언 탐지는 전부 no-op**이 된다.
**가드레일** — GPS·카메라 시리얼·소유자명·XMP-mwg-rs Face regions는 읽지도 저장하지도 않는다(화이트리스트를 코드 상수로 두고 테스트로 고정). 메타데이터는 위조 가능하므로 "자기신고 정보"로 라벨링하고 자동 반려하지 않는다.
**검증자가 지적한 조건** — (1) XMP/C2PA는 `defusedxml` 반입 안건으로 **분리**하고, 추가 전까지는 "이 배포에서는 XMP 미판독"을 기동 로그에 명시. (2) **스톡 자산 ID +70(단독 high)은 논리적으로 틀렸다** — Getty/Adobe 자산 ID는 정품 라이선스 구매본에도 그대로 박혀 배포된다. 올바른 귀결은 반려가 아니라 "라이선스 증빙 요구"이므로 +30(medium)으로 낮춘다. (3) recall이 낮다 — 카카오톡/인스타/트위터를 거친 이미지는 메타가 이미 제거돼 있어 히트율이 한 자릿수~십몇 %일 가능성. 따라서 **GUI 섹션은 추출값이 하나라도 있을 때만 렌더**(빈 섹션 금지). (4) 추출 훅은 `InternalAnalyzer.analyze` 진입부(원본 `ImagePayload.content`가 살아 있는 지점)에 둔다 — 파이프라인 순서 변경 불필요. (5) `.svg`가 `SUPPORTED_IMAGE_SUFFIXES`에 있으나 Pillow가 못 여니 추출 실패를 예외가 아니라 "메타 없음"으로 degrade.
### 3. YuNet 초상권 정밀화 (**심사 2위 · 평균 7.30**, 공수 M)
**무엇을** — Haar cascade를 `cv2.FaceDetectorYN`(YuNet)로 교체하고 검출 신뢰도를 실제 값으로 채운다. 얼굴 면적 비율 + 검출 수로 "식별 가능한 근접 인물"과 "배경 군중"을 등급 분리. `FacePersonSignal` 인터페이스와 `face_boxes` 계약은 유지해 크롭·얼굴 웹탐지 경로 무변경.
**왜** — 두 방향 동시 이득. 놓침: 뒷모습·마스크·군중·저조도 인물이 지금 전부 신호 0. 낭비: Haar 오탐 박스가 `+35`와 face_crop 파일을 소비한다. 그리고 **밴드 분포가 이미 붕괴했다**(실측 high 4 / medium 2 / low 0) — `FACE_PERSON +35`가 사람 있는 사진 전부에 붙어 medium 문턱을 혼자 넘기기 때문이다. 등급화는 트리아지 변별력을 직접 회복시킨다.
**AI 구성요소·모델·지연** — YuNet ONNX 약 230~345KB, CPU 640x480 약 10ms. **현재보다 빨라진다**: 실측 Haar 2캐스케이드 1920x1080 = 218ms/호출 × 건당 2회 = 약 440ms → YuNet 약 20ms. **순감 약 0.4초/장.**
**코드 근거** — `face_person_detection.py:_detect_with_opencv`(scaleFactor 1.1 / minNeighbors 4 고정) · `face_person_detection.py:103`(`person_count = face_count`) · `face_person_detection.py:57-67`(OpenCV 5.x가 CascadeClassifier를 제거한다는 경고가 이미 코드에 있음 — 이 교체가 그 미래 호환 문제까지 해소) · `internal_analyzer.py:78`과 `store_enrichment.py:184`(detect 중복 호출) · `internal_analyzer.py`(confidence 0.8 하드코딩).
**폐쇄망 번들** — 파이썬 의존성 0(설치본 opencv-python-headless 4.13.0에 `cv2.FaceDetectorYN` 존재를 직접 확인). 모델 자산 345KB로 사실상 무시 가능.
**가드레일** — YuNet이 반환하는 5점 랜드마크는 **읽는 즉시 폐기**하고 저장하지 않는다. 나아가 `assert_no_biometric_template` 금지 키에 `landmarks`/`face_landmarks`/`face_descriptor` 추가(호출부가 2곳뿐이라 기존 데이터 로드 무영향 — 정책 강화 방향, 무충돌).
**검증자가 지적한 조건** — (1) **사람 검출기(YOLOv8n/MobileNet-SSD)는 별건으로 분리**. cv2.dnn으로 YOLOv8 ONNX를 쓰려면 NMS를 직접 구현해야 하고 저사양 CPU에서 0.3~1.5s/장이다. YuNet 345KB/10ms와 같은 묶음으로 취급하면 안 된다. (2) 같은 커밋에서 `detect()` **중복 호출을 제거**한다. (3) `tier`(등급 라벨)와 실제 신뢰도를 `_evidence_payload`와 `_domain_evidence_from_ui` **양쪽**에 추가하고 라운드트립 점수 보존 테스트를 먼저 작성 — 안 하면 재채점 때마다 전 건이 +35로 복귀한다. (4) 등급화는 기존 medium 다수를 low로 이동시켜 `decision_stats`의 캘리브레이션 기준선을 리셋한다. **판정 0건인 지금이 이 비용을 치르기 가장 싼 시점**이다(나중이면 판정 이력과 기준선이 어긋난다).
### 4. 판정 골든셋 회귀게이트 (심사 9위 · 평균 5.63 — 운영자 4.5 vs 기술 6.4, 공수 M / **각인 부분만 오늘 즉시**)
**무엇을** — 원안은 결정 케이스를 evidence 스냅샷과 함께 내보내 verify에서 재채점하는 하네스. 검증 결과 **원안의 절반은 오늘 성립하지 않고, 나머지 절반은 오늘 당장 실효가 있다.**
**왜 (오늘 유효한 절반)** — 검증자가 24건 전체에서 "가장 실효가 큰 한 줄짜리 지적"으로 꼽은 것: `_rescore_submission`이 12곳에서 `riskScore`를 덮어쓰므로 **저장된 점수는 이미 왕복 점수**다. 그것을 다시 왕복 채점과 비교하면 항상 일치한다. 즉 각인 없이 만든 점수 재현 테스트는 **항상 통과하는 빈 테스트**다. 막으려는 실제 버그(`search_image` 왕복 뒤집힘, `risk_scoring.py:105-112` 주석)는 원본 채점 vs 왕복 채점의 차이인데 원본 점수가 어디에도 저장되지 않는다.
**AI 구성요소·모델·지연** — 모델 없음(평가 하네스). verify 단계에서만 도는 오프라인 하네스라 운영자 체감 0. 실측 1000건 전량 재채점 0.3초 미만.
**코드 근거** — `store_search_candidates.py:721-731 _rescore_submission` 및 호출 12곳(`sqlite_store.py:309,406,743` / `store_enrichment.py:125,167,298,358` / `store_operations.py:324,385,462` / `store_search_candidates.py:160`) · `store_serialization.py:124`(`fileFacts.analysis`가 리터럴 'v1', `AnalysisRun.analysis_version`은 submission payload에 각인되지 않음) · `store_serialization.py:271-275`(라운드트립 불변식이 주석으로만 존재).
**폐쇄망 번들** — 신규 의존성 0. **비용은 번들이 아니라 반출 리스크**: 실측 evidence 평균 632바이트 × 케이스당 약 49행 = 약 31KB/건, 1000건이면 약 31MB짜리 파일이 result URL·pageTitle·thumbnailUrl·내부 riskScore를 통째로 담는다.
**가드레일** — 골든셋은 `APPLICANT_EXPORT_FIELDS` 경로와 **물리적으로 다른 모듈**에 두고, `.gitignore` + 납품 번들 생성 스크립트 제외 목록 + `offline-install.md`에 3중 명시.
**검증자가 지적한 조건** — (1) **선행 필수**: `initialRiskScore` / `initialRiskBand` / `scorerVersion`을 `_submission_payload`(`store_serialization.py:106`)에 추가하고 `_rescore_submission`이 절대 덮어쓰지 않게 한다. (2) 골든셋 생성에 `export_decisions`를 재사용하지 말 것 — 호출마다 'Decisions exported' 감사이벤트를 남기고 `audit_events()`는 최신 500행으로 잘리므로 verify가 돌 때마다 운영자 감사 화면이 노이즈로 밀려난다. (3) 지표 회귀는 CI 실패가 아니라 **차이 리포트 출력만** — `highBandApprovalRate`는 코드 변경뿐 아니라 케이스 믹스 변화로도 움직인다. (4) 판정 100건 미만이면 계산하지 않는다. (5) 왕복 복원의 취약 지점(`weak_hint`가 저장되지 않고 제목 접두사 'Best guess label ' 문자열로 재구성됨, `domainSource` 미기록 레거시 행)을 스키마에 명시 필드로 굳혀 제목 문자열 의존을 끊는다. (6) **판정 0건이어도 (1)+(5)만으로 오늘 즉시 가치가 있다** — 라벨 없이 실제 버그를 잡을 수 있는 유일한 학습계 항목.
### 5. 모델·프롬프트 버전 스탬프 (심사 10위 · 평균 5.37 — 기술 6.6 vs 운영자 3.5, 공수 S · **verdict FEASIBLE**)
**무엇을** — 프롬프트 리터럴을 `analysis/llm_prompts.py`로 분리하고 `PROMPT_ID`/`PROMPT_VERSION` 부여. `/api/generate` 응답의 model과 `/api/tags`의 digest를 Evidence.data에 심고, `_evidence_payload`에 `modelVersion`/`promptVersion`/`latencyMs`를 추가해 GUI에 "모델 qwen2.5:0.5b · 프롬프트 ko-v2 · 1.4s"를 작은 글씨로 노출.
**왜** — 재현성 필드가 코드 전체에 **단 하나도 없다**. 모델을 올리거나 프롬프트를 고친 뒤 과거 요약과 신규 요약이 DB에 섞여도 구분이 불가능하다. 모델을 바꾸자는 모든 후속 제안이 이 스탬프 없이는 전후 비교를 못 한다. 부수 효과로 `latency_ms`가 쌓여 **운영자 대기시간 실측치가 처음으로 생긴다**.
**AI 구성요소·모델·지연** — 모델 변경 없음. 응답 메타데이터 파싱만. 추론 증가 0, `/api/tags`는 프로세스당 1회 수십 ms.
**코드 근거** — `llm_assistance.py:38-61 summarize_evidence`(모델명을 보내지만 응답의 model을 `_response_text:143-154`가 버림) · `llm_assistance.py:41-50`(버전 없는 시스템 프롬프트 리터럴) · `store_serialization.py:256-322 _evidence_payload`(40여 필드 중 모델/프롬프트 0개) · `store_serialization.py:428-430 _evidence_id`.
**폐쇄망 번들** — 증분 0. 프롬프트 파일도 코드 동봉.
**가드레일** — 스탬프는 메타데이터일 뿐 판정 무영향(LLM_SUMMARY 0점 유지). 모델명은 admin 전용 `PROVIDER_METADATA` 정책과 정합하게 노출.
**검증자가 지적한 조건** — (1) **id 해시 오염 주의**: `_evidence_id`는 data 전체의 sha256이라 `latency_ms`/`generated_at`을 data에 넣으면 버전이 바뀔 때가 아니라 **매 호출마다** id가 바뀐다. `model`/`model_digest`/`prompt_version`만 data에, 시간 관련 필드는 `_evidence_payload` 전용으로 분리. (2) digest는 `/api/generate` 응답에 없다 — `/api/tags`에만 있고(실측 `a8b0c51577010a27...`, 397,821,319바이트, Q4_K_M) 그것도 blob 해시가 아니라 매니페스트 sha256이므로 "매니페스트 digest"라고 정직하게 명명. (3) 정책 필수화는 신규 생성 경로에만 적용하고 legacy 행은 `prompt_version='legacy'`로 1회 마이그레이션. (4) 반환 타입을 dataclass로 바꾸면 `FakeInternalLlmClient`와 기존 테스트 3건이 함께 바뀐다.
### 6. 근거공백 액션 플래너 (심사 7위 · 평균 6.00, 공수 M→**S**)
**무엇을** — 원안은 신규 엔드포인트 + LLM 재랭킹이었으나 **검증 결과 둘 다 제거**되고, 서버 `plan()` 결과를 `(query, strategy, source, priority)`로 기존 `review()` 응답에 실어 보내고 `_is_generic_local_query`가 걸러낸 쿼리를 화면에서 숨기는 것으로 축소됐다.
**왜** — 파일명이 `IMG_2831`인 케이스에 GUI가 추천하는 쿼리는 `IMG_2831 저작권`이다. 실행 버튼이 그대로 노출되어 운영자가 헛검색을 돌리고 외부 쿼터를 태운다. `_is_generic_local_query`의 정규식 `(img|image|photo|picture|portrait|screenshot|download|copy|scan|file)[\s_-]*\d*`가 이 계열을 정확히 잡는데, 그 필터가 서버에만 있고 GUI 추천에는 반영되지 않는다.
**AI 구성요소·모델·지연** — **LLM 없음**(검증자 권고로 재랭킹 제거). `plan()`은 순수 정규식으로 μs 단위, `review()` 응답에 얹으면 체감 0.
**코드 근거** — `search_query_generation.py:19 plan()` · `search_query_generation.py:295 _is_generic_local_query` · `evidence-guidance.js:suggestedEvidenceQueries`(서버와 무관하게 seed+['저작권','공식','이미지 출처'] 템플릿) · `app.js:1083 renderEvidenceNextActions` · `store_serialization.py:285`(`query_priority`가 data엔 들어가지만 `_evidence_payload`가 매핑 안 함).
**폐쇄망 번들** — 증분 0.
**가드레일** — 추천 쿼리 실행은 여전히 운영자 클릭이 필요하고 `SearchApiPolicy` 쿼터를 그대로 통과.
**검증자가 지적한 조건** — (1) 원안의 "`plan()`의 strategy가 API 표면에 나온 적 없다"는 **사실이 아니다** — `store_enrichment.py:447,620`이 이미 `plan()`을 호출하고 `queryStrategy`로 직렬화해 `app.js:1886`이 화면에 그린다. 실제로 빠진 것은 `priority` 하나뿐이다. (2) 인용된 `evidence_enrichment.py:58 enrich_latest`는 **죽은 코드**다. (3) `knowledge_entries`를 `plan()`에 넘기면 오히려 나빠진다 — 관련성 검사 없이 활성 엔트리 전부에 대해 priority 70/68 후보를 만들어 로컬 메타(54)를 밀어낸다. 프로덕션 두 호출부가 모두 `[]`를 넘기는 것은 우연이 아니다. (4) LLM 재랭킹은 후보 총량이 애초에 3~5개라 정보 이득이 사실상 0인 전형적 과설계. (5) 폴백 전제가 거짓 — `env_clients._llm_assistant()`는 도달성 검사 없이 무조건 어시스턴트를 반환하므로 `llm_assistant is None` 분기는 **절대 실행되지 않는다**.
### 7. 골든 케이스 회귀 하네스 (심사 12위 · 평균 5.03 — 운영자 3.8, 공수 M→**S**)
**무엇을** — 원안 3축 중 **축(2) 라운드트립만** 남기되 케이스 고정이 아니라 **속성 기반 테스트**로 만든다: `match`/`promoted`/`weak_hint`/`domainSource`/`knowledge_entry_type`/`similarity` 조합을 열거해 `_evidence_payload → _domain_evidence_from_ui → RiskScorer` 점수 보존을 전수 검사.
**왜** — `risk_scoring.py`와 `store_serialization.py` 주석이 기록한 실제 회귀 이력이 전부 이 조합에서 나왔다(FACE_PERSON 중복 +35, naver 중복 +40, search_image 왕복 0점→+45). 고정 케이스 몇 개로는 조합 폭발을 못 덮는다.
**AI 구성요소·모델·지연** — 모델 없음. 평시 추론 0초, 밀리초 단위.
**코드 근거** — `risk_scoring.py:7-99`(밴드 임계 70/30 하드코딩, 캘리브레이션 테스트 없음) · `tests/rights_filter/analysis/test_llm_assistance.py`(전체 47줄, FakeInternalLlmClient만 사용 — 실제 모델 출력 회귀 0건) · `store_serialization.py:271-275`(불변식이 주석으로만 존재).
**폐쇄망 번들** — 증분 0(pytest는 requirements-dev에 이미 있음). 픽스처는 합성 JSON 수십 KB.
**가드레일** — 픽스처는 운영 DB 익명화 추출이 **아니라 합성**한다 — 점수 기여 키만 필요하고 실제 URL·블로거명은 필요 없다. 원안의 "이미지가 안 들어가니 안전"은 틀렸다(evidence payload에 `bloggerName`/`bloggerLink`/`pageTitle`/`url`이 그대로 들어간다).
**검증자가 지적한 조건** — (1) 축(1) 밴드 정확도는 **정답 라벨의 출처가 없다** — 사람이 기대 밴드를 손으로 적으면 "손튜닝 가중치를 손으로 만든 기대값에 맞추는" 동어반복. "정확도 임계"가 아니라 **"가중치 변경 시 전 제출건 밴드 변동 diff"**로 바꾼다. (2) 축(3) 환각률은 14번(인용 검증기)이 안정화되기 전에는 만들지 않고, 만들 때도 `test_llm_output_snapshot`으로 정직하게 명명한다(녹화 재생은 환각률 회귀가 아니라 문자열 diff이고, 실측 편차가 임계 0.1보다 크다).
### 8. AI 선연산 잡 큐 (심사 6위 · 평균 6.07, 공수 M / **락 이동 5줄만 오늘 즉시**)
**무엇을** — 원안은 `ai_jobs` 테이블 + 백그라운드 워커. 검증 결과 **"잡 큐보다 먼저 5줄로 얻을 것을 얻어라"**로 축소 권고: 시드의 LLM 순회를 `_write_lock` 밖으로 옮기고 시드 1회당 처리 건수 상한(예: 20건)을 둔다.
**왜** — 현재 `seed_from_image_store`가 `_write_lock`을 잡은 채 제출건마다 Ollama를 부르고, `record_decision`도 같은 락을 쓴다. 즉 **폴더 재적재 중에는 판정 버튼이 락 대기에 걸린다.** 실측 1건당 20.6초 기준 제출 100건이면 약 34분간 모든 쓰기가 잠긴다. 게다가 Ollama가 아예 없어도 비용이 든다 — 이 Windows 환경 실측에서 127.0.0.1:11434 연결은 즉시 refuse가 아니라 2초 타임아웃이었다(100건 × 2초 = 200초 락 점유).
**AI 구성요소·모델·지연** — 모델 변경 없음. 바뀌는 것은 추론의 위치와 시점.
**코드 근거** — `sqlite_store.py:293` `seed_from_image_store`가 `_write_lock` 획득 → `:310` LLM 순회 · `sqlite_store.py:688 record_decision`이 같은 락 · `store_operations.py:457-458`(수동 검색은 이미 "락 밖에서 호출"을 명시 — 같은 레포에 상반된 두 패턴) · `store_serialization.py:220 _provider_item_has_result`(source=='failure'에 False를 반환해 `only_if_missing`이 실패 행을 "요약 없음"으로 봄 → **매 reload마다 실패 전건 무한 재시도**) · `store_persistence.py:19 _connect()`(WAL 미설정).
**폐쇄망 번들** — 증분 0(threading + sqlite3 stdlib). 새 `ai_jobs` 테이블은 `store_schema.py` DDL + `store_constants.py:18 STORE_TABLES` 등록 필요(`store_serialization.py:480 _validate_table`이 미등록 테이블을 차단). 백업은 `Connection.backup`이 파일 전체를 뜨므로 자동 포함.
**가드레일** — 워커는 생성(HTTP) 구간을 반드시 락 밖에서 수행하고 저장 순간에만 락을 잡는다. '생성 중' 상태가 판정 버튼을 막지 않는다.
**검증자가 지적한 조건** — (1) **1번(프롬프트 축소)이 선행이다** — 축소 없이 잡 큐만 넣으면 '생성 중' 칩이 영구히 붙어 있는 화면이 된다. (2) 실패 백오프 추가(현재는 매 reload마다 전건 재시도). (3) `env_clients._llm_assistant()`에 도달성 프로브 추가 — 여러 아이디어가 "None이면 폴백"을 전제하는데 그 전제가 현재 거짓이다. (4) ~~`_connect()`에 `journal_mode=WAL` 추가~~ — **이 조건은 취소한다. 확인 결과 WAL은 이미 켜져 있다**(`sqlite_store.py:162 initialize()`, DB 파일에 영속). `store_backup.py` docstring이 WAL을 전제로 서술한 것도 정확했다. 워커 쓰기가 HTTP 읽기를 블록하는 위험은 존재하지 않는다. (5) 워커는 `run_once()`를 노출해 스레드 없이 테스트 가능하게 하고, 부팅 시 `running→pending` 리셋을 넣는다.
### 상위권 중 상세 미수록 3건 (요약)
**4위 · 방송캡처 포렌식 (평균 6.53, 공수 S→M)** — 방송 화면·앱 스크린샷을 결정적 영상처리(휘도 투영 프로파일, Canny 엣지 밀도, HSV 채도, 히스토그램 균일도)만으로 판별해 라벨을 붙인다. **ML 모델 0, 의존성 0, 실측 2ms/장.** 지금 코드에 캡처를 식별하는 줄이 하나도 없고, 오히려 `_is_generic_best_guess_label`이 'screenshot'을 블록리스트로 버린다. 심사 3인이 공통으로 "관측 가능성이 로컬비전 6건 중 최고"(오탐 원인이 사람이 읽는 규칙으로 남는다)라고 평가했다. 조건: **1차는 반드시 0점 라벨로 출시**하고 `decision_stats()` 교차표로 실제 반려와의 상관을 본 뒤 점수를 부여할 것. 레터박스·하단 텍스트밴드는 시네마틱 크롭·밈·PPT 캡처와 구분되지 않으므로 "서로 독립인 카테고리 2개 이상"을 검출 조건으로 둘 것. → 로드맵 2-f.
**5위 · 모델 번들 무결성 검증 (평균 6.10, 공수 M)** — `/api/tags` 응답 기반 `models.lock.json` + **비차단** 프리플라이트 + `enabled`와 분리된 `modelReady` 필드. 핵심 발견: `env_clients._llm_assistant()`가 **도달성 검사 없이 무조건 어시스턴트를 반환**해 llm provider가 항상 `enabled=True`이고, `offline-install.md`는 정반대로 서술한다. 즉 **provider 패널이 지금 거짓말을 하고 있다.** 이 산출식 하나를 고치면 다른 4건이 공통으로 깔고 있는 "LLM이 없으면 폴백" 거짓 전제까지 한 번에 참이 된다. 조건: 프리플라이트가 기동을 막으면 안 되고(DROP 방화벽에서 기동 정지), 운영자 긴급 비활성 토글인 `enabled`를 자동 강등이 덮어써서도 안 된다. → 로드맵 1-f + 2-b.
**8위 · 작업별 모델 라우터 (평균 5.73, 공수 M→L / 1단계 S)** — 태스크별 Ollama 파라미터 프로필과 모델 등급 분리(0.5B 분류 / 3B 주력 / 7B 옵션). 심사 3인이 공통으로 "**태스크가 문자 그대로 하나뿐인 지금 라우터는 전형적 선행 추상화**"라고 감점했고, 동시에 그 1단계(`num_ctx`/`num_predict`/`keep_alive`/`timeout`을 환경변수로 노출 + 시드 LLM 호출을 `_write_lock` 밖으로)는 "최우선급 수술"로 평가했다. **결론: 라우터가 아니라 1단계만 떼어내 쓴다**(로드맵 1-b·2-d). 7B(+4.7GB)는 1단계 실측 tok/s가 나오기 전 번들 반입 금지.
## 반증되어 제외된 것
### 통째로 REFUTED — 6건 (심사 대상에서 제외)
발산 30건 중 6건이 적대적 검증에서 전제 자체가 무너져 심사에 올라가지 못했다. **이 6건은 "나중에 하자"가 아니라 "이 형태로는 하지 말자"이다.**
| 탈락 아이디어 | 렌즈 | 무너진 전제 |
|---|---|---|
| **LLM 쿼리 리라이터** | LLM 파이프라인 | 입력으로 쓰겠다는 지식엔트리가 파이프라인에 안 들어온다 — 프로덕션 두 호출부가 `plan(evidence, [], ...)`로 **빈 리스트**를 넘긴다(`store_enrichment.py:447,620`). 그런데 `plan()`은 이미 `entry.aliases`로 별칭 쿼리를 만든다. **별칭 확장 기계는 이미 있고 입력선만 끊겨 있다** — 한 줄 수정이 LLM보다 먼저다. 또 가드레일("입력 토큰 1개 이상 공유")과 가치(활동명↔본명·로마자 = 입력에 없는 외부 지식)가 서로를 무효화한다 |
| **반려 메모 초안기** | LLM 파이프라인 | 코퍼스가 존재하지 않는다(판정 메모 0건). 그리고 `memo`는 `APPLICANT_EXPORT_FIELDS`에 포함돼 **LLM 생성 텍스트가 신청자에게 나가는 경로를 새로 여는 유일한 아이디어**다. 반려 사유는 분쟁 시 문서 증거다. 효용도 역전 — 3B 초안 대기 40~90초 vs 수작업 타이핑 30~90초 |
| **로컬 VLM 쿼리 시드** | 로컬 비전 | "배치라 대기 경로 밖"이 거짓(시드는 `_write_lock` 안 동기 POST). 환각이 점수로 새는 경로 2개 실재 — VLM priority 58이 유료 쿼터를 잠식하고, 헛쿼리 검색 실패는 `FAILURE +30`으로 **환각이 곧바로 위험점수를 올린다**. 지연도 과소추정(이미지 프리필만 15~50초 > `timeout=30`) |
| **판정역산 가중치 학습기** | 사람 판정 학습 | n=0. 더 근본적으로 **라벨 순환이 구조적**이다 — 큐가 `riskScore` 내림차순 정렬이고 화면이 점수·근거를 먼저 보여준 뒤 판정을 받으므로, 학습된 계수는 "이 근거가 반려를 예측한다"가 아니라 "운영자가 내가 보여준 점수에 동의했다"를 재학습한다. 제안 피처 3개는 현 코퍼스에서 상수 0 컬럼 |
| **라벨가치 우선 큐** | 사람 판정 학습 | 입력인 p(반려)가 없다(위 항목이 REFUTED). 능동학습의 경제학이 성립하지 않는다 — **큐의 모든 케이스가 결국 판정되므로** 재정렬은 라벨이 언제 오는지만 바꾸고 몇 개 오는지는 못 바꾼다. 게다가 `riskScore` 내림차순은 정보 순서가 아니라 **위험 트리아지 순서**이고 미탐 비용이 비대칭이라 정당하다. 가치 있는 절반(중복그룹 우선)은 `duplicateGroup`으로 이미 클라이언트에 도착해 있다 |
| **시리즈 묶음 심사 카드** | 운영자 효율 | 0.88~0.97 밴드가 목표를 못 맞춘다(실측 반증). 실제 제출 이미지로 측정: 20% 크롭 0.812~0.859, 좌우반전 0.406~0.844로 **목표는 밴드 아래로 빠지는데**, 무관한 이미지 쌍이 이미 0.781을 낸다. 원인은 `fingerprints.py:47`이 DCT pHash가 아니라 **8x8 평균해시**라는 것. 게다가 묶음 LLM 요약은 8건 전체에서 근거 링크를 뽑으면서 실제로는 1건 남짓만 읽어 **근거 없는 요약 하나를 8건에 확산**시킨다 |
> 이 6건이 남긴 **재사용 가능한 교훈 3가지**: ① 이 레포에서 "배치니까 괜찮다"는 거짓이다 — 시드가 `_write_lock` 안에서 돈다. ② 실패 증거에 `+30`이 붙으므로 **AI가 틀리면 위험점수가 올라간다**(환각→가점 경로). ③ `_perceptual_hash`는 이름만 pHash이고 실제로는 평균해시라, 유사도 임계값에 기대는 모든 설계가 이 위에서 흔들린다.
### 살아남은 아이디어에서 잘려나간 구성요소
아래는 항목 자체는 살았지만 **내부의 특정 주장·구성요소가 반증되어 잘려나간 것들**이다. 착수 시 되살리면 안 된다.
| 잘려나간 것 | 소속 | 반증 근거 |
|---|---|---|
| **ⓒ/© OCR 매칭** | 워터마크 검출기 | OpenCV 배포 CRNN `crnn.onnx`의 알파벳은 `alphabet_36`(0-9, a-z)뿐이라 대문자·괄호·기호가 없다. 94자 `crnn_cs.onnx`에도 U+00A9가 없다. `'(C) 2024'` 정규식 매칭률 0, `stock.adobe.com`은 `stockadobecom`으로 복원 |
| **전체 프레임 로고 템플릿 매칭** | 워터마크 검출기 | 개발 머신 실측 1920x1080 · 로고 10개 × 6스케일 = **2,347ms**. KB에 로고 50개면 ×5 → 저사양 30~100초/장. 비용이 "운영자가 등록한 로고 수"에 선형이고 상한이 없다 |
| **타일 aHash 부분매칭 국소화** | 증거 영역 오버레이 | `match='partial'`은 Google Vision의 원격 URL 목록에서만 생성되며 **우리는 그 픽셀을 갖고 있지 않다**. 게다가 8x8 average hash는 실측에서 무관한 제출 이미지 쌍이 이미 0.25~0.781을 낸다 — 1/16 타일은 더 심하다. **틀린 영역을 칠하는 것은 아무것도 안 칠하는 것보다 나쁘다** |
| **LLM 정책메모 초안** | 근거프로파일 일관성 감시 | 착지점 `decision_memo_presets`의 본문이 `memo`를 거쳐 `APPLICANT_EXPORT_FIELDS`로 신청자에게 나간다. 또 "이 조합에서는 …인 경우 반려"는 시스템 프롬프트가 금지하는 판정 권고의 집합 형태 |
| **LLM 후보 재랭킹** | 근거공백 액션 플래너 | 후보 총량이 3~5개(쿼터 상한). 5개 중 3개 고르기에 0.5B + 파싱 폴백 + 범위 검증 + 배지를 붙이는 과설계. `priority`가 이미 순서를 준다 |
| **"추가 네트워크 비용 0"** | 페이지 권리표기 추출 | `page_fetcher` 호출부는 유사도 비교의 **네 번째 폴백**이라, 이미지가 실제로 걸린 페이지는 애초에 HTML을 받지 않는다. 게다가 페치 바이트는 어디에도 캐시되지 않는다 |
| **"screenshot 블록리스트는 자해"** | 방송캡처 포렌식 | 과장된 오독. `_is_generic_best_guess_label`은 검색 쿼리 적합성 판단 함수이고 'screenshot' 한 단어로 검색하는 것은 실제로 무의미하다. 차단은 옳은 설계 |
| **"수천 번 클릭한 오탐 라벨"** | 근거 신뢰도 재보정 | 실측 evidence 292행 전부 `operatorStatus=None`, 백업 DB에 `false_positive` 2행이 전 이력 |
| **"pending 프리마킹은 점수 불변이 코드로 보장"** | 근거 신뢰도 재보정 | `mark_evidence_status`(`sqlite_store.py:731-734`)가 NON_CONTRIBUTING이 아니면 `contributed`를 True로 복원하고 재채점한다. 이전에 false_positive였던 FINGERPRINT 행을 pending으로 바꾸면 +80이 되살아난다 |
| **"운영자별 편향 감지"** | 근거프로파일 일관성 감시 | `actor='rights.ops'` 상수가 문제가 아니라 **시스템에 사용자 개념 자체가 없다**(`_require_auth`는 단일 공유 bearer 토큰 하나). 이 선행 작업 없이 편향 감지를 주장하면 허위 |
| **인용 검증기의 "환각 차단" 가치** | 문장별 인용 검증기 | 실측 환각은 서술어(의미)에 있고 제안된 검사는 개체(URL·숫자·연도)에 있다. 실측 출력 'confirming the image's authenticity [E3]' 같은 근거 없는 단정이 화이트리스트 대조를 100% 통과 → **오히려 환각 문장에 "근거 확인됨" 도장을 찍는다** |
| **"문장 끝 라벨" 파서 계약** | 문장별 인용 검증기 | 실측 5회 전부 라벨이 **불릿 앞**에 붙었고 문장 끝은 0건. 출력이 마침표 문장이 아니라 개행 덩어리라 한국어 문장 분해가 성립하지 않는다 |
| **"검증된 폐기 경로 재사용"** | 문장별 인용 검증기 | `_source_evidence_ids`는 항상 비지 않으므로 `verified`는 프로덕션에서 항상 True. `policies.py:110-114`의 LLM_SUMMARY 분기와 폐기 분기는 **한 번도 실행된 적이 없다** |
| **"llm_assistant가 None이면 폴백"** | 6·9·16·23 공통 | `env_clients._llm_assistant()`는 도달성 검사 없이 무조건 반환. `is None` 분기는 절대 실행되지 않고 llm provider는 항상 `enabled=True` |
| **`EvidenceEnricher` / `ReviewEnrichmentJob` 수정** | 6·9·16 공통 | 프로덕션 미배선 죽은 코드. 실제 호출 지점은 `store_operations.py:471`. 테스트 2건 + `test_public_module_layout.py` 임포트 검사가 붙어 있어 이 범위에서 손대면 안 됨 |
| **"7B로 메모 초안"** | 작업별 모델 라우터 | CPU 프리필 100초+ 생성 40~50초. 1단계 실측 tok/s가 나오기 전에는 번들에 넣지 말 것(+4.7GB) |
| **"새 저장 인프라 없음"** | 의미지문 인덱스 | 신규 테이블은 `STORE_TABLES`·`CONSTRAINED_TABLE_SCHEMAS`·`LEGACY_REBUILD_ORDER`·`TYPED_COLUMNS`·`SCHEMA_VERSION`·`_validate_table`을 전부 건드리고, 벡터 BLOB이 백업 zip 10개에 곱해져 들어간다 |
**미검증 1건**: 16위 「밴드 임계값 스윕·섀도 리스코어」는 적대적 검증 단계에서 판정이 기록되지 않았다(`verdict: null`). 다른 항목과 같은 수준의 반증을 거치지 않았으므로 착수 전 별도 검증이 필요하다. 특히 원안이 전제하는 "결정된 케이스 전체"가 현재 0건이라는 점, `_rescore_all_submissions`가 항상 쓰기까지 한다는 점(read-only 경로 분리 필요)이 검증 대상이다.
## 단계별 로드맵 제안
> 이 로드맵은 심사 점수와 독립적으로 **선행조건 의존 관계**로 짰지만, 결과적으로 정합한다:
> 1단계는 심사 1·3·6·7·10·12위를 포함하고, 라벨 의존 항목(21·23·24위)은 전부 3단계로 밀린다.
> 점수 하위권이 3단계로 가는 이유는 "가치가 없어서"가 아니라 **지금은 입력이 없어서**다.
### 1단계 — 지금 당장(라벨 0건·의존성 0·모델 반입 0에서 작동)
한 스프린트짜리 "기반 정리" 묶음. **이 단계는 AI를 늘리지 않는다. 이미 켜져 있는 AI의 거짓말과 세금을 끄는 작업이다.**
| 항목 | 내용 | 공수 |
|---|---|---|
| 1-a | **LLM 입력 경계 단독 커밋** — 프롬프트 화이트리스트 + 증거 상한 12건 + `num_ctx` 명시 (`promotion_reason` 제외) | S |
| 1-b | **시드 LLM 순회를 `_write_lock` 밖으로** + 시드당 처리 상한 20건 + 실패 백오프 | S |
| 1-c | **`initialRiskScore`/`initialRiskBand`/`scorerVersion` 각인** + 라운드트립 속성 기반 테스트 | S |
| 1-d | **모델·프롬프트 버전 스탬프** (id 해시 오염 회피 규칙 준수) | S |
| 1-e | **원본 메타 권리추출 EXIF+IPTC만** (XMP는 별건, 스톡 ID는 +30 라벨) | S |
| 1-f | **`env_clients._llm_assistant()` 도달성 프로브** + provider `enabled` 산출식 정정 + `offline-install.md`의 잘못된 서술 정정 | S |
| 1-g | **요약 상태 3단 배지**(정상 / 생성 안 됨+원인 / 실패) — 5단은 과설계 | S |
| 1-h | **액션 플래너 축소판** — `plan()` 결과를 `review()` 응답에 싣고 `_is_generic_local_query` 탈락 쿼리 숨김 + `query_priority` 매핑 1줄 | S |
| 1-i | `app.js:726-746` 영→한 if-체인을 사전 테이블로 리팩터 *(같은 항목에 있던 "`_connect()`에 WAL 추가"는 **이미 적용돼 있어 삭제**)* | S |
**2단계로 가기 위한 선행조건**
- 1-d의 `latency_ms`가 실제 타깃 하드웨어에서 2주 이상 쌓여 **케이스당 실측 지연 분포**가 나올 것.
- 1-c의 라운드트립 속성 테스트가 그린이고, 새 `EvidenceSource` 배관 12곳 체크리스트가 문서로 확정될 것.
- 타깃 하드웨어 사양(코어/스레드/메모리대역/GPU 유무)이 확정될 것 — 실측이 2스레드 20.6초 vs 20스레드 5.9초로 갈렸다. 이 값 없이는 3B 채택 여부를 결정할 수 없다.
- 모델 번들 크기 상한이 승인될 것.
### 2단계 — 모델 도입을 수반하는 중기
| 항목 | 내용 | 비고 |
|---|---|---|
| 2-a | **YuNet 교체** + detect 중복 호출 제거 + tier 라운드트립 | 모델 345KB, 지연 순감. 밴드 재캘리브레이션 비용을 판정 라벨이 쌓이기 전에 치른다 |
| 2-b | **모델 번들 무결성 검증** — `models.lock.json`(`/api/tags` 응답 기반) + **비차단** 프리플라이트 + `enabled`와 분리된 `modelReady` 필드 | 차단형 프리플라이트 금지(DROP 방화벽에서 기동 정지) |
| 2-c | **잡 큐 + 백그라운드 워커** (`run_once()` 테스트 모드, 부팅 시 running→pending 리셋) | 1-b로 급한 불을 끈 뒤, 태스크가 2개 이상 생긴 다음 |
| 2-d | **모델 라우터 1단계** — `num_ctx`/`num_predict`/`keep_alive`/`timeout`을 환경변수 프로필로 노출 + 3B 실험 + 감사 로깅(모델명·소요시간·토큰수) | 7B는 실측 tok/s 확인 전 번들 금지 |
| 2-e | **증거팩 직렬화 + 인용 칩** (3B) — 줄 단위 출력(`[E<n>]`로 **시작**), 라벨 없는 줄만 드롭, 미라벨 줄은 렌더하지 않음(취소선 금지) | 1-a·2-d 선행 필수 |
| 2-f | **방송캡처 포렌식** — 실측 2.0ms/장, 모델 0. 단 **1차는 0점 라벨로만** 출시하고 워터마크와 같은 EvidenceSource·같은 GUI 그룹으로 배관 비용을 한 번만 지불 | 독립 카테고리 2개 이상 성립 규칙 |
| 2-g | **워터마크 로고 템플릿 매칭** — ROI를 4모서리 + 하단 20%로 한정, 활성 로고 12개 상한, 스케일 3단계, 점수 +45(단독 high 금지), 크롭은 face-crop과 **분리된 보관 경로** | OCR(56~72MB) 반입은 로고 경로 검출률을 본 뒤 결정 |
**3단계로 가기 위한 선행조건**
- **판정 라벨이 실제로 축적될 것** — 이것이 절대적 게이트다. 현재 0건. 프로파일 군집은 자체 규칙(군집 5건 이상)까지 감안하면 수백 건이 필요하고, 쿼리전략별 통계는 `queryStrategy`가 채워지기 시작한 이후 수개월이 걸린다.
- `decision_stats()`의 밴드×판정 교차표가 유의미한 표본(최소 100건)을 가질 것.
- 골든셋 diff 하네스가 돌아 "가중치를 바꾸면 큐가 어떻게 변하는가"를 숫자로 낼 수 있을 것.
- 임베딩 저장 금지 정책의 개정 여부가 **사용자 결정**으로 확정될 것(아래 열린 질문 참조).
### 3단계 — 학습·자동화로 가는 장기
| 항목 | 내용 | 진입 조건 |
|---|---|---|
| 3-a | **근거 신뢰도 카운트 배지** — (source, matchType, domainSource) 3키 집계, n≥15에서만 표시. isotonic 보정표·confidence 주입은 **만들지 않는다** | 사실상 오늘 출시 가능(n=0에서 조용히 사라짐). 오히려 라벨 수집 인센티브 |
| 3-b | **AI 수용률 축소판** — 새 테이블·새 화면 없이 `decision_stats`에 `llmDraftPulled`/`llmDraftMedianRetention` 두 필드. 단 `_latest_decision_memo`의 `' · '` partition 버그 선수정 | 판정 라벨 축적 |
| 3-c | **밴드 임계값 스윕 + 섀도 리스코어** — 쓰기를 타지 않는 read-only 경로로 분리, 적용은 항상 사람 클릭 + 감사 이벤트 | 결정 케이스 100건 + 별도 검증(현재 미검증) |
| 3-d | **근거프로파일 일관성 감시(A안만)** — 시그니처는 현행 코드가 실제로 채우는 필드로만. LLM 정책메모 초안은 **폐기**. actor/다중 사용자 인증은 **분리 발주** | 판정 수백 건 |
| 3-e | **트리아지 레인 — 규칙 우선** — 1차는 모델 없이 `decision_stats` 교차표 규칙("같은 밴드+같은 providerState에서 과거 승인률>80% & n≥10")으로, 동결 라벨 200건 초과 시 로지스틱 교체 | 피처 동결(판정 시점 스냅샷) + 워치리스트 역류 제외가 선행 |
| 3-f | **의미지문 인덱스** — 1단계는 제출물+KB 참조만(검색 후보 제외), cv2.dnn + CNN 백본, 벡터는 SQLite BLOB이 아니라 `.npy` 외부 저장, 점수 0 + '변형 의심' 라벨 | **거버넌스 사전 승인 없이는 착수 금지** |
| 3-g | **페이지 권리표기 추출** — 유사도 폴백 체인에 얹지 말고 "점수 기여 상위 N개 URL"에 대한 독립 페치 단계(케이스당 2건)로. 1차는 정규식 전용, LLM 없음 | 페치 커버리지 재설계 |
| 3-h | **케이스 Q&A — 고정 질문 사전 캐시** — 자유 질문 동기 대기(40~120초)는 기능의 사망 원인. 증거 확정 시점에 3~5개 고정 질문을 미리 생성해 클릭 시 0초 | 잡 큐(2-c) + 별도 캐시 테이블(TTL, 감사 대상 아님) |
## 결정 반영 실행안 (2026-07-25 확정)
아래 열린 질문 중 4개가 답변되었다. **이 답변들이 위 로드맵의 전제를 바꾸므로, 실행 시에는 이 절이 로드맵보다 우선한다.**
| 질문 | 결정 |
|---|---|
| LLM 역할 범위 | **증거 판독 + 구조화 판단** (요약만 유지 아님) — 3B 이상 승급 |
| 타깃 하드웨어 | **GPU 있음** |
| 새 신호의 점수 개입 | **0점 라벨로 시작**, `decision_stats` 상관 확인 후 가중치 부여 |
| 실사용 투입 | **곧 투입 예정** |
### 이 결정들이 무효화한 분석
1. **CPU 지연 분석 전체가 이 배포에는 적용되지 않는다.** 제약 2절의 실측(2스레드 20.6초 / 20스레드 5.9초, 3B 60~180초, "7B는 인터랙티브 불가")은 CPU-only 전제였다. GPU에서는 3B가 인터랙티브이고 7B도 후보다. **다만 지연이 사라져도 `_write_lock` 점유 문제는 남는다** — 락은 추론 시간에 비례할 뿐 원인이 아니다.
2. **"잡 큐/배치 선연산이 필수"라는 전제가 약해졌다.** 2-c(백그라운드 워커)의 우선순위를 낮추고, 1단계의 락 이동(5줄)만으로 당분간 버틴다.
3. **워터마크 OCR의 재검토 여지가 생겼다.** 단 반증된 결함(배포 CRNN 알파벳에 `©`·대문자 없음)은 하드웨어와 무관하므로, 재검토한다면 **다른 OCR 모델 선정이 전제**다.
4. **의미지문 인덱스는 여전히 막혀 있다.** 막는 것이 성능이 아니라 거버넌스(생체 템플릿 저장 금지 사양)이기 때문. GPU는 이 항목을 풀어주지 않는다.
### 🔴 실사용 투입 **전에** 끝내야 하는 것 — 되돌릴 수 없는 창
**"곧 투입"이 만든 가장 중요한 제약이다.** 아래 4건은 운영자가 첫 판정을 누르는 순간 **소급이 불가능하거나 비용이 급등**한다.
| # | 항목 | 왜 투입 전이어야 하는가 |
|---|---|---|
| **T0-1** | `initialRiskScore` / `initialRiskBand` / `scorerVersion` 각인 (`_submission_payload`) | `_rescore_submission`이 12곳에서 `riskScore`를 덮어쓰므로 저장된 점수는 이미 왕복 점수다. 원본 점수를 지금 각인하지 않으면 **판정이 시작된 뒤에는 영원히 복원할 수 없고**, 모든 점수 회귀 테스트가 항상 통과하는 빈 테스트로 남는다 |
| **T0-2** | YuNet 교체 + 밴드 재캘리브레이션 | 등급화는 기존 medium 다수를 low로 옮겨 밴드 분포를 바꾼다. 판정 이력이 쌓인 뒤에 하면 `decision_stats`의 캘리브레이션 기준선이 판정 이력과 어긋난다. **판정 0건인 지금이 이 비용이 가장 싼 시점이고, 그 시점은 곧 닫힌다** |
| **T0-3** | **판정 시점 피처 스냅샷 각인** (라벨 순환 차단) | 큐가 `riskScore` 내림차순 정렬이고(`app.js:1267`) 화면이 점수·밴드·reasons를 보여준 뒤 판정을 받는다. 이 구조에서 모은 라벨은 "이 근거가 반려를 예측한다"가 아니라 **"운영자가 내가 보여준 점수에 동의했다"**를 기록한다. 판정 순간 운영자가 실제로 본 것(점수·밴드·표시된 근거 목록)을 스냅샷으로 남겨야 나중에 순환 보정이 가능하다. **투입 후에 붙이면 그 이전 라벨은 전부 용도 불명이 된다** |
| **T0-4** | 모델·프롬프트 버전 + `latency_ms` 스탬프 | 투입 후 모델을 3B로 올리면 과거 요약과 신규 요약이 DB에 섞이는데 구분할 필드가 없다. 지금 스탬프를 넣으면 승급 전후 비교가 성립한다 |
> T0-3은 이번 심사에서 나온 항목이 아니라, **"곧 투입"이라는 답변이 새로 만든 요구사항**이다. 학습 계열 7건 전부의 미래 가치가 여기 걸려 있고, 비용은 S다.
### 실행 순서 (확정)
**A. 투입 전 (T0)** — `initialRiskScore` 각인 · YuNet + 재밴딩 · 판정 스냅샷 각인 · 버전/지연 스탬프
**B. LLM을 판독기로** — GPU 전제로 공격적으로 간다
- B-1 입력 경계(화이트리스트 + 상위 12건 + `num_ctx`) — `promotion_reason` 제외 필수
- B-2 3B 승급 + GPU 프로필(`num_ctx`/`num_predict`/`keep_alive`/`num_gpu`), 7B는 B-2 실측 후 판단
- B-3 시드 LLM 호출을 `_write_lock` 밖으로
- B-4 **구조화 증거 판독** — `format=json`으로 `{claims:[{text, evidence_ids[], kind}], needs_check:[...]}` 스키마 강제.
**인용 검증은 문장 파싱이 아니라 스키마 필드 검증으로 한다** — `evidence_ids`가 실제로 프롬프트에 들어간 증거 id 집합의 부분집합인지만 대조하고, 아니면 그 claim을 렌더하지 않는다.
*이 설계는 심사에서 3.03점을 받은 「문장별 근거 인용 검증기」의 반증 사유를 우회한다*: 그 안은 자유 텍스트를 문장 분해해 개체(URL·숫자)를 대조하는 방식이라 `'confirming the image's authenticity [E3]'` 같은 **근거 없는 서술어에 "확인됨" 도장을 찍는** 문제가 있었다. 스키마 필드 대조는 모델이 claim 단위로 id를 스스로 붙이게 하므로 이 실패 모드가 구조적으로 발생하지 않는다.
**C. 모델 없는 신호 (전부 0점 라벨)** — EXIF/IPTC 권리추출 · 방송캡처 포렌식. 두 항목은 **같은 `EvidenceSource`·같은 GUI 그룹**으로 묶어 배관 12곳 비용을 한 번만 지불한다.
**D. 투입 후 라벨 축적 뒤** — `decision_stats` 교차표로 0점 라벨들의 실제 반려 상관 측정 → 가중치 부여 → 임계값 스윕. 학습 모델은 T0-3 스냅샷이 수백 건 쌓인 뒤.
### 남은 결정 1개
**임베딩 저장 금지 정책의 개정 여부** — 의미지문 인덱스(22위)만 이 결정에 걸려 있고, 나머지 A~D는 전부 이 결정 없이 진행된다. 급하지 않으므로 D 시점에 재론해도 된다.
## 열린 질문 (사용자 결정 필요)
아래는 근거로 답할 수 없고 **사람이 정해줘야 다음 단계가 진행되는** 항목이다.
1. **타깃 하드웨어 사양** — CPU 코어/스레드 수, 메모리 대역폭, GPU 유무. 실측이 2스레드에서 20.6초, 20스레드에서 5.9초로 3배 이상 갈렸다. 이 값 없이는 3B 채택 여부(2-d)도, "케이스당 몇 초까지 허용되는가"도 결정할 수 없다. **현재 문서의 모든 저사양 추정치는 개발 머신 실측의 외삽이며 추정이다.**
2. **임베딩 저장 금지 정책의 개정 여부** — 3-f(의미지문 인덱스)는 사양 문서 두 곳(`docs/operations/image-rights-risk-filter.md:16`, `docs/project-introduction-and-technical-implementation.md:209`)의 "생체 템플릿 저장 금지" 문구와 정면으로 만난다. 전용 테이블 우회는 코드는 통과해도 사양은 통과하지 못한다. 결정 옵션: (a) 개정하지 않고 3-f 영구 배제, (b) "장면 벡터는 SEMANTIC_INDEX로 별도 관리"를 명문화하되 얼굴 박스 검은 마스킹 + 인물이 프레임 N% 이상이면 인덱싱 스킵 + 원본 삭제 시 연쇄 삭제를 조건으로. **(b)를 택하면 사양 문서 2곳 동시 개정이 필요하다.**
3. **케이스당 허용 가능한 추가 지연** — 동기 요청 경로에서 몇 초까지 운영자가 기다릴 수 있는가? 현재 `timeout=30`은 0.5B조차 여유가 없는 값이다. 이 숫자가 정해져야 (a) 어느 기능이 동기이고 어느 것이 배치인지, (b) 3B를 쓸 수 있는지가 결정된다.
4. **모델 번들 크기 상한** — 후보별 증분: 3B Q4_K_M +약 1.9GB / 7B +4.7GB / OCR ONNX 56~72MB / YuNet 345KB / 롤백 태그 유지 +379MiB. Ollama 런타임과 0.5B는 현행 구성이 이미 요구하므로 증분이 아니다. **반입 매체와 절차 기준으로 상한을 정해달라.**
5. **4번째 런타임 의존성 반입 승인 여부** — `requirements.txt` 헤더가 "서드파티는 PIL/cv2/numpy 뿐"이라고 선언하고 있다. 후보: `defusedxml`(25KB, XMP/C2PA 판독에 필수 — 없으면 폐쇄망에서 조용히 no-op), `onnxruntime`(40~55MB, CLIP/ViT 계열에 필수). 각각 별도 심사 안건이다.
6. **AI 제안의 판정 개입 범위** — 새 신호를 (a) 0점 라벨로만 낼 것인가, (b) 캘리브레이션 후 점수를 줄 것인가, (c) 준다면 단독 high 승격을 허용할 것인가. 검증자들은 워터마크 +70, 스톡 자산 ID +70, 방송캡처 +30, 의미유사도 +30을 **전부 낮추거나 0점 라벨로 시작하라**고 권고했다. 판정 라벨이 0건인 현재로선 어떤 가중치도 근거가 없다.
7. **새 `EvidenceSource` 배관 12곳을 언제 한 번에 지불할 것인가** — 워터마크·방송캡처·권리메타·권리표기가 각각 새 소스를 요구한다. 운영자 관점에서는 넷 다 같은 질문("이 이미지가 어디서 왔나")에 답하므로 **하나의 소스·하나의 GUI 그룹으로 묶어 배관 비용을 한 번만 지불**하는 안이 검증 단계에서 제시됐다. 묶을지 나눌지는 화면 설계 결정이다.
8. **다중 사용자 인증(actor)을 별도 발주할 것인가** — 현재 시스템에 사용자 개념이 없어(단일 공유 bearer 토큰) "운영자별 편향 감지"·"프리마킹과 사람 라벨 구분"이 원리적으로 불가능하다. 계정·로그인·세션→actor 전파(`add_audit_event` 호출부 약 10곳) + 감사 로그 마이그레이션은 별도 프로젝트이며 보안 리뷰 대상이다.
9. **골든셋 파일의 반출 통제 수준** — 1000건 기준 약 31MB, 내부 riskScore·result URL·pageTitle을 담는다. `.gitignore`만으로 충분한가, 아니면 납품 번들 생성 스크립트 제외 목록 + `offline-install.md` + delivery-audit 체크리스트까지 3중으로 갈 것인가.
10. **판정 라벨을 언제까지 얼마나 모을 것인가** — 3단계 전체(3-a~3-h)가 이 값에 걸려 있다. "몇 건이 모이면 학습계를 켠다"는 목표치를 정하지 않으면, 라벨 의존 기능 7건이 영구히 비어 있는 화면으로 남는다.

View file

@ -13,11 +13,37 @@
- Do not automate Naver web search UI or scrape Naver result pages.
- Do not send internal originals to external APIs.
- Do not send original images or analysis derivatives to Naver search; use text queries only.
- Do not store face embeddings, celebrity identity matches from faces, or biometric templates.
- Do not store face embeddings, face landmarks, face descriptors, celebrity identity matches from faces, or biometric templates.
- Do not use an LLM output as standalone evidence unless it is tied back to source evidence.
- Do not expose automated scores, evidence, or provider details to applicants.
- Do not let the filter change review status automatically.
## Scene Embedding Policy
> **2026-07-25 개정.** 이전 규정은 "임베딩 저장 금지"를 포괄적으로 서술해, 인물 식별과 무관한
> **장면 임베딩**(전체 이미지의 의미 벡터)까지 함께 금지되는 것으로 읽혔다. 금지의 취지는
> **생체 식별**이지 이미지 유사도 판정이 아니므로, 아래 조건을 만족하는 장면 임베딩을 허용하도록
> 범위를 좁힌다. 얼굴 임베딩 금지는 그대로 유지되며 오히려 강화되었다(landmark·descriptor 추가).
Scene embeddings (whole-image semantic vectors) are permitted **only** for image-to-image
similarity matching — detecting that a submission is a crop, retouch, or re-encode of an image
already in the knowledge base or of another submission. They are never used to identify a person.
Every scene embedding must satisfy all of the following:
1. **Face masking** — detected face regions are filled before the vector is computed. The vector
never encodes facial pixels.
2. **Person-dominant exclusion** — if detected face/person regions cover more than the configured
share of the frame, the image is not indexed at all. A portrait is excluded rather than masked.
3. **Purpose restriction** — the vector may only be compared against other scene embeddings for
similarity. Identity lookup, clustering by person, and any face-keyed index are prohibited.
4. **Separate storage class** — vectors are stored under `DataClass.SCENE_EMBEDDING`, never inside
operator evidence payloads. Evidence rows carry the similarity result, not the vector.
5. **Cascade deletion** — deleting or detaching the source image deletes its vector.
`governance/policies.py` enforces (3)(4)(5) structurally and asserts that (1)(2) were applied
before a vector is accepted. A vector missing the masking/exclusion attestation is rejected.
## Disable External Calls
Set the external API policy to disabled. Batches should continue internal-only and record external-skipped evidence reasons for affected submissions.

View file

@ -36,10 +36,45 @@ 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 both the repository and the `wheelhouse/` directory to the target via the
approved air-gap transfer process (e.g. removable media).
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)

View file

@ -104,9 +104,10 @@ The LLM output is stored as a source-linked summary evidence item. It helps oper
Important boundary:
- it does not identify a person;
- it does not store face embeddings;
- it does not store face embeddings, landmarks, or descriptors;
- it does not perform biometric matching;
- it is used only as a review-priority signal.
- it is used only as a review-priority signal — and, when scene embeddings are enabled, to supply
the face regions that must be masked out before a scene vector is computed.
### 6.4 Image Fingerprints and pHash Similarity
@ -206,7 +207,7 @@ The project intentionally keeps several strict boundaries:
- Do not automate Google Image Search, Google Lens, Naver web UI, or scrape result pages.
- Do not send original images to Naver; Naver is used through text queries.
- Do not store biometric templates, face embeddings, or celebrity identity matches from faces.
- Do not store biometric templates, face embeddings, face landmarks/descriptors, or celebrity identity matches from faces. Scene embeddings (whole-image semantic vectors, face-masked and person-dominant-excluded) are permitted for image-similarity matching only — see "Scene Embedding Policy" in `docs/operations/image-rights-risk-filter.md` (2026-07-25 개정).
- Do not expose internal risk scores or evidence details to applicants.
- Do not let automated analysis change final review status.
- Do not treat LLM output as standalone evidence unless it links back to source evidence.

View file

@ -1,8 +1,11 @@
from __future__ import annotations
import logging
import os
import threading
from io import BytesIO
from dataclasses import dataclass
from pathlib import Path
from rights_filter.analysis.preprocessing import ImagePayload
@ -11,32 +14,214 @@ logger = logging.getLogger(__name__)
# Emit the "OpenCV build lacks CascadeClassifier" warning at most once per
# process instead of on every image so operator logs stay readable.
_warned_missing_cascade = False
_warned_missing_model = False
FaceBox = tuple[int, int, int, int]
# Weights live outside the repo (gitignored, carried across the air gap with the
# wheelhouse). Absent weights are a downgrade, not a failure — see
# docs/operations/offline-install.md.
DEFAULT_FACE_MODEL_PATH = (
Path(__file__).resolve().parents[3] / "models" / "face_detection_yunet_2023mar.onnx"
)
FACE_MODEL_PATH_ENV = "COPYRIGHTER_FACE_MODEL_PATH"
YUNET_SCORE_THRESHOLD = 0.6
YUNET_NMS_THRESHOLD = 0.3
# A face covering at least this share of the frame is a recognizable subject: the
# portrait-rights question is about that person. Anything smaller is a bystander
# in a scene, which is a much weaker signal — treating both as one flat +35 is
# what pushed every photo containing a human into the medium band.
IDENTIFIABLE_FACE_AREA_RATIO = 0.05
# Haar reports no per-detection score, so a Haar signal cannot claim the
# confidence a YuNet one can. Keep it explicitly middling and labelled.
HAAR_FALLBACK_CONFIDENCE = 0.5
TIER_NONE = "none"
TIER_BACKGROUND = "background"
TIER_IDENTIFIABLE = "identifiable"
# One detector per thread: ThreadingHTTPServer serves concurrent operators, and
# FaceDetectorYN carries mutable input-size state that must not be shared.
_detector_state = threading.local()
@dataclass(frozen=True)
class FacePersonSignal:
face_count: int
person_count: int
face_boxes: tuple[FaceBox, ...] = ()
# Highest per-detection score in the frame. 0.0 when nothing was found.
confidence: float = 0.0
tier: str = TIER_NONE
# Which implementation produced this, so a downgraded run is visible in the
# evidence rather than silently looking like a confident one.
detector: str = "none"
# Largest face box area as a share of the frame — the input to the tier.
face_area_ratio: float = 0.0
@property
def present(self) -> bool:
return self.face_count > 0 or self.person_count > 0
def _face_model_path() -> Path:
configured = os.environ.get(FACE_MODEL_PATH_ENV, "").strip()
return Path(configured) if configured else DEFAULT_FACE_MODEL_PATH
def _yunet_detector(cv2: object) -> object | None:
"""Per-thread YuNet instance, or None when the weights are unavailable."""
global _warned_missing_model
# Checked before the cache, not after: an OpenCV build without FaceDetectorYN
# must degrade to Haar every time, and a cached instance from a build that
# had it must not be handed back for a module that does not. Mirrors the
# CascadeClassifier guard below.
if not hasattr(cv2, "FaceDetectorYN"):
return None
if getattr(_detector_state, "yunet_failed", False):
return None
detector = getattr(_detector_state, "yunet", None)
if detector is not None:
return detector
model_path = _face_model_path()
if not model_path.is_file():
_detector_state.yunet_failed = True
if not _warned_missing_model:
_warned_missing_model = True
logger.warning(
"face detector weights not found at %s — falling back to Haar "
"cascades (see docs/operations/offline-install.md)",
model_path,
)
return None
try:
detector = cv2.FaceDetectorYN.create(
str(model_path),
"",
(320, 320),
YUNET_SCORE_THRESHOLD,
YUNET_NMS_THRESHOLD,
)
except Exception:
_detector_state.yunet_failed = True
logger.warning("face detector failed to load from %s", model_path, exc_info=True)
return None
_detector_state.yunet = detector
return detector
def _detect_with_yunet(cv2: object, decoded: object) -> FacePersonSignal | None:
detector = _yunet_detector(cv2)
if detector is None:
return None
height, width = decoded.shape[:2]
try:
detector.setInputSize((width, height))
_, faces = detector.detect(decoded)
except Exception:
logger.warning("face detection failed; degrading to no-face signal", exc_info=True)
return FacePersonSignal(face_count=0, person_count=0, detector="yunet")
if faces is None or len(faces) == 0:
return FacePersonSignal(face_count=0, person_count=0, detector="yunet")
face_boxes: list[FaceBox] = []
best_confidence = 0.0
for face in faces:
# Columns are [x, y, w, h, 10 landmark coordinates, score]. The landmarks
# are face-derived identity features: read the geometry and the score,
# never the landmarks. Storing them is prohibited outright by
# governance.policies.assert_no_biometric_template.
x, y, box_width, box_height = (int(round(float(value))) for value in face[:4])
score = float(face[-1])
face_boxes.append((max(0, x), max(0, y), max(0, box_width), max(0, box_height)))
best_confidence = max(best_confidence, score)
return _signal_from_boxes(face_boxes, best_confidence, "yunet", width * height)
def _signal_from_boxes(
face_boxes: list[FaceBox],
confidence: float,
detector: str,
frame_area: int,
) -> FacePersonSignal:
if not face_boxes:
return FacePersonSignal(face_count=0, person_count=0, detector=detector)
largest = max(width * height for _, _, width, height in face_boxes)
if frame_area <= 0:
# Unknown frame size: the ratio cannot be computed, so fall back to the
# pre-tier weight. A risk filter must not under-score on missing data.
return FacePersonSignal(
face_count=len(face_boxes),
person_count=len(face_boxes),
face_boxes=tuple(face_boxes),
confidence=round(confidence, 4),
tier=TIER_IDENTIFIABLE,
detector=detector,
)
ratio = largest / frame_area
tier = TIER_IDENTIFIABLE if ratio >= IDENTIFIABLE_FACE_AREA_RATIO else TIER_BACKGROUND
return FacePersonSignal(
face_count=len(face_boxes),
person_count=len(face_boxes),
face_boxes=tuple(face_boxes),
confidence=round(confidence, 4),
tier=tier,
detector=detector,
face_area_ratio=round(ratio, 4),
)
def face_person_evidence_data(signal: FacePersonSignal) -> dict[str, object]:
"""Evidence payload for a face/person signal.
Shared by the analysis and enrichment paths, which build the same row from
two call sites — without one builder they drift, and a tier missing on one
side silently scores as the legacy flat weight.
"""
return {
"face_count": signal.face_count,
"person_count": signal.person_count,
"tier": signal.tier,
"detector": signal.detector,
"face_area_ratio": signal.face_area_ratio,
}
def face_person_reason(signal: FacePersonSignal) -> str:
if signal.tier == TIER_BACKGROUND:
return "배경 인물 검출 (초상권 신호 약함)"
return "식별 가능한 인물 검출"
class HeuristicFacePersonDetector:
"""Presence-only detector used by the standalone implementation.
Target applications should replace this with their approved local detector or
internal service. It intentionally emits no identity or embedding data.
Prefers YuNet (OpenCV Zoo) when its weights are present and degrades to the
bundled Haar cascades when they are not. It intentionally emits no identity
or embedding data: face landmarks returned by the model are read for nothing
and never leave this module.
"""
def detect(self, image: ImagePayload) -> FacePersonSignal:
return self._detect_with_opencv(image.content)
# Frame size comes from the payload rather than the decoded array: the
# Haar path never needs the array's shape, and probing it would couple
# tiering to the decode backend.
return self._detect_with_opencv(image.content, image.width, image.height)
def _detect_with_opencv(self, content: bytes) -> FacePersonSignal:
def _detect_with_opencv(
self, content: bytes, width: int = 0, height: int = 0
) -> FacePersonSignal:
try:
import cv2
import numpy as np
@ -50,6 +235,10 @@ class HeuristicFacePersonDetector:
if decoded is None:
return FacePersonSignal(face_count=0, person_count=0)
yunet_signal = _detect_with_yunet(cv2, decoded)
if yunet_signal is not None:
return yunet_signal
# Some OpenCV builds (e.g. the 5.x preview wheels) drop the legacy Haar
# cascade API. Detect that up front and degrade to a presence-negative
# signal instead of letting an AttributeError escape into the seed/
@ -97,11 +286,11 @@ class HeuristicFacePersonDetector:
# rather than crashing the caller.
return FacePersonSignal(face_count=0, person_count=0)
face_count = len(face_boxes)
return FacePersonSignal(
face_count=face_count,
person_count=face_count,
face_boxes=tuple(face_boxes),
return _signal_from_boxes(
face_boxes,
HAAR_FALLBACK_CONFIDENCE,
"haar",
max(0, int(width) * int(height)),
)
def _boxes_overlap(a: FaceBox, b: FaceBox, threshold: float = 0.5) -> bool:

View file

@ -1,12 +1,17 @@
from __future__ import annotations
from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector
from rights_filter.analysis.face_person_detection import (
HeuristicFacePersonDetector,
face_person_evidence_data,
face_person_reason,
)
from rights_filter.analysis.fingerprints import FingerprintService
from rights_filter.analysis.preprocessing import ImagePayload
from rights_filter.domain.records import (
Evidence,
EvidenceSource,
InMemoryRightsFilterRepository,
KnowledgeEntryType,
)
@ -46,11 +51,13 @@ class InternalAnalyzer:
)
if similarity >= self.similarity_threshold:
entry_status = entry.entry_status or "confirmed"
reason = (
f"주의 후보 이미지 유사도 {similarity:.2f}"
if entry_status == "watchlist"
else f"Knowledge base image similarity {similarity:.2f}"
)
entry_type = str(entry.entry_type)
if entry_type == KnowledgeEntryType.CLEARED_REFERENCE:
reason = f"이전에 클리어된 이미지와 일치 {similarity:.2f}"
elif entry_status == "watchlist":
reason = f"주의 후보 이미지 유사도 {similarity:.2f}"
else:
reason = f"Knowledge base image similarity {similarity:.2f}"
evidence.append(
Evidence(
source=EvidenceSource.FINGERPRINT,
@ -60,6 +67,9 @@ class InternalAnalyzer:
"knowledge_entry_id": entry.id,
"knowledge_name": entry.name,
"knowledge_entry_status": entry_status,
# Carry the type so the scorer can tell a cleared
# (positive) match from a risk match.
"knowledge_entry_type": entry_type,
"source_submission_id": entry.source_submission_id,
"similarity": similarity,
"provenance": entry.provenance,
@ -72,12 +82,11 @@ class InternalAnalyzer:
evidence.append(
Evidence(
source=EvidenceSource.FACE_PERSON,
reason="Face/person detected",
confidence=0.8,
data={
"face_count": signal.face_count,
"person_count": signal.person_count,
},
reason=face_person_reason(signal),
# The detector's own score, not a hardcoded 0.8: a Haar
# fallback must not claim the confidence YuNet earns.
confidence=signal.confidence,
data=face_person_evidence_data(signal),
)
)

View file

@ -1,16 +1,35 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
from rights_filter.analysis.llm_prompts import (
LEGACY_PROMPT_VERSION,
PROMPT_ID,
PROMPT_VERSION,
SYSTEM_PROMPT,
)
from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.integrations.http_json import UrllibJsonTransport
@dataclass(frozen=True)
class LlmSummaryResult:
"""A summary plus the provenance needed to reproduce or compare it."""
text: str
model: str = ""
prompt_id: str = ""
prompt_version: str = LEGACY_PROMPT_VERSION
latency_ms: int = 0
@dataclass
class FakeInternalLlmClient:
summary: str = ""
error: Exception | None = None
model: str = "fake-llm"
def summarize_evidence(self, evidence: list[Evidence]) -> str:
if self.error:
@ -36,24 +55,33 @@ class OllamaGenerateLlmClient:
self.timeout = timeout
def summarize_evidence(self, evidence: list[Evidence]) -> str:
return self.summarize_evidence_detailed(evidence).text
def summarize_evidence_detailed(self, evidence: list[Evidence]) -> LlmSummaryResult:
payload = {
"model": self.model,
"system": (
"Summarize only the provided source evidence for an internal image "
"rights review operator. Do not make a final decision. Do not add "
"claims that are not grounded in source evidence."
),
"system": SYSTEM_PROMPT,
"prompt": _evidence_prompt(evidence),
"stream": False,
"options": {"temperature": 0.1},
}
started = time.monotonic()
response = self.transport.request_json(
"POST",
self._generate_url(),
payload=payload,
timeout=self.timeout,
)
return _response_text(response)
elapsed_ms = int((time.monotonic() - started) * 1000)
return LlmSummaryResult(
text=_response_text(response),
# Prefer the model the server says it ran: a tag can resolve to a
# different blob than the one configured here.
model=str(response.get("model") or self.model),
prompt_id=PROMPT_ID,
prompt_version=PROMPT_VERSION,
latency_ms=elapsed_ms,
)
def _generate_url(self) -> str:
if self.base_url.endswith("/api"):
@ -67,7 +95,7 @@ class InternalLlmAssistant:
def summarize(self, submission_id: str, evidence: list[Evidence]) -> Evidence:
try:
summary = self.client.summarize_evidence(evidence)
result = self._summarize(evidence)
except Exception as exc:
return Evidence(
source=EvidenceSource.ENRICHMENT_FAILURE,
@ -76,6 +104,17 @@ class InternalLlmAssistant:
data={"submission_id": submission_id},
)
# Provenance for every summary row: which model and which prompt made it,
# and how long the operator would have waited. Without these, swapping the
# model leaves old and new summaries mixed in the table with no way to
# tell them apart or to measure whether the swap helped.
stamp = {
"model": result.model,
"prompt_id": result.prompt_id,
"prompt_version": result.prompt_version,
"latency_ms": result.latency_ms,
}
source_urls = _source_urls(evidence)
source_evidence_ids = _source_evidence_ids(evidence)
if not source_urls and not source_evidence_ids:
@ -85,10 +124,11 @@ class InternalLlmAssistant:
confidence=0.0,
data={
"submission_id": submission_id,
"summary": summary,
"summary": result.text,
"source_urls": [],
"source_evidence_ids": [],
"verified": False,
**stamp,
},
)
@ -98,13 +138,26 @@ class InternalLlmAssistant:
confidence=0.0,
data={
"submission_id": submission_id,
"summary": summary,
"summary": result.text,
"source_urls": source_urls,
"source_evidence_ids": source_evidence_ids,
"verified": True,
**stamp,
},
)
def _summarize(self, evidence: list[Evidence]) -> LlmSummaryResult:
# Clients that predate provenance (and the test fakes) only implement
# summarize_evidence. Accept them and mark the row as unversioned rather
# than forcing every client to grow a new method.
detailed = getattr(self.client, "summarize_evidence_detailed", None)
if callable(detailed):
return detailed(evidence)
return LlmSummaryResult(
text=self.client.summarize_evidence(evidence),
model=str(getattr(self.client, "model", "") or ""),
)
def _source_urls(evidence: list[Evidence]) -> list[str]:
urls: list[str] = []

View file

@ -0,0 +1,30 @@
"""Versioned prompt text for the internal LLM assistant.
Separated from the client so the prompt has an identity that can be stamped onto
the evidence it produced. Without a version on the row, summaries written before
and after a prompt (or model) change are indistinguishable in the database, and
"did the new prompt help?" becomes unanswerable after the fact.
Bump PROMPT_VERSION whenever SYSTEM_PROMPT changes in a way that could alter
output — wording, format contract, or language.
"""
from __future__ import annotations
PROMPT_ID = "operator-review-note-ko"
PROMPT_VERSION = "ko-v2"
# Marks rows written before prompt versioning existed. Never assume the text
# below produced them.
LEGACY_PROMPT_VERSION = "legacy"
SYSTEM_PROMPT = (
"You draft review notes for an internal image-rights operator, "
"grounded ONLY in the provided source evidence. Reply in Korean. "
"Produce two short parts: (1) '요약:' one or two sentences on what "
"the evidence shows; (2) '확인 필요:' a brief list of what the "
"operator should verify before deciding. Do NOT state or recommend "
"a final decision (approve/hold/reject). Do NOT add any claim that "
"is not supported by the source evidence; if evidence is thin, say "
"so plainly."
)

View file

@ -1,7 +1,17 @@
from __future__ import annotations
from rights_filter.analysis.face_person_detection import TIER_BACKGROUND
from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
FACE_PERSON_IDENTIFIABLE_POINTS = 35
FACE_PERSON_BACKGROUND_POINTS = 10
# Stamped onto every submission at creation so a later weight change is
# distinguishable from a scoring bug. Bump this whenever the weights, the bucket
# rules, or the band thresholds below change — the stamp is what lets
# initialRiskScore be compared against a rescore and mean something.
SCORER_VERSION = "v1"
class RiskScorer:
def score(self, evidence: list[Evidence]) -> ScoreResult:
@ -17,6 +27,15 @@ class RiskScorer:
if item.source == EvidenceSource.FINGERPRINT:
if item.data.get("contributed") is False or item.data.get("status") == "queued":
continue
# A match against a CLEARED reference is a positive signal: the
# team already confirmed this image is safe to use. It must not
# add risk (a +80 here would push a known-safe image to the high
# band). Surface it as a reason so the operator sees the prior
# clearance, but contribute no points.
if item.data.get("knowledge_entry_type") == "cleared_reference":
if item.reason:
reasons.append(item.reason)
continue
similarity = _safe_float(item.data.get("similarity", 0))
if similarity >= 0.9:
score += 80
@ -32,7 +51,7 @@ class RiskScorer:
if counted_face_person:
continue
counted_face_person = True
score += 35
score += _face_person_points(item)
reasons.append(item.reason)
elif item.source == EvidenceSource.WEB_DETECTION:
bucket = _web_detection_bucket(item)
@ -90,6 +109,21 @@ class RiskScorer:
return ScoreResult(score=score, band=band, reasons=_unique(reasons))
def _face_person_points(item: Evidence) -> int:
# A recognizable subject and a bystander in a crowd are not the same portrait
# risk. The old flat +35 gave every photo containing any human enough to
# clear the medium threshold on its own, which is why the band distribution
# collapsed with nothing in the low band.
tier = str(item.data.get("tier", "")).strip()
if not tier:
# Rows written before the tier existed. Keep them at the original weight
# rather than silently rescoring history against a rule they predate.
return FACE_PERSON_IDENTIFIABLE_POINTS
if tier == TIER_BACKGROUND:
return FACE_PERSON_BACKGROUND_POINTS
return FACE_PERSON_IDENTIFIABLE_POINTS
def _web_detection_points(item: Evidence) -> int:
if item.data.get("weak_hint"):
return 0

View file

@ -33,6 +33,10 @@ class KnowledgeEntryType(StrEnum):
WEBTOON = "webtoon"
GAME = "game"
REJECTED_IMAGE = "rejected_image"
# A positive reference: an image the team has confirmed is safe to use
# (licensed / owned / public domain). A match argues FOR fast approval, not
# against it, so the scorer treats it as a cleared signal rather than +80.
CLEARED_REFERENCE = "cleared_reference"
OTHER = "other"
@ -58,6 +62,12 @@ class DataClass(StrEnum):
LLM_SUMMARY = "llm_summary"
PROVIDER_METADATA = "provider_metadata"
OPERATOR_NOTE = "operator_note"
# Whole-image semantic vector used ONLY for image-to-image similarity
# (crop / retouch / re-encode detection). Not a biometric template: faces are
# masked out before the vector is computed and person-dominant frames are
# excluded entirely. See the Scene Embedding Policy in
# docs/operations/image-rights-risk-filter.md (2026-07-25 개정).
SCENE_EMBEDDING = "scene_embedding"
@dataclass(frozen=True)

View file

@ -76,6 +76,16 @@ class GovernancePolicyRegistry:
"delete with decision audit policy",
"append correction rather than overwriting audit trail",
),
DataClass.SCENE_EMBEDDING: DataClassPolicy(
# Operators never read raw vectors — they read the similarity
# result, which is ordinary evidence. Keeping the vector out
# of the operator role is what makes "no identity lookup"
# enforceable rather than merely promised.
{"system", "admin"},
"derived-index retention policy: lives only as long as its source image",
"delete with the source image (cascade)",
"recompute from the corrected original",
),
}
)
@ -83,12 +93,80 @@ class GovernancePolicyRegistry:
return self._policies[data_class]
# A frame this dominated by people is a portrait: masking the faces would leave a
# vector that still describes the person (pose, body, hair, clothing), so it is
# excluded from indexing entirely rather than masked. Policy knob, not a tuning
# parameter — raising it widens what the Scene Embedding Policy permits.
MAX_PERSON_AREA_RATIO = 0.35
# Purposes a scene vector may be created for. Identity lookup is absent by
# construction: there is no value that authorizes it.
ALLOWED_SCENE_EMBEDDING_PURPOSES = frozenset({"image_similarity"})
_BIOMETRIC_KEYS = frozenset(
{
"embedding",
"face_embedding",
"biometric_template",
# Landmarks and descriptors are face-derived identity features too. YuNet
# returns 5-point landmarks alongside every detection, so name them
# explicitly rather than relying on callers to discard them quietly.
"landmarks",
"face_landmarks",
"face_descriptor",
"face_encoding",
}
)
def assert_no_biometric_template(payload: dict[str, Any]) -> None:
forbidden_keys = {"embedding", "face_embedding", "biometric_template"}
if forbidden_keys.intersection(payload):
if _BIOMETRIC_KEYS.intersection(payload):
raise ValueError("biometric template storage is not allowed")
def assert_scene_embedding_allowed(record: dict[str, Any]) -> None:
"""Gate a scene-embedding record against the Scene Embedding Policy.
The policy permits whole-image semantic vectors for image-to-image similarity
only. This asserts the two conditions that must have been applied upstream
(face masking, person-dominant exclusion) and the two that are structural
(purpose restriction, cascade-deletable source). It deliberately refuses a
record that merely omits an attestation: a vector whose provenance cannot be
stated is indistinguishable from one that skipped the masking step.
"""
if record.get("vector") is None:
raise ValueError("scene embedding record must carry a vector")
if not record.get("face_masked"):
raise ValueError("scene embedding requires face regions to be masked first")
ratio = record.get("person_area_ratio")
if ratio is None:
raise ValueError("scene embedding requires a measured person_area_ratio")
try:
ratio_value = float(ratio)
except (TypeError, ValueError) as exc:
raise ValueError("person_area_ratio must be numeric") from exc
if ratio_value > MAX_PERSON_AREA_RATIO:
raise ValueError(
"person-dominant image must not be indexed "
f"({ratio_value:.2f} > {MAX_PERSON_AREA_RATIO:.2f})"
)
purpose = str(record.get("purpose", ""))
if purpose not in ALLOWED_SCENE_EMBEDDING_PURPOSES:
raise ValueError(f"scene embedding purpose not allowed: {purpose or 'unset'}")
if not str(record.get("source_image_id", "")).strip():
# Without a source link the cascade delete in the policy cannot run, so
# the vector would outlive the image it was derived from.
raise ValueError("scene embedding must reference its source image")
# A scene vector lives in its own store; it must never be smuggled in as a
# face-keyed structure under one of the biometric names.
assert_no_biometric_template({key: value for key, value in record.items() if key != "vector"})
def assert_operator_evidence_payload_allowed(
data_class: DataClass, payload: dict[str, Any]
) -> None:

View file

@ -127,6 +127,8 @@ def build_server(
)
elif path == "/api/admin/backups":
self._json(store.backups())
elif path == "/api/stats/decisions":
self._json(store.decision_stats())
elif path.startswith("/media/"):
self._file(active_store().media_path(unquote(path.removeprefix("/media/"))), untrusted=True)
elif path.startswith("/knowledge-media/"):
@ -265,6 +267,16 @@ def build_server(
self._json(store.delete_decision_memo_preset(preset_id))
elif path == "/api/admin/backup":
self._json(store.create_backup())
elif path.startswith("/api/submissions/") and path.endswith("/clear-reference"):
submission_id = unquote(path.split("/")[3])
self._json(
store.register_cleared_reference(
submission_id,
str(body.get("label", "")),
str(body.get("note", "")),
active_store(),
)
)
elif path == "/api/providers/emergency-disable":
self._json(store.emergency_disable_external_providers())
else:

View file

@ -686,10 +686,17 @@ class CopyrighterStore(
# so an operator decision is not silently reverted by a concurrent
# lock-holding re-put of a stale submission payload.
with self._write_lock:
# Capture the displayed state before the decision changes anything.
# Read on its own connection but inside the write lock, so no
# concurrent writer can move it under us and the snapshot is exactly
# what the operator was looking at. See decision_feature_snapshot for
# why this cannot be reconstructed after the fact.
snapshot = self.decision_feature_snapshot(submission_id)
with self._transaction() as conn:
submission = self._get("submissions", submission_id, conn=conn)
submission["decisionStatus"] = decision
submission["applicantStatus"] = "승인 대기" if decision == "approved" else "반려 예정" if decision == "rejected" else "검토 중"
submission["decisionSnapshot"] = snapshot
self._put("submissions", submission_id, submission, conn=conn)
if decision in {"held", "rejected"}:

View file

@ -17,7 +17,11 @@ from typing import Any
logger = logging.getLogger(__name__)
from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector
from rights_filter.analysis.face_person_detection import (
HeuristicFacePersonDetector,
face_person_evidence_data,
face_person_reason,
)
from rights_filter.analysis.fingerprints import FingerprintService
from rights_filter.analysis.internal_analyzer import InternalAnalyzer
from rights_filter.analysis.preprocessing import build_external_derivative, build_face_crop_derivatives
@ -152,12 +156,9 @@ class StoreEnrichmentMixin:
evidence = Evidence(
source=EvidenceSource.FACE_PERSON,
reason="Face/person detected",
confidence=0.8,
data={
"face_count": signal.face_count,
"person_count": signal.person_count,
},
reason=face_person_reason(signal),
confidence=signal.confidence,
data=face_person_evidence_data(signal),
)
self._put(
"evidence",

View file

@ -203,6 +203,66 @@ class StoreOperationsMixin:
return []
return [fingerprints.perceptual]
def register_cleared_reference(
self,
submission_id: str,
label: str = "",
note: str = "",
image_store: LocalSubmissionImageStore | None = None,
) -> dict[str, Any]:
"""Record an approved image as a positive (cleared) reference.
Future submissions matching this fingerprint get a "previously cleared"
signal instead of being re-reviewed from scratch. Only images the
operator explicitly marks become references — approval alone does not, so
one-off approvals do not pollute the reference set.
"""
submission = self._get("submissions", submission_id)
sample_fingerprints = self._watchlist_fingerprints(submission_id, image_store)
if not sample_fingerprints:
stored = (submission.get("fingerprints") or {}).get("perceptual", "")
if stored:
sample_fingerprints = [str(stored)]
if not sample_fingerprints:
raise ValueError("이미지 지문을 계산할 수 없어 클리어 기준으로 등록할 수 없습니다")
entry_id = _stable_id("kb-cleared", submission_id)
with self._write_lock:
with self._transaction() as conn:
try:
existing = self._get("knowledge_entries", entry_id, conn=conn)
except KeyError:
existing = {}
entry = {
**existing,
"id": entry_id,
"name": label.strip()
or f"클리어: {submission.get('title', submission_id)} / {submission_id}",
"type": "cleared_reference",
"aliases": _unique_texts([submission_id, str(submission.get("title", ""))]),
"keywords": _text_list(existing.get("keywords")),
"memo": note.strip() or "승인 케이스에서 클리어 기준으로 등록",
"provenance": "manual",
"active": True,
# entry_status stays 'confirmed' to satisfy the schema CHECK;
# the cleared disposition is carried by the entry TYPE.
"entryStatus": "confirmed",
"sourceSubmissionId": submission_id,
"sampleFingerprints": sample_fingerprints,
"imageAsset": str(submission.get("asset", "")),
"imageFacts": submission.get("fileFacts", {}),
"registeredAt": _now_label(),
}
self._put("knowledge_entries", entry_id, entry, conn=conn)
self.add_audit_event(
"rights.ops",
"Cleared reference registered",
submission_id,
entry["name"],
conn=conn,
)
return self.bootstrap()
def rerun_enrichment(
self,
submission_id: str,

View file

@ -725,9 +725,19 @@ class StoreSearchCandidatesMixin:
for item in self._evidence_for_submission(submission_id)
]
score = RiskScorer().score(evidence)
# Only the live score moves. initialRiskScore / initialRiskBand /
# scorerVersion are the as-created stamp and must survive every rescore —
# they are the other half of the comparison that makes score-preservation
# tests non-vacuous.
submission["riskScore"] = score.score
submission["riskBand"] = score.band
submission["reasons"] = score.reasons or ["분석 근거 없음"]
if "initialRiskScore" not in submission:
# Rows created before the stamp existed. Their original score was
# already overwritten by this same method, so it is unrecoverable —
# mark them instead of fabricating one, so a later calibration can
# exclude them rather than silently trust a round-tripped value.
submission["scorerVersion"] = "legacy"
self._put("submissions", submission_id, submission)
def _rescore_all_submissions(self, queue_id: str | None = None) -> None:

View file

@ -18,6 +18,7 @@ from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from rights_filter.analysis.risk_scoring import SCORER_VERSION
from rights_filter.domain.records import (
Evidence,
EvidenceSource,
@ -109,6 +110,15 @@ def _submission_payload(
"asset": record["asset"],
"riskScore": score,
"riskBand": band,
# The score as first computed, never touched again. _rescore_submission
# overwrites riskScore/riskBand from 12 call sites, so without this stamp
# the stored score is always a round-tripped score and comparing it to a
# fresh rescore is tautological — every score-preservation test would
# pass vacuously. Once an operator decides a case the original is
# unrecoverable, so it has to be captured at creation.
"initialRiskScore": score,
"initialRiskBand": band,
"scorerVersion": SCORER_VERSION,
"submittedAt": submitted_at,
"submittedEpoch": submitted_epoch,
"lastAnalysis": _now_label(),
@ -306,11 +316,32 @@ def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]:
"knowledgeEntryId": str(evidence.data.get("knowledge_entry_id", "")),
"knowledgeEntryName": str(evidence.data.get("knowledge_name", "")),
"knowledgeEntryStatus": knowledge_entry_status,
"knowledgeEntryType": str(evidence.data.get("knowledge_entry_type", "")),
"sourceSubmissionId": str(evidence.data.get("source_submission_id", "")),
"similarity": evidence.data.get("similarity", ""),
"retrievedAt": _now_label(),
"contributed": source not in {"llm", "failure"} and not evidence.data.get("weak_hint", False),
# The LLM narration body previously lived only in the domain evidence and
# was dropped here, so the feature meant to help operators read the case
# faster produced text that was never shown. Persist it (and its source
# links) so the GUI can render the summary.
"summary": str(evidence.data.get("summary", "")),
"sourceUrls": list(evidence.data.get("source_urls", []) or []),
"sourceEvidenceIds": evidence.data.get("source_evidence_ids", []),
# Face signal (face rows only; empty elsewhere). faceTier drives the
# score, so losing it on the round trip would silently revert every
# background-face row to the identifiable weight on the next rescore.
"faceTier": str(evidence.data.get("tier", "")),
"faceDetector": str(evidence.data.get("detector", "")),
"faceAreaRatio": evidence.data.get("face_area_ratio", ""),
"faceCount": evidence.data.get("face_count", ""),
"personCount": evidence.data.get("person_count", ""),
# Reproducibility stamp (LLM rows only; empty elsewhere). latencyMs is the
# first real measurement of how long an operator waits on the assistant.
"modelVersion": str(evidence.data.get("model", "")),
"promptId": str(evidence.data.get("prompt_id", "")),
"promptVersion": str(evidence.data.get("prompt_version", "")),
"latencyMs": evidence.data.get("latency_ms", ""),
"status": "active",
"submission_id": submission_id,
}
@ -358,8 +389,21 @@ def _domain_evidence_from_ui(payload: dict[str, Any]) -> Evidence:
"knowledge_entry_id": payload.get("knowledgeEntryId", ""),
"knowledge_name": payload.get("knowledgeEntryName", ""),
"knowledge_entry_status": payload.get("knowledgeEntryStatus", ""),
"knowledge_entry_type": payload.get("knowledgeEntryType", ""),
"source_submission_id": payload.get("sourceSubmissionId", ""),
"similarity": payload.get("similarity", ""),
"summary": payload.get("summary", ""),
"source_urls": payload.get("sourceUrls", []),
"source_evidence_ids": payload.get("sourceEvidenceIds", []),
"model": payload.get("modelVersion", ""),
"prompt_id": payload.get("promptId", ""),
"prompt_version": payload.get("promptVersion", ""),
"latency_ms": payload.get("latencyMs", ""),
"tier": payload.get("faceTier", ""),
"detector": payload.get("faceDetector", ""),
"face_area_ratio": payload.get("faceAreaRatio", ""),
"face_count": payload.get("faceCount", ""),
"person_count": payload.get("personCount", ""),
},
)
@ -414,8 +458,20 @@ def _google_weak_label_title(title: str) -> str:
return title
# Data keys that move on every call without changing what the evidence asserts.
# Hashing them would hand the same summary a new id on each regeneration, so the
# id would encode "when it ran" instead of "what it says". Existing rows carry
# none of these keys, so excluding them leaves every stored id unchanged.
_VOLATILE_EVIDENCE_DATA_KEYS = frozenset({"latency_ms"})
def _evidence_id(submission_id: str, evidence: Evidence) -> str:
base = f"{submission_id}:{evidence.source}:{evidence.reason}:{json.dumps(evidence.data, sort_keys=True, default=str)}"
stable_data = {
key: value
for key, value in evidence.data.items()
if key not in _VOLATILE_EVIDENCE_DATA_KEYS
}
base = f"{submission_id}:{evidence.source}:{evidence.reason}:{json.dumps(stable_data, sort_keys=True, default=str)}"
return "ev-" + hashlib.sha256(base.encode("utf-8")).hexdigest()[:24]

View file

@ -38,6 +38,52 @@ INTERNAL_EXPORT_FIELDS = (
class StoreWorkflowMixin:
# ---- decision-time feature snapshot ---------------------------------
def decision_feature_snapshot(self, submission_id: str) -> dict[str, Any]:
"""Record what the operator actually saw at the moment they decided.
The queue sorts by riskScore descending and the case screen shows the
score, band and reasons before the decision buttons. A label collected
under that layout does not mean "this evidence predicts rejection"; it
means "the operator agreed with the score I showed them". Learning from
such labels re-learns the existing weights.
Capturing the displayed state at decision time is what makes that
circularity correctable later: with the snapshot, a model can condition
on (or hold out) what was shown. Without it, the labels are
indistinguishable from copies of the score, permanently. It has to be
written from the first decision onward — it cannot be reconstructed.
"""
submission = self._get("submissions", submission_id)
evidence = self._evidence_for_submission(submission_id)
shown = [
{
"id": str(item.get("id", "")),
"source": str(item.get("source", "")),
"matchType": str(item.get("matchType", "")),
"contributed": bool(item.get("contributed", False)),
"operatorStatus": str(item.get("operatorStatus") or ""),
}
for item in evidence
]
return {
"capturedAt": _now_label(),
# The score the operator was looking at — the queue's sort key.
"riskScore": submission.get("riskScore"),
"riskBand": submission.get("riskBand"),
"reasons": list(submission.get("reasons") or []),
# The as-created stamp, so a later analysis can tell whether the
# displayed score had already drifted from the original.
"initialRiskScore": submission.get("initialRiskScore"),
"initialRiskBand": submission.get("initialRiskBand"),
"scorerVersion": submission.get("scorerVersion", "legacy"),
"providerState": dict(submission.get("providerState") or {}),
"evidenceCount": len(shown),
"contributedCount": sum(1 for item in shown if item["contributed"]),
"evidence": shown,
}
# ---- batch decisions ------------------------------------------------
def record_decision_batch(
@ -142,6 +188,76 @@ class StoreWorkflowMixin:
)
return self.decision_memo_presets()
# ---- calibration / decision stats -----------------------------------
def decision_stats(self) -> dict[str, Any]:
"""Cross-tabulate risk band against the operator's actual decision.
The rule-based risk score is hand-tuned and has never been checked
against real decisions. This exposes the mismatch so it can be: a
high-risk case the operator approved is over-detection; a low-risk case
they rejected is under-detection. Everything is read from data already
stored (submissions + the decision audit trail).
"""
bands = ["high", "medium", "low", "failed", "pending"]
decisions = ["approved", "held", "rejected", "corrected", "unreviewed"]
matrix = {band: {decision: 0 for decision in decisions} for band in bands}
matrix_other: dict[str, int] = {decision: 0 for decision in decisions}
submissions = self._all("submissions")
unreviewed_epochs: list[int] = []
for submission in submissions:
band = str(submission.get("riskBand", "pending"))
decision = str(submission.get("decisionStatus", "unreviewed"))
if decision not in decisions:
decision = "unreviewed"
bucket = matrix.get(band, matrix_other)
bucket[decision] = bucket.get(decision, 0) + 1
if decision == "unreviewed":
unreviewed_epochs.append(int(submission.get("submittedEpoch", 0) or 0))
def _decided(band: str) -> int:
row = matrix.get(band, {})
return sum(row[d] for d in ("approved", "held", "rejected", "corrected"))
high_decided = _decided("high")
low_decided = _decided("low")
calibration = {
# High band the operator ended up approving -> the score cried wolf.
"highBandApprovalRate": _ratio(matrix["high"]["approved"], high_decided),
"highBandDecided": high_decided,
# Low band the operator rejected -> the score missed real risk.
"lowBandRejectionRate": _ratio(matrix["low"]["rejected"], low_decided),
"lowBandDecided": low_decided,
}
return {
"bands": bands,
"decisions": decisions,
"matrix": matrix,
"matrixOther": matrix_other,
"calibration": calibration,
"throughput": self._decision_throughput(),
"backlog": {
"unreviewed": len(unreviewed_epochs),
"total": len(submissions),
},
}
def _decision_throughput(self) -> dict[str, Any]:
# Group the decision audit events by calendar day (the timestamp label is
# "YYYY-MM-DD HH:MM:SS", so the date is a stable prefix).
by_day: dict[str, int] = {}
for event in self._decision_events():
day = str(event["timestamp"])[:10]
if day:
by_day[day] = by_day.get(day, 0) + 1
recent = sorted(by_day.items(), reverse=True)[:14]
return {
"byDay": [{"day": day, "count": count} for day, count in reversed(recent)],
"totalDecisions": sum(by_day.values()),
}
# ---- decision export -------------------------------------------------
def export_decisions(
@ -275,6 +391,10 @@ class StoreWorkflowMixin:
return memo
def _ratio(numerator: int, denominator: int) -> float:
return round(numerator / denominator, 3) if denominator else 0.0
def _rows_to_csv(rows: list[dict[str, Any]], fields: tuple[str, ...]) -> str:
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=list(fields), extrasaction="ignore")

View file

@ -0,0 +1,214 @@
"""Face signal tiering: a recognizable subject vs a bystander in a scene.
The old detector emitted one flat "a face is present" signal worth +35, which
every photo containing any human cleared the medium threshold on. These tests
pin the tier split, the legacy default, and — most importantly — that the tier
survives the payload round trip. A tier lost in serialization reverts silently
to the identifiable weight on the next rescore, which is invisible until the
band distribution drifts back.
"""
import numpy as np
import pytest
from rights_filter.analysis.face_person_detection import (
HAAR_FALLBACK_CONFIDENCE,
IDENTIFIABLE_FACE_AREA_RATIO,
TIER_BACKGROUND,
TIER_IDENTIFIABLE,
TIER_NONE,
DEFAULT_FACE_MODEL_PATH,
FACE_MODEL_PATH_ENV,
FacePersonSignal,
HeuristicFacePersonDetector,
_detector_state,
_signal_from_boxes,
face_person_evidence_data,
face_person_reason,
)
from rights_filter.analysis.preprocessing import ImagePayload
from rights_filter.analysis.risk_scoring import (
FACE_PERSON_BACKGROUND_POINTS,
FACE_PERSON_IDENTIFIABLE_POINTS,
RiskScorer,
)
from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.governance.policies import assert_no_biometric_template
from rights_filter.server.store_serialization import (
_domain_evidence_from_ui,
_evidence_payload,
)
FRAME = 1000 * 1000
@pytest.fixture(autouse=True)
def _reset_detector_cache():
# The detector is cached per thread; tests that change the model path must
# not inherit an instance built under the previous one.
for attribute in ("yunet", "yunet_failed"):
if hasattr(_detector_state, attribute):
delattr(_detector_state, attribute)
yield
def _face_evidence(**data) -> Evidence:
return Evidence(
source=EvidenceSource.FACE_PERSON,
reason="얼굴",
confidence=0.9,
data={"face_count": 1, "person_count": 1, **data},
)
# --- tier classification -----------------------------------------------------
def test_large_face_is_an_identifiable_subject():
# 10% of the frame — comfortably above the threshold.
signal = _signal_from_boxes([(0, 0, 316, 316)], 0.95, "yunet", FRAME)
assert signal.tier == TIER_IDENTIFIABLE
assert signal.face_area_ratio >= IDENTIFIABLE_FACE_AREA_RATIO
assert signal.confidence == 0.95
def test_small_face_is_a_background_person():
signal = _signal_from_boxes([(0, 0, 40, 40)], 0.8, "yunet", FRAME)
assert signal.tier == TIER_BACKGROUND
assert signal.present
def test_no_faces_is_tier_none_and_not_present():
signal = _signal_from_boxes([], 0.0, "yunet", FRAME)
assert signal.tier == TIER_NONE
assert not signal.present
def test_tier_uses_the_largest_face_not_the_count():
# A crowd of small faces plus one close subject is an identifiable case.
signal = _signal_from_boxes(
[(0, 0, 20, 20), (30, 30, 20, 20), (60, 60, 300, 300)], 0.9, "yunet", FRAME
)
assert signal.face_count == 3
assert signal.tier == TIER_IDENTIFIABLE
# --- scoring -----------------------------------------------------------------
def test_identifiable_face_scores_more_than_a_background_person():
identifiable = RiskScorer().score([_face_evidence(tier=TIER_IDENTIFIABLE)])
background = RiskScorer().score([_face_evidence(tier=TIER_BACKGROUND)])
assert identifiable.score == FACE_PERSON_IDENTIFIABLE_POINTS
assert background.score == FACE_PERSON_BACKGROUND_POINTS
assert background.score < identifiable.score
def test_row_without_a_tier_keeps_the_original_weight():
# Evidence written before tiering existed must not be rescored against a
# rule it predates.
legacy = RiskScorer().score([_face_evidence()])
assert legacy.score == FACE_PERSON_IDENTIFIABLE_POINTS
# --- round trip --------------------------------------------------------------
@pytest.mark.parametrize(
"data, expected",
[
({"tier": TIER_IDENTIFIABLE}, FACE_PERSON_IDENTIFIABLE_POINTS),
({"tier": TIER_BACKGROUND}, FACE_PERSON_BACKGROUND_POINTS),
({}, FACE_PERSON_IDENTIFIABLE_POINTS),
],
)
def test_face_score_survives_the_payload_round_trip(data, expected):
evidence = _face_evidence(**data)
direct = RiskScorer().score([evidence])
payload = _evidence_payload("SUB-1", evidence)
restored = RiskScorer().score([_domain_evidence_from_ui(payload)])
assert direct.score == expected
assert restored.score == expected
def test_face_detail_survives_the_payload_round_trip():
evidence = _face_evidence(
tier=TIER_BACKGROUND, detector="yunet", face_area_ratio=0.01, face_count=4, person_count=4
)
restored = _domain_evidence_from_ui(_evidence_payload("SUB-1", evidence))
assert restored.data["tier"] == TIER_BACKGROUND
assert restored.data["detector"] == "yunet"
assert restored.data["face_count"] == 4
# --- governance --------------------------------------------------------------
def test_face_evidence_never_carries_landmarks_or_descriptors():
# YuNet returns 5-point landmarks on every detection. They are read for
# nothing and must not reach any payload.
signal = _signal_from_boxes([(0, 0, 300, 300)], 0.9, "yunet", FRAME)
data = face_person_evidence_data(signal)
assert_no_biometric_template(data)
assert_no_biometric_template(_evidence_payload("SUB-1", _face_evidence(**data)))
def test_reason_distinguishes_the_two_tiers():
identifiable = face_person_reason(FacePersonSignal(1, 1, tier=TIER_IDENTIFIABLE))
background = face_person_reason(FacePersonSignal(1, 1, tier=TIER_BACKGROUND))
assert identifiable != background
# --- detector selection ------------------------------------------------------
def _blank_image() -> ImagePayload:
import cv2
blank = np.full((240, 320, 3), 200, dtype=np.uint8)
return ImagePayload(
content=cv2.imencode(".png", blank)[1].tobytes(),
width=320,
height=240,
metadata={},
)
@pytest.mark.skipif(
not DEFAULT_FACE_MODEL_PATH.is_file(),
reason="YuNet weights absent (fetched on the build machine, see offline-install.md)",
)
def test_yunet_runs_when_the_weights_are_present():
signal = HeuristicFacePersonDetector().detect(_blank_image())
assert signal.detector == "yunet"
assert not signal.present
def test_missing_weights_degrade_to_haar_instead_of_failing(monkeypatch, tmp_path):
# A missing optional weight must not stop an operator from reviewing.
monkeypatch.setenv(FACE_MODEL_PATH_ENV, str(tmp_path / "absent.onnx"))
signal = HeuristicFacePersonDetector().detect(_blank_image())
assert signal.detector in {"haar", "none"}
assert not signal.present
def test_haar_signal_does_not_claim_yunet_confidence():
signal = _signal_from_boxes([(0, 0, 300, 300)], HAAR_FALLBACK_CONFIDENCE, "haar", FRAME)
assert signal.detector == "haar"
assert signal.confidence == HAAR_FALLBACK_CONFIDENCE

View file

@ -1,5 +1,12 @@
from rights_filter.analysis.llm_assistance import FakeInternalLlmClient, InternalLlmAssistant
from rights_filter.analysis.llm_assistance import (
FakeInternalLlmClient,
InternalLlmAssistant,
LlmSummaryResult,
OllamaGenerateLlmClient,
)
from rights_filter.analysis.llm_prompts import PROMPT_ID, PROMPT_VERSION, SYSTEM_PROMPT
from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.server.store_serialization import _evidence_id
def test_llm_summary_cites_source_urls_and_evidence_ids():
@ -45,3 +52,67 @@ def test_llm_failure_becomes_enrichment_failure_evidence():
assert evidence.source == EvidenceSource.ENRICHMENT_FAILURE
assert "LLM assistance failed" in evidence.reason
# --- reproducibility stamp ---------------------------------------------------
class _StampingClient:
def summarize_evidence_detailed(self, evidence):
return LlmSummaryResult(
text="요약: 근거 1건.",
model="qwen2.5:3b-instruct",
prompt_id=PROMPT_ID,
prompt_version=PROMPT_VERSION,
latency_ms=1420,
)
def test_summary_evidence_carries_model_and_prompt_version():
# Without this stamp, summaries written before and after a model swap are
# indistinguishable in the table and the swap cannot be evaluated.
evidence = InternalLlmAssistant(_StampingClient()).summarize("submission-1", [])
assert evidence.data["model"] == "qwen2.5:3b-instruct"
assert evidence.data["prompt_id"] == PROMPT_ID
assert evidence.data["prompt_version"] == PROMPT_VERSION
assert evidence.data["latency_ms"] == 1420
def test_client_without_the_detailed_method_still_summarizes():
# Older clients (and the test fake) only implement summarize_evidence.
evidence = InternalLlmAssistant(
FakeInternalLlmClient(summary="요약: 근거 없음.")
).summarize("submission-1", [])
assert evidence.data["summary"] == "요약: 근거 없음."
assert evidence.data["prompt_version"] == "legacy"
def test_latency_does_not_change_the_evidence_id():
# latency_ms moves on every call. If it fed the id hash, regenerating the
# same summary would mint a new id each time and the id would encode when it
# ran rather than what it says.
base = {"submission_id": "s1", "summary": "요약", "source_evidence_ids": ["e1"]}
without = Evidence(EvidenceSource.LLM_SUMMARY, "r", 0.0, dict(base))
with_latency = Evidence(
EvidenceSource.LLM_SUMMARY, "r", 0.0, {**base, "latency_ms": 9999}
)
assert _evidence_id("s1", without) == _evidence_id("s1", with_latency)
def test_ollama_client_sends_the_versioned_system_prompt():
captured = {}
class _Transport:
def request_json(self, method, url, payload=None, timeout=None):
captured.update(payload or {})
return {"response": "요약: ok", "model": "qwen2.5:0.5b-instruct"}
result = OllamaGenerateLlmClient(transport=_Transport()).summarize_evidence_detailed([])
assert captured["system"] == SYSTEM_PROMPT
assert result.prompt_version == PROMPT_VERSION
assert result.model == "qwen2.5:0.5b-instruct"
assert result.latency_ms >= 0

View file

@ -1,11 +1,27 @@
import pytest
from rights_filter.domain.records import DataClass
from rights_filter.governance.policies import (
MAX_PERSON_AREA_RATIO,
GovernancePolicyRegistry,
assert_no_biometric_template,
assert_operator_evidence_payload_allowed,
assert_scene_embedding_allowed,
)
def _scene_record(**overrides):
record = {
"vector": [0.1, 0.2, 0.3],
"face_masked": True,
"person_area_ratio": 0.05,
"purpose": "image_similarity",
"source_image_id": "SUB-1",
}
record.update(overrides)
return record
def test_each_data_class_has_access_retention_delete_and_correction_policy():
registry = GovernancePolicyRegistry.default()
@ -42,6 +58,57 @@ def test_search_evidence_rejects_image_payloads_sent_to_naver():
raise AssertionError("expected Naver image payload rejection")
@pytest.mark.parametrize(
"key",
["embedding", "face_embedding", "biometric_template", "landmarks", "face_landmarks", "face_descriptor", "face_encoding"],
)
def test_face_derived_identity_features_are_all_rejected(key):
with pytest.raises(ValueError, match="biometric template"):
assert_no_biometric_template({key: [0.1, 0.2]})
def test_scene_embedding_is_allowed_when_face_masked_and_scene_dominant():
# The 2026-07-25 policy revision permits whole-image similarity vectors; this
# is the shape that must pass, or the revision has no effect.
assert_scene_embedding_allowed(_scene_record())
def test_scene_embedding_requires_face_masking():
with pytest.raises(ValueError, match="masked"):
assert_scene_embedding_allowed(_scene_record(face_masked=False))
def test_scene_embedding_rejects_person_dominant_frame():
with pytest.raises(ValueError, match="person-dominant"):
assert_scene_embedding_allowed(
_scene_record(person_area_ratio=MAX_PERSON_AREA_RATIO + 0.01)
)
def test_scene_embedding_requires_a_measured_person_area_ratio():
# An unmeasured frame is not the same as a scene-dominant one: without the
# measurement the exclusion rule never ran.
record = _scene_record()
del record["person_area_ratio"]
with pytest.raises(ValueError, match="person_area_ratio"):
assert_scene_embedding_allowed(record)
def test_scene_embedding_rejects_identity_lookup_purpose():
with pytest.raises(ValueError, match="purpose"):
assert_scene_embedding_allowed(_scene_record(purpose="identity_lookup"))
def test_scene_embedding_requires_source_image_for_cascade_delete():
with pytest.raises(ValueError, match="source image"):
assert_scene_embedding_allowed(_scene_record(source_image_id=""))
def test_scene_embedding_cannot_smuggle_a_face_embedding():
with pytest.raises(ValueError, match="biometric template"):
assert_scene_embedding_allowed(_scene_record(face_embedding=[0.9, 0.8]))
def test_llm_summary_must_reference_source_evidence():
try:
assert_operator_evidence_payload_allowed(

View file

@ -0,0 +1,125 @@
"""Tests for the stamps that must exist before the first operator decision.
Each one records something that becomes unrecoverable once real reviewing
starts: the as-created risk score (rescore overwrites the live one from a dozen
call sites) and the state the operator was actually looking at when they decided
(without it, a decision label is indistinguishable from a copy of the score the
screen showed them).
"""
from pathlib import Path
from rights_filter.analysis.risk_scoring import SCORER_VERSION
from rights_filter.integrations.env_clients import build_provider_runtime
from rights_filter.server.sqlite_store import CopyrighterStore
from tests.rights_filter.server.test_sqlite_store import FakeJsonTransport
from tests.rights_filter.server.test_store_workflow import _photo_store
def _store(tmp_path: Path) -> CopyrighterStore:
store = CopyrighterStore(
tmp_path / "copyrighter.sqlite3",
provider_runtime=build_provider_runtime(env={}, transport=FakeJsonTransport({})),
)
store.initialize()
store.seed_from_image_store(_photo_store(tmp_path / "submissions"))
return store
# --- initial score stamp -----------------------------------------------------
def test_new_submission_stamps_its_as_created_score_and_scorer_version(tmp_path: Path):
store = _store(tmp_path)
for submission in store.bootstrap()["submissions"]:
assert submission["initialRiskScore"] == submission["riskScore"]
assert submission["initialRiskBand"] == submission["riskBand"]
assert submission["scorerVersion"] == SCORER_VERSION
def test_rescore_moves_the_live_score_but_never_the_initial_stamp(tmp_path: Path):
store = _store(tmp_path)
submission = store.bootstrap()["submissions"][0]
submission_id = str(submission["id"])
initial_score = submission["initialRiskScore"]
initial_band = submission["initialRiskBand"]
# Drift the live score the way a weight change or an evidence edit would,
# then rescore: the live value must be recomputed and the stamp must not.
submission["riskScore"] = 93
submission["riskBand"] = "high"
store._put("submissions", submission_id, submission)
store._rescore_submission(submission_id)
rescored = store._get("submissions", submission_id)
assert rescored["riskScore"] != 93
assert rescored["initialRiskScore"] == initial_score
assert rescored["initialRiskBand"] == initial_band
assert rescored["scorerVersion"] == SCORER_VERSION
def test_legacy_submission_is_marked_not_backfilled_with_a_fabricated_score(tmp_path: Path):
# A row created before the stamp existed: its original score was already
# overwritten, so inventing one would silently pass off a round-tripped score
# as the original.
store = _store(tmp_path)
submission = store.bootstrap()["submissions"][0]
submission_id = str(submission["id"])
del submission["initialRiskScore"]
del submission["initialRiskBand"]
del submission["scorerVersion"]
store._put("submissions", submission_id, submission)
store._rescore_submission(submission_id)
stored = store._get("submissions", submission_id)
assert stored["scorerVersion"] == "legacy"
assert "initialRiskScore" not in stored
# --- decision-time snapshot --------------------------------------------------
def test_decision_records_the_score_and_evidence_the_operator_saw(tmp_path: Path):
store = _store(tmp_path)
submission = store.bootstrap()["submissions"][0]
submission_id = str(submission["id"])
shown_score = submission["riskScore"]
shown_evidence = store.review(submission_id)["evidence"]
store.record_decision(submission_id, "approved")
snapshot = store._get("submissions", submission_id)["decisionSnapshot"]
assert snapshot["riskScore"] == shown_score
assert snapshot["riskBand"] == submission["riskBand"]
assert snapshot["scorerVersion"] == SCORER_VERSION
assert snapshot["evidenceCount"] == len(shown_evidence)
assert {item["id"] for item in snapshot["evidence"]} == {
str(item["id"]) for item in shown_evidence
}
def test_decision_snapshot_captures_state_before_the_decision_is_applied(tmp_path: Path):
store = _store(tmp_path)
submission_id = str(store.bootstrap()["submissions"][0]["id"])
store.record_decision(submission_id, "rejected", "반려 사유")
stored = store._get("submissions", submission_id)
assert stored["decisionStatus"] == "rejected"
# The snapshot is the pre-decision view, so it must not carry the decision
# it triggered — otherwise it records the outcome instead of the input.
assert "decisionStatus" not in stored["decisionSnapshot"]
def test_batch_decisions_snapshot_every_case(tmp_path: Path):
store = _store(tmp_path)
ids = [str(item["id"]) for item in store.bootstrap()["submissions"]][:2]
store.record_decision_batch(ids, "approved")
for submission_id in ids:
assert store._get("submissions", submission_id)["decisionSnapshot"]["evidence"] is not None

View file

@ -0,0 +1,153 @@
"""Tests for the 2026-07-24 'three-punch' review-intelligence features:
calibration stats, cleared references, and the persisted LLM draft.
"""
from pathlib import Path
from rights_filter.analysis.risk_scoring import RiskScorer
from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.integrations.env_clients import build_provider_runtime
from rights_filter.server.sqlite_store import CopyrighterStore
from rights_filter.server.store_serialization import (
_domain_evidence_from_ui,
_evidence_payload,
)
from tests.rights_filter.server.test_store_workflow import _photo_store
from tests.rights_filter.server.test_sqlite_store import FakeJsonTransport
def _store(tmp_path: Path, *, duplicate: bool = False) -> CopyrighterStore:
store = CopyrighterStore(
tmp_path / "copyrighter.sqlite3",
provider_runtime=build_provider_runtime(env={}, transport=FakeJsonTransport({})),
)
store.initialize()
store.seed_from_image_store(_photo_store(tmp_path / "submissions", duplicate=duplicate))
return store
# --- Calibration stats -------------------------------------------------------
def test_decision_stats_cross_tabulates_band_against_decision(tmp_path: Path):
store = _store(tmp_path)
ids = [str(item["id"]) for item in store.bootstrap()["submissions"]]
store.record_decision(ids[0], "approved")
store.record_decision(ids[1], "rejected", "반려 사유")
stats = store.decision_stats()
total_decided = sum(
stats["matrix"][band][decision]
for band in stats["bands"]
for decision in ("approved", "held", "rejected", "corrected")
)
assert total_decided == 2
# Backlog reflects the still-unreviewed submissions.
assert stats["backlog"]["unreviewed"] == len(ids) - 2
assert "highBandApprovalRate" in stats["calibration"]
assert "lowBandRejectionRate" in stats["calibration"]
def test_decision_stats_calibration_flags_high_band_approvals(tmp_path: Path):
store = _store(tmp_path)
# Force both submissions into the high band, then approve both: 100% of
# high-band decisions were approvals -> maximal over-detection signal.
for submission in store.bootstrap()["submissions"]:
submission["riskBand"] = "high"
store._put("submissions", str(submission["id"]), submission)
for submission in store.bootstrap()["submissions"]:
store.record_decision(str(submission["id"]), "approved")
stats = store.decision_stats()
assert stats["calibration"]["highBandApprovalRate"] == 1.0
assert stats["calibration"]["highBandDecided"] == 2
# --- Cleared reference -------------------------------------------------------
def test_cleared_reference_match_scores_zero_and_is_surfaced():
match = Evidence(
source=EvidenceSource.FINGERPRINT,
reason="이전에 클리어된 이미지와 일치 0.99",
confidence=0.99,
data={
"knowledge_entry_id": "kb-cleared-1",
"knowledge_entry_type": "cleared_reference",
"similarity": 0.99,
},
)
scorer = RiskScorer()
result = scorer.score([match])
# A cleared match must not add risk (a raw >=0.9 fingerprint match is +80).
assert result.score == 0
assert any("클리어" in reason for reason in result.reasons)
# And it survives the persistence round-trip as a cleared match, not a +80.
restored = _domain_evidence_from_ui(_evidence_payload("SUB-1", match))
assert scorer.score([restored]).score == 0
def test_register_cleared_reference_creates_a_positive_knowledge_entry(tmp_path: Path):
store = _store(tmp_path, duplicate=True)
store.record_decision("alpha", "approved")
store.register_cleared_reference("alpha", "자사 보유 스톡", "라이선스 확인됨")
entry = next(
item
for item in store.bootstrap()["knowledgeEntries"]
if item.get("type") == "cleared_reference"
)
assert entry["sourceSubmissionId"] == "alpha"
assert entry["sampleFingerprints"]
# entry_status stays within the schema CHECK; disposition is on the type.
assert entry["entryStatus"] == "confirmed"
def test_cleared_reference_downranks_a_matching_resubmission(tmp_path: Path):
# alpha and alpha-copy are the same image. Clearing alpha should make a fresh
# analysis of the copy carry a cleared (0-point) match, not a +80 risk match.
store = _store(tmp_path, duplicate=True)
store.register_cleared_reference("alpha", "클리어 기준")
store.rerun_enrichment("alpha-copy", store.active_submission_image_store(
(tmp_path / "submissions")
))
review = store.review("alpha-copy")
cleared = [
item
for item in review["evidence"]
if item.get("knowledgeEntryType") == "cleared_reference"
]
assert cleared, "expected a cleared-reference match on the resubmission"
# --- LLM draft persistence ---------------------------------------------------
def test_llm_summary_body_survives_serialization():
summary = Evidence(
source=EvidenceSource.LLM_SUMMARY,
reason="Assistant summarized source-linked evidence",
confidence=0.0,
data={
"submission_id": "SUB-1",
"summary": "요약: 이 이미지는 웹에서 여러 페이지와 일치합니다.",
"source_urls": ["https://example.test/a"],
"source_evidence_ids": ["ev-1"],
"verified": True,
},
)
payload = _evidence_payload("SUB-1", summary)
assert payload["summary"].startswith("요약:")
assert payload["sourceUrls"] == ["https://example.test/a"]
restored = _domain_evidence_from_ui(payload)
assert restored.data["summary"] == summary.data["summary"]

View file

@ -7,7 +7,11 @@ from io import BytesIO
import pytest
from rights_filter.analysis.face_person_detection import FacePersonSignal
from rights_filter.analysis.face_person_detection import (
TIER_IDENTIFIABLE,
FacePersonSignal,
face_person_reason,
)
from rights_filter.analysis.preprocessing import ImagePayload, build_face_crop_derivatives
from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.server.image_store import LocalSubmissionImageStore
@ -19,6 +23,13 @@ from rights_filter.server.sqlite_store import CopyrighterStore
from rights_filter.integrations.env_clients import build_provider_runtime
# The reason the store writes for a close, recognizable face. Derived from the
# detector so a wording change does not have to be chased through assertions.
IDENTIFIABLE_FACE_REASON = face_person_reason(
FacePersonSignal(face_count=1, person_count=1, tier=TIER_IDENTIFIABLE)
)
class FakeJsonTransport:
def __init__(self, response):
self.response = response
@ -1060,7 +1071,7 @@ def test_sqlite_store_rescore_ignores_non_contributing_queued_rerun_marker(tmp_p
assert review["riskScore"] == 35
assert review["riskBand"] == "medium"
assert "Face/person detected" in review["reasons"]
assert IDENTIFIABLE_FACE_REASON in review["reasons"]
assert "재분석 요청이 접수됨" not in review["reasons"]
@ -8008,7 +8019,7 @@ def test_sqlite_store_migrates_google_best_guess_labels_to_non_scoring_hints(tmp
assert review["riskScore"] == 35
assert review["riskBand"] == "medium"
assert review["reasons"] == ["Face/person detected"]
assert review["reasons"] == [IDENTIFIABLE_FACE_REASON]
assert weak_label["title"] == "Google weak label gentleman"
assert weak_label["contributed"] is False
@ -8052,7 +8063,7 @@ def test_sqlite_store_rescores_existing_saved_google_best_guess_labels(tmp_path:
assert review["riskScore"] == 35
assert review["riskBand"] == "medium"
assert "Face/person detected" in review["reasons"]
assert IDENTIFIABLE_FACE_REASON in review["reasons"]
assert "Best guess label gentleman" not in review["reasons"]
assert weak_label["title"] == "Google weak label gentleman"
assert weak_label["contributed"] is False

View file

@ -17,6 +17,7 @@ def test_planned_module_boundaries_are_importable():
"rights_filter.analysis.risk_scoring",
"rights_filter.analysis.reason_builder",
"rights_filter.analysis.llm_assistance",
"rights_filter.analysis.llm_prompts",
"rights_filter.analysis.search_query_generation",
"rights_filter.analysis.search_result_promoter",
"rights_filter.integrations.external_policy",

View file

@ -113,7 +113,7 @@ const state = {
const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "audit"]);
const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "stats", "audit"]);
@ -1463,6 +1463,11 @@ function renderNoSelectedCase() {
// against nothing.
const nextActions = document.getElementById("evidence-next-actions");
if (nextActions) nextActions.innerHTML = "";
const llmDraft = document.getElementById("llm-draft");
if (llmDraft) {
llmDraft.hidden = true;
llmDraft.innerHTML = "";
}
}
@ -1478,6 +1483,8 @@ function renderCaseReview() {
}
renderLlmDraft(submission);
document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`;
const caseImageElement = document.getElementById("case-image");
@ -2398,6 +2405,15 @@ function switchView(view, updateHash = true) {
state.currentView = view;
// Stats are read on demand (a fresh cross-tab each open) rather than shipped
// in every bootstrap payload.
if (view === "stats") {
void loadDecisionStats();
}
document.querySelectorAll(".nav-button").forEach((button) => {
const active = button.dataset.view === view;
@ -3467,6 +3483,430 @@ async function createBackupSnapshot() {
// --- Cleared reference (positive knowledge) -----------------------------
async function registerClearedReference() {
const submission = getSelectedCase();
if (!submission) return;
const label = window.prompt(
"클리어 기준 이름 (예: 자사 보유 스톡 · 라이선스 확인됨)",
submission.title || submission.id,
);
if (label === null) return;
const note = window.prompt("메모(선택) — 왜 클리어인지", "") || "";
try {
const payload = await apiJson(
`/api/submissions/${encodeURIComponent(submission.id)}/clear-reference`,
{ method: "POST", body: JSON.stringify({ label: label.trim(), note }) },
);
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
// --- LLM draft (persist + show + pull into memo) ------------------------
function llmSummaryFor(submission) {
const evidence = submission && Array.isArray(submission.evidence) ? submission.evidence : [];
// The freshest LLM summary that actually carries body text.
return (
evidence
.filter((item) => item.source === "llm" && String(item.summary || "").trim())
.slice(-1)[0] || null
);
}
let currentLlmDraftText = "";
function renderLlmDraft(submission) {
const box = document.getElementById("llm-draft");
if (!box) return;
const summary = llmSummaryFor(submission);
if (!summary) {
box.hidden = true;
box.innerHTML = "";
currentLlmDraftText = "";
return;
}
currentLlmDraftText = String(summary.summary || "");
const urls = Array.isArray(summary.sourceUrls) ? summary.sourceUrls : [];
const sourceNote = urls.length ? `연결 근거 ${urls.length}건` : "연결 근거 없음";
box.hidden = false;
box.innerHTML = `
<div class="llm-draft-head">
<span class="llm-draft-tag">내부 요약(초안)</span>
<span class="muted small">${escapeHtml(sourceNote)} · 최종 판정은 운영자</span>
</div>
<p class="llm-draft-body">${escapeHtml(currentLlmDraftText)}</p>
<button type="button" class="llm-draft-use" id="llm-draft-use">메모 초안으로 넣기</button>
`;
}
function pullLlmDraftIntoMemo() {
const memo = document.getElementById("decision-memo");
if (!memo || !currentLlmDraftText) return;
memo.value = memo.value.trim()
? `${memo.value.trim()}\n${currentLlmDraftText}`
: currentLlmDraftText;
memo.focus();
}
// --- Decision statistics / calibration ----------------------------------
const DECISION_STAT_LABELS = {
approved: "승인",
held: "보류",
rejected: "반려",
corrected: "보정",
unreviewed: "미심사",
};
const BAND_STAT_LABELS = {
high: "고위험",
medium: "중위험",
low: "저위험",
failed: "분석실패",
pending: "대기",
};
async function loadDecisionStats() {
const body = document.getElementById("stats-body");
if (!body) return;
body.innerHTML = `<div class="empty-state">통계를 불러오는 중…</div>`;
try {
const stats = await apiJson("/api/stats/decisions");
renderDecisionStats(stats);
} catch (errorValue) {
body.innerHTML = `<div class="empty-state">통계를 불러오지 못했습니다: ${escapeHtml(errorValue.message)}</div>`;
}
}
function calibrationTone(rate, decided) {
// Not enough decisions to judge -> neutral, don't cry miscalibration on n=1.
if (decided < 5) return "neutral";
if (rate >= 0.4) return "bad";
if (rate >= 0.2) return "warn";
return "good";
}
function renderDecisionStats(stats) {
const body = document.getElementById("stats-body");
if (!body) return;
const cal = stats.calibration || {};
const throughput = stats.throughput || { byDay: [] };
const backlog = stats.backlog || { unreviewed: 0, total: 0 };
const overTone = calibrationTone(cal.highBandApprovalRate || 0, cal.highBandDecided || 0);
const underTone = calibrationTone(cal.lowBandRejectionRate || 0, cal.lowBandDecided || 0);
const tiles = `
<div class="stat-tiles">
<article class="stat-tile tone-${overTone}">
<span class="stat-tile-label">과탐 — 고위험인데 승인</span>
<strong class="stat-tile-value">${formatPercent(cal.highBandApprovalRate)}</strong>
<span class="stat-tile-sub">고위험 판정 ${cal.highBandDecided || 0}건 기준</span>
</article>
<article class="stat-tile tone-${underTone}">
<span class="stat-tile-label">미탐 — 저위험인데 반려</span>
<strong class="stat-tile-value">${formatPercent(cal.lowBandRejectionRate)}</strong>
<span class="stat-tile-sub">저위험 판정 ${cal.lowBandDecided || 0}건 기준</span>
</article>
<article class="stat-tile">
<span class="stat-tile-label">미심사 백로그</span>
<strong class="stat-tile-value">${backlog.unreviewed}</strong>
<span class="stat-tile-sub">전체 ${backlog.total}건 중</span>
</article>
<article class="stat-tile">
<span class="stat-tile-label">누적 판정</span>
<strong class="stat-tile-value">${throughput.totalDecisions || 0}</strong>
<span class="stat-tile-sub">감사 로그 기준</span>
</article>
</div>
`;
body.innerHTML = `
${tiles}
<div class="stats-columns">
<section class="stats-card">
<h2>밴드 × 판정 매트릭스</h2>
<p class="muted small">각 위험 밴드가 실제로 어떻게 판정됐는지. 대각선을 벗어난 값이 보정 오차입니다.</p>
${renderStatsMatrix(stats)}
</section>
<section class="stats-card">
<h2>최근 결정 추이</h2>
<p class="muted small">최근 14일 일별 판정 건수.</p>
${renderThroughputBars(throughput.byDay || [])}
</section>
</div>
`;
}
function renderStatsMatrix(stats) {
const bands = stats.bands || [];
const decisions = stats.decisions || [];
const matrix = stats.matrix || {};
const head = decisions.map((d) => `<th scope="col">${escapeHtml(DECISION_STAT_LABELS[d] || d)}</th>`).join("");
const rows = bands
.map((band) => {
const row = matrix[band] || {};
const total = decisions.reduce((sum, d) => sum + (row[d] || 0), 0);
const cells = decisions
.map((d) => {
const value = row[d] || 0;
const strong = value > 0 ? " has-value" : "";
return `<td class="matrix-cell${strong}">${value}</td>`;
})
.join("");
return `<tr><th scope="row"><span class="risk-badge ${riskClass(band)}">${escapeHtml(BAND_STAT_LABELS[band] || band)}</span></th>${cells}<td class="matrix-total">${total}</td></tr>`;
})
.join("");
return `
<div class="table-shell">
<table class="stats-matrix">
<thead><tr><th scope="col">밴드 \\ 판정</th>${head}<th scope="col">합계</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
function renderThroughputBars(byDay) {
if (!byDay.length) {
return `<div class="empty-state">아직 기록된 판정이 없습니다.</div>`;
}
const max = Math.max(...byDay.map((entry) => entry.count), 1);
const bars = byDay
.map((entry) => {
const height = Math.round((entry.count / max) * 100);
return `
<div class="throughput-bar" title="${escapeHtml(entry.day)} · ${entry.count}건">
<div class="throughput-bar-fill" style="height:${height}%"></div>
<span class="throughput-bar-count">${entry.count}</span>
<span class="throughput-bar-day">${escapeHtml(entry.day.slice(5))}</span>
</div>
`;
})
.join("");
return `<div class="throughput-chart" role="img" aria-label="일별 판정 건수">${bars}</div>`;
}
function formatPercent(rate) {
return `${Math.round((Number(rate) || 0) * 100)}%`;
}
async function rerunSelectedEnrichment() {
const ids = selectedBulkSubmissionIds();
@ -4691,6 +5131,14 @@ function bindEvents() {
document.getElementById("create-backup").addEventListener("click", createBackupSnapshot);
document.getElementById("clear-reference").addEventListener("click", registerClearedReference);
document.getElementById("llm-draft").addEventListener("click", (event) => {
if (event.target.closest("#llm-draft-use")) pullLlmDraftIntoMemo();
});
document.getElementById("memo-presets").addEventListener("click", (event) => {
if (event.target.closest("#memo-preset-save")) {

View file

@ -53,6 +53,11 @@
<span aria-hidden="true">P</span>
<span>외부 검색 tool 활용</span>
</button>
<button class="nav-button" type="button" data-view="stats">
<span aria-hidden="true">S</span>
<span>통계</span>
</button>
<button class="nav-button" type="button" data-view="audit">
<span aria-hidden="true">A</span>
<span>감사 로그</span>
@ -225,6 +230,7 @@
<span class="score-pill risk-pending" id="floating-case-score">대기</span>
</div>
<div class="recommendation-box" id="recommendation-box"></div>
<div class="llm-draft" id="llm-draft" hidden></div>
<label class="memo-field" for="decision-memo">
<span>결정 메모</span>
<textarea id="decision-memo" rows="3" placeholder="반려 또는 보정 시 메모 필수"></textarea>
@ -243,6 +249,7 @@
<button type="button" id="add-from-case">주의 후보 보기</button>
<button type="button" id="mark-irrelevant">증거 미사용 처리</button>
<button type="button" id="disable-derived">자동 항목 비활성</button>
<button type="button" id="clear-reference">클리어 기준 등록</button>
</div>
</details>
</aside>
@ -497,6 +504,17 @@
<div id="providers-list" class="provider-list"></div>
</section>
<section class="view" id="stats-view" data-view-panel="stats" aria-labelledby="stats-title" hidden>
<div class="section-header">
<div>
<p class="eyebrow">스코어 보정 · 처리량</p>
<h1 id="stats-title">판정 통계</h1>
<p class="section-sub">룰 기반 위험 점수가 실제 운영자 판정과 얼마나 맞는지, 백로그가 어디서 쌓이는지 봅니다.</p>
</div>
</div>
<div id="stats-body" class="stats-body"></div>
</section>
<section class="view" id="audit-view" data-view-panel="audit" aria-labelledby="audit-title" hidden>
<div class="section-header">
<div>

View file

@ -3005,3 +3005,144 @@ tbody tr.selected-row,
background: var(--red-tint);
border-color: var(--red-line);
}
/* --- LLM draft (grounded summary + pull into memo) -------------------- */
.llm-draft {
margin: 8px 0;
padding: 10px 12px;
background: var(--violet-tint);
border: 1px solid var(--violet-line);
border-radius: var(--radius);
}
.llm-draft[hidden] { display: none; }
.llm-draft-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.llm-draft-tag {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--violet);
text-transform: uppercase;
}
.llm-draft-body {
margin: 0 0 8px;
font-size: 0.84rem;
line-height: 1.6;
color: var(--ink);
white-space: pre-wrap;
}
.llm-draft-use {
padding: 4px 10px;
font-size: 0.75rem;
color: var(--violet);
background: var(--card);
border: 1px solid var(--violet-line);
border-radius: var(--r-pill);
cursor: pointer;
}
/* --- Stats / calibration view ---------------------------------------- */
.section-sub {
margin: 4px 0 0;
font-size: 0.82rem;
color: var(--ink-soft);
}
.stats-body { display: flex; flex-direction: column; gap: 18px; }
.stat-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.stat-tile {
display: flex;
flex-direction: column;
gap: 3px;
padding: 14px 16px;
background: var(--card-raised);
border: 1px solid var(--hair);
border-left: 3px solid var(--hair-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.stat-tile-label { font-size: 0.76rem; font-weight: 600; color: var(--ink-soft); }
.stat-tile-value { font-size: 1.7rem; font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.stat-tile-sub { font-size: 0.72rem; color: var(--ink-faint); }
.stat-tile.tone-good { border-left-color: var(--green); }
.stat-tile.tone-good .stat-tile-value { color: var(--green); }
.stat-tile.tone-warn { border-left-color: var(--amber); }
.stat-tile.tone-warn .stat-tile-value { color: var(--amber); }
.stat-tile.tone-bad { border-left-color: var(--red); }
.stat-tile.tone-bad .stat-tile-value { color: var(--red); }
.stat-tile.tone-neutral { border-left-color: var(--slate-line); }
.stats-columns {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 16px;
}
@media (max-width: 900px) {
.stats-columns { grid-template-columns: 1fr; }
}
.stats-card {
padding: 16px;
background: var(--card);
border: 1px solid var(--hair);
border-radius: var(--r-lg);
}
.stats-card h2 { margin: 0 0 2px; font-size: 0.95rem; color: var(--ink); }
.stats-matrix { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 0.82rem; }
.stats-matrix th, .stats-matrix td { padding: 7px 10px; text-align: center; border-bottom: 1px solid var(--hair-soft); }
.stats-matrix thead th { color: var(--ink-soft); font-weight: 600; font-size: 0.76rem; }
.stats-matrix th[scope="row"] { text-align: left; }
.matrix-cell { color: var(--ink-faint); font-variant-numeric: tabular-nums; }
.matrix-cell.has-value { color: var(--ink); font-weight: 600; }
.matrix-total { color: var(--ink-soft); font-weight: 700; font-variant-numeric: tabular-nums; }
.throughput-chart {
display: flex;
align-items: flex-end;
gap: 6px;
height: 160px;
margin-top: 12px;
padding-top: 8px;
}
.throughput-bar {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 2px;
height: 100%;
min-width: 0;
}
.throughput-bar-fill {
width: 100%;
max-width: 22px;
min-height: 2px;
background: var(--teal);
border-radius: 4px 4px 0 0;
}
.throughput-bar-count { font-size: 0.68rem; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
.throughput-bar-day { font-size: 0.62rem; color: var(--ink-faint); white-space: nowrap; }