Compare commits

...

54 commits

Author SHA1 Message Date
changukyu
2735ce1194 feat: 운영자 신원 기록 + 게시처 성격(언론·공식 / 무단 게시처) 표시
④ 감사 actor 31곳이 'rights.ops' 리터럴이라 누가 판정했는지 답이 없었다. ContextVar 로 요청
   범위 운영자를 걸고 스토어는 current_actor() 를 읽는다. 콘솔 좌측 하단 '운영자' 칸 →
   X-Operator 헤더(한글 percent-encode, 서버에서 제어문자 제거·40자). 이름 없으면 판정이
   막힌다. 인증이 아니라 기록 — 권한은 공유 토큰.
⑤ 기준 DB 유형 news_host / scraper_host. 점수는 바꾸지 않고 한 줄 요약에
   '(언론·공식 n · 무단 게시처 m)', 증거 호스트 칩 옆에 배지.

verify.ps1: ruff clean, 684 passed. 실렌더: 운영자 입력칸 표시 확인(44px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-26 14:46:20 +09:00
changukyu
177e365b55 fix: 페르소나 리뷰 권고 1~3 — 판정 시점 내보내기, '위험'은 검토 순서, 운영 모드 문서
① 내부 내보내기가 현재 점수·현재 사용 표시 대신 decisionSnapshot 을 읽는다. 드리프트는
   currentRiskScore·scoredVersion 열로 드러낸다. 스냅샷 없는 옛 행은 no-snapshot.
② 큐 열 '위험' → '검토 우선'(침해 판정 아님), 점수 구성 패널에 범위 한 줄, 가이드 명시.
③ docs/operations/operating-modes.md: 전체 egress / API 호스트만 / 완전 폐쇄에서 점수가
   뜻하는 것. 완전 폐쇄는 키를 빼야 한다(남겨 두면 큐 전체가 실패 하한 30).

verify.ps1: ruff clean, 681 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-25 18:24:26 +09:00
changukyu
7405ab3e0e docs: 페르소나 × 소크라테스 3단논법 프로젝트 리뷰
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-25 18:16:20 +09:00
changukyu
2661af86e5 feat: codex 검토 잔여 ③④⑤ + 밀린 부채 — 투입 충돌 표시, 사용/무관 분리, 측정 불가, VERSION
③ 파일명 stem 충돌: 다른 폴더의 같은 이름 파일이 한 건으로 합쳐져 둘째가 조용히 미심사였다.
   id 는 재유도하지 않고(기존 판정 보존) id_collisions() 로 탐지해 bootstrap.intakeConflicts 와
   파일당 1회 감사로 남기고, 콘솔 투입 상태줄에 경고 목록을 띄운다.
④ 행 버튼 '미사용' 이 false_positive 로 가 대표 근거 하나를 고르면 나머지 점수가 지워졌다 →
   '사용'/'무관', 사용 재클릭은 pending(표시 해제, 점수 유지).
⑤ 분모 0 비율은 None → '측정 불가'. 외부 검색이 꺼진 케이스는 점수 0 유지, 한 줄 요약에
   '외부 검색 꺼짐'.

부채: operator-review-workflow 13px 넘침 해소(겹치지 않는 레인 병합) → 다이어그램 5종 첫
REBUILD OK, v6 이전 카드 문구 교정. tests/conftest.py 로 로컬 Ollama 격리(가동 중에도 통과).
루트 VERSION 0.1.0 단일 출처, /health 와 기동 로그에 버전·스코어러 버전.

verify.ps1 (Ollama 가동 중, override 없음): ruff clean, 680 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-25 18:13:09 +09:00
changukyu
70e1bcb900 fix: 판정 정정 일관성 — 승인은 파생 주의 후보를 철회하고, 내보내기 메모는 마지막 판정의 것만
codex F4·4.2·4.3·4.5:
- 내보내기 메모가 마지막 비어 있지 않은 메모라 반려(메모)→승인(무메모) 뒤 지원자용 CSV 에
  반려 사유가 나갔다 → 마지막 판정의 메모만.
- 보류·반려→승인 시 그 판정이 만든 주의 후보를 자동 철회(excluded, 감사 기록). 운영자가
  승격한 확정 항목은 손대지 않고 감사에만 남긴다.
- 철회·제외된 항목에 걸려 있던 다른 케이스의 저장된 일치 행을 contributed=False 로 퇴역시키고
  리스코어 — 스코어러는 항목 상태가 아니라 저장된 행을 읽는다. 기존 exclude 경로에도 적용.
- 재보류·재반려가 excluded/confirmed 항목을 되살리지 않는다.

verify.ps1: ruff clean, 674 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-23 21:57:17 +09:00
changukyu
92fc8094d4 feat: 스코어러 v7 + 검색 대기 보류 — 주의 후보 일치는 다시 보라는 뜻이지 알려진 권리 대상이 아니다
codex F1: 보류·반려로 자동 생성된 주의 후보(watchlist)와의 일치가 확정 항목과 같은 80/high 를
받아, 검색 장애로 보류한 정상 이미지의 재제출이 알려진 권리 대상처럼 올라왔다.

① 스코어러: knowledge_entry_status == watchlist 는 별도 축 40/20(상한 40, 단독 medium),
   확정(승격·수동 등록)만 80. 한 줄 요약도 '주의 후보 일치 0.95' 로 구분한다.
② 스토어: record_decision(held) 가 has_coverage_gap()(실패·일일 한도 미수행)이면 주의 후보를
   만들지 않고 감사에 '검색 대기 (주의 후보 미생성)' 를 남긴다. 반려는 커버리지와 무관하게 생성.

가이드·PROJECT_MAP 갱신, 다이어그램 태그 v7 재생성(manifest 5종).
verify.ps1: ruff clean, 672 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-23 21:45:02 +09:00
changukyu
30772e93cf docs: codex 기능 검토 원문 보존 + 검증 결과·채택 순위 기록
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-23 21:29:17 +09:00
changukyu
29534f441a docs: 심사 가이드 다이어그램 — archify workflow 5번째, 운영자 관점의 심사 절차
docs/operations/review-guide.md 의 6단계를 3레인(읽기·근거 확인 / 판정 / 기준 DB 에 남는 것)
workflow 로: 한 줄 요약 → 점수 구성 → 증거 열기 → 근거 표시 → 판정 → 다음 미심사.
분기는 미수행 하한(보류 후 재분석)과 판정 뒤 DB(주의 후보 자동 · 클리어 기준 등록 수동).

첫 안은 4레인·3항목 카드로 1440x900 에서 127px 넘쳐 레인 병합·제목 한 줄·카드 2항목으로
압축했고, 노드 폭 148 로 높이 예산(종횡비 1.52)을 맞췄다. validate 9/9 showcase,
visual-check 4개 뷰포트 containment pass, manifest 5종 갱신. rebuild 스크립트·manifest
갱신기 목록에 등록, PROJECT_MAP 표 추가. 문서·다이어그램만 변경.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-23 19:51:54 +09:00
changukyu
1db2c30885 docs: 심사 가이드 v2 — 어떻게 심사하고 무엇을 보는가 (운영자용)
기존 operator-review-workflow 다이어그램은 시스템 흐름(폴더→분석→채점)이라 운영자의 질문
'어떻게 심사하고 무엇을 보는가'에 답하지 못했다. docs/operations/review-guide.md 로 다시 쓴다:
한 건 심사 6단계, 한 줄 요약의 신호와 판정 무게, 승인/보류/반려 기준과 뒤에 일어나는 일
(보류·반려 → 주의 후보, 승인은 DB 에 안 남으니 클리어 기준 등록 별도), 반려 사유 프리셋,
키보드, 일괄 처리, 헷갈리는 것, 종료 루틴. 콘솔 라벨은 index.html 문자열로 대조.

문서만 변경 — 코드·테스트 무변경.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-23 19:28:45 +09:00
changukyu
e3298d149c feat: 한 줄 요약(headline) — 운영자의 첫 1초를 사유 원문 덤프에서 사실·규모로
코퍼스 DB 로 실 콘솔을 띄워 보니 큐의 '상위 근거' 칸이 '구글 visually s... 구글 페이지
with ...'(사유 원문 둘을 잘라 붙인 것)이었고 증거 제목도 '구글 full image match found' 로
절반 영어였다.

스코어러가 점수 계산과 별개로 집계(_Tally)해 riskBreakdown.headline 에
'웹 완전일치 사본 4곳 · 식별 가능 인물 · 이름 있는 검색 승격 2건 · 라벨 9개' 를 싣는다.
순서는 예산표, 웹은 가장 강한 뷰만 호스트 수(곳)로, 등록 일치·클리어 이력·미수행·실패·
신뢰 출처 제외까지 한 줄. 큐 행과 케이스 요약 보드의 첫 줄이 이것으로 바뀌고
headline 없는 옛 케이스만 예전 배치. formatReason 에 Vision 매치 4종·검색결과 사본·
기준 DB 유사도 한국어 매핑. 요약 보드 첫 줄은 word-break: keep-all 로 구 단위 줄바꿈.

점수·밴드 변화 없음(SCORER_VERSION 유지). 실렌더로 큐·케이스 확인.
verify.ps1: ruff clean, 663 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-22 12:08:00 +09:00
changukyu
4b74f9577a feat: 스코어러 v6 — 외부 검색 미수행(일일 한도)의 조용한 통과를 막는다
배포 판단에서 파일럿 전 유일한 차단으로 꼽은 구멍. 네이버 일일 한도 100 = 20장이라
21장째부터 검색이 SEARCH_SKIPPED 0점·무사유로 빠져 점수가 낮게 나왔다.

정책 거절 사유 중 하루 안에서 케이스마다 달라지고 어디에도 안 보이는 하나(usage limit
reached)만 v5 의 실패 하한 30 에 합류시키고 사유에 '<provider> 미수행 — <사유>' 를 남긴다.
disabled 는 키 미설정 배포와 비상 차단이 같은 사유를 쓰고 둘 다 콘솔 providerState 에
보이므로 그대로 0점·무사유 — 하한을 걸면 키 없는 배포의 큐 전체가 medium 이 된다(기본
스토어 테스트가 잡아냈다). 거버넌스 스위치(compliance·metadata logging·online mode)도 0.

라운드트립은 신선 점수·사유를 재현한다(provider 는 domain 으로 복원). 다이어그램 태그 v6.

verify.ps1: ruff clean, 657 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-22 11:06:58 +09:00
changukyu
6017750bae feat: 스코어러 v5 — 축 예산을 총합 기준으로 재배분, 실이미지 100점 포화 20/20 → 0/20
첫 실측이 보인 원인은 축 예산의 합(245 vs 상한 100)이었다. 캡 없는 두 축만 캡하는
시뮬레이션(80/50, 60/40)으로는 20/20 이 그대로 100 이라 총합 기준으로 다시 짰다.

예산: 등록 권리 대상 일치 80(단독 high) · 웹 존재 40(검색결과 사본 유사도·Vision 매치·
CSE 페이지가 제공자 무관 한 풀, 뷰 강도로만 40/30/10) · 인물 30/10 · 이름 있는 검색
승격 15 · 라벨 10 → 등록 제외 최대 95. 외부 검색 실패는 +30/행 가산 대신 medium 하한.
검색결과 사본 행은 knowledge_entry_id 부재 + url/source_evidence_ids 로 웹 풀에 간다.

격자 432: high 92→75%, 포화 80→44%(남은 포화는 전부 등록 강한 일치 조합), 웹 신호만
high 13/24→0/24. 실이미지 20장 리스코어: 100점 20→0, 구별 점수 1→4(75/80/92/95),
밴드는 전부 high 유지(전부 웹에 있는 인물 사진 — medium/low 임계는 여전히 미검증).

다이어그램 spec 3종 v5 + 재생성(archify 2.17). operator-review-workflow 는 새
desktop-readability 검사에 HEAD 그대로 걸려 노드 폭 132→124 로 통과시켰고, 그 확대로
1440x900 세로 13px 넘침이 남는다(visual-check containment) — INDEX 에 기록.

verify.ps1: ruff clean, 654 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-22 10:31:53 +09:00
changukyu
352031cf45 docs: 실이미지 20장 첫 실측 기록 — 20/20 포화, 축 예산 합이 상한을 넘는다
sample/ 20장을 별도 DB 로 시드해 이 프로젝트 최초의 실이미지 측정을 했다.
증거 1,615행(Vision 809 + 네이버 692)이 붙었고 결과는 20장 전부 100점/high 다.

탐지는 정상이다 — 15장은 웹에서 완전일치(similarity 1.00) 사본이 발견됐고
4장은 근사일치다. 문제는 밴드가 아니라 순서가 없다는 것이다. 큐 기본 정렬이
riskScore 내림차순인데 전부 100 이라 정렬이 no-op 이고, 운영자는 구별되지 않는
20건을 받는다.

근본 원인은 축별 예산의 합이다.

  인물            35
  웹 게시         60  (캡 있음)
  웹 엔티티       20  (캡 있음)
  검색결과 유사도  캡 없음 — 장당 평균 216
  네이버 승격      캡 없음 — 장당 평균 70

앞의 세 축만 더해도 115 로 이미 상한 100 을 넘는다. v2~v4 가 축을 하나씩
캡했지만 합을 한 번도 보지 않았다 — 어제 만든 432조합 격자가 high 92% /
포화 80% 를 보고했는데 실이미지에서는 100% 다.

캡 없는 두 축에 캡을 씌우는 것으로는 안 된다. 지문 80/네이버 50 과 60/40 을
실제 코퍼스 데이터로 시뮬레이션했으나 20/20 이 그대로 100 이었다. 축 예산을
총합 기준으로 재배분해야 하고 그것은 제품 결정이라 임의로 하지 않았다.

검색결과 유사도가 +80/건 무제한인 것은 v2 가 고친 것과 같은 논리 오류다.
같은 이미지가 웹에 있다는 하나의 사실을 발견된 URL 수만큼 세고 있다.

코퍼스 한계: 20장 전부 웹 유통 인물 사진이라 low 기대군이 0건이다. 이
코퍼스로는 medium/low 임계를 검증할 수 없다(lowBandRejectionRate 분모 0).

첫 시도는 내 스크립트가 .env 로딩(__main__.py:70)을 빠뜨려 프로바이더가 전부
비활성이었다. 외부 호출 0콜이라 쿼터 손실은 없었고, 그 무효 런이 부수적으로
두 가지를 알려줬다 — 웹 증거가 0건이어도 인물만으로 35/medium 이 되고,
external_skipped 는 점수에도 사유에도 뜨지 않아 키 만료나 쿼터 고갈이 조용히
지나간다.

sample/ 과 corpus/ 를 gitignore 에 넣었다. 저작권 실이미지가 납품 저장소를
따라가면 안 된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-22 09:26:22 +09:00
changukyu
c740c2e6ba feat: 캘리브레이션 측정 도구 + 스코어러 구조적 거동 첫 실측
SCORER_VERSION 이 v1 에서 v4 까지 갔는데 v3(제공자별 풀링)와 v4(신뢰 출처)는
밴드 분포를 한 번도 측정하지 않고 나갔다. 2026-08-08 평가가 쓴 224조합 하네스는
저장소에 남아 있지 않고, 그 문서의 두 수치(174/224=77.7% 와 82.6%)가 서로 맞지
않아 격자가 달랐음을 시사한다 — 과거 숫자와 비교 가능한 재현은 불가능하다.
그래서 격자를 스크립트에 명시적으로 고정해 새 기준선으로 삼는다.

  scripts/score_distribution.py   432조합 전수, 사람 없이 돈다
  scripts/sweep_thresholds.py     판정 시점 스냅샷 기준 임계 스윕
  docs/operations/calibration-corpus.md  코퍼스 절차

실측 결과(v4, 432조합):

                     신뢰출처 미등록   신뢰출처 등록
  high                      92%            63%
  100점 포화                80%            38%
  웹 신호만으로 high      13/24           0/24

두 가지가 드러났다.

1. v2 가 세운 "웹 존재만으로는 high 가 되지 않는다" 원칙이 이미 깨져 있다.
   presence 풀(최대 60)과 entity 풀(최대 20)이 각각 임계 아래로 캡되지만 합은
   캡되지 않아, full+partial + 엔티티 1개만으로 80/high 가 된다.

2. v3 의 제공자별 풀링이 그 경향을 키웠다. 독립 제공자가 하나 더 있으면 웹 전용
   조합의 high 가 4/12 -> 9/12, 전체 high 가 88% -> 97% 가 된다. 교차 확증이
   high 에 닿아야 한다는 v3 의 의도대로 동작한 결과지만 총량 통제가 없다.

대응 후보(웹 유래 점수 총합 상한)는 적용하지 않았다. 적용하면 또 한 번의
미측정 변경이 되고, 이번 실측이 바로 그 패턴을 지적한 것이기 때문이다.
결정 후 진행한다 — 적용 시 SCORER_VERSION 을 올리고 이 표를 다시 측정한다.

이 격자는 신호 축을 균등 샘플링하므로 실제 큐 분포가 아니다. 92% 는 "실제 큐의
92% 가 high"라는 뜻이 아니고 임계가 맞는지도 말해주지 않는다. 구조적 거동의
기준선일 뿐이다.

임계 검증은 실이미지 30~50장과 **사람의 판정**이 있어야 성립한다. 내가 판정을
만들면 도구의 밴드를 내 라벨과 대조하는 꼴이라 아무것도 검증되지 않는다.
sweep_thresholds.py 는 그때를 위한 도구이고, 지금은 usable pairs 0 을 정직하게
보고한다(가짜 판정 6건을 넣은 사본 DB 로 출력 동작은 확인했다).

검증: ruff clean, pytest 645 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-22 08:56:57 +09:00
changukyu
e169e24584 feat: 반려 키보드 완결 — 가장 중요한 경로가 가장 느렸다
승인·보류는 A/H 한 키에 자동 다음 케이스 이동까지 되는데 반려만 메모가
필수라 흐름이 끊겼다. R 은 메모 필드에 포커스만 줬고, 입력 중에는 전역
단축키가 막히므로(J/K 가 글자로 들어가면 안 되니 의도된 동작) 거기서부터는
마우스로만 제출할 수 있었다. 저작권 심사에서 법적으로 가장 중요한 판정이
가장 느린 경로였다.

  R        사유 선택 모드 (프리셋에 1~9 번호가 보인다)
  1~9      그 사유로 즉시 반려 + 다음 미심사 케이스로
  M        직접 입력으로 전환
  Esc      취소
  메모 안에서 Ctrl+Enter   자유 서술 반려 제출

화면의 번호가 곧 눌러야 할 키다. 프리셋 칩에 키 배지를 붙여 단축키를 외우지
않아도 되게 했다.

숫자는 사유 선택 모드 안에서만 의미를 갖는다. 모드 밖에서 숫자에 의미를 주면
잘못 누른 키 하나로 반려가 나간다 — 되돌릴 수 있는 동작이지만 주의목록 항목을
만들고 감사에 남으므로 사고로 일어나서는 안 된다. 가드가 숫자 분기보다 먼저
오는지를 테스트가 위치로 확인한다.

프리셋이 하나도 없으면 고를 것이 없으므로 모드를 열지 않고 예전처럼 메모
필드로 보낸다.

실렌더(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
  R -> 2      {"decision":"rejected","memo":"라이선스 확인 불가로 반려"}
  메모 + Ctrl+Enter  {"decision":"rejected","memo":"자유 서술 사유"}
콘솔 에러 0.

검증: ruff clean, pytest 645 passed (정적 3건 추가).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 18:41:22 +09:00
changukyu
0c60369da0 feat: 점수 구성 패널 — "증거 51개인데 왜 35점"에 답한다
이건 내가 만든 부채를 갚는 커밋이다. 스코어러에 풀 상한(v2/v3)과 신뢰 출처
제외(v4)를 넣으면서 증거 개수와 점수의 간극이 그 어느 때보다 벌어졌는데,
그 이유가 화면 어디에도 없었다. 운영자가 숫자를 믿지 못하면 그 우선순위는
우선순위가 아니고, 그러면 앞의 두 기능이 오히려 불신을 만든다.

ScoreResult.breakdown 을 신설했다. reasons 가 "어떤 증거가 말했는가"라면
breakdown 은 "그래서 몇 점인가"다.

  지식DB 지문 매칭        80
  웹 게시 (google)        60   원래 125 · 상한 60
  웹 엔티티 라벨          20   원래 114 · 상한 20
  신뢰 출처 posar.info         3행 제외
  합계 115점이 상한 100에 걸렸습니다 — 이 구간의 케이스는 서로 구별되지 않습니다

마지막 줄이 특히 중요하다. 100 에 걸린 케이스들은 서로 구별되지 않으므로
우선순위 판단에 쓸 수 없는데, 지금까지는 그 사실이 숨어 있었다.

자리는 증거 요약과 증거 목록 사이다 - "51개 증거"를 읽은 직후, 목록을 훑기
직전이 바로 그 질문이 생기는 지점이다.

점수는 하나도 바뀌지 않는다. 계산 흐름에 기록만 붙였고 기존 스코어러 테스트
31건이 그대로 통과한다. 가중치·버킷·밴드가 불변이므로 SCORER_VERSION 은 v4 를
유지한다(설명을 추가하는 것은 스탬프 규칙의 대상이 아니다).

분해 합계가 항상 점수와 일치하는지를 테스트로 고정했다. 분해가 점수와 다른
이야기를 하면 그건 분해가 아니라 또 하나의 주장이 된다.

실렌더(Playwright 1440x900): 패널이 의도한 자리에 서고 콘솔 에러 0.

검증: ruff clean, pytest 642 passed (스코어러 5건 + 정적 1건 추가).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 18:07:08 +09:00
changukyu
975c252201 feat: 증거 일괄 표시 — 51번 클릭하던 되먹임을 1번으로
8/8 실효성 평가가 "운영자 되먹임이 순증 노동"이라고 적은 항목이다. 스코어러는
점수를 만든 행이 표시돼야 점수를 내려놓는데, 단건 API(/api/evidence/{id}/status)
하나뿐이라 실 DB 제출물 하나를 100 에서 35 로 내리려면 google 증거 51행을
개별로 눌러야 했다. 되먹임이 노동보다 비싸면 아무도 되먹이지 않는다.

증거 그룹 헤더의 개수 옆에 [전부 무관] 을 붙이고, POST /api/evidence/status-batch
를 신설했다.

서버는 한 트랜잭션, 리스코어 1회, 감사 이벤트 1건으로 묶는다. 감사 부분은 그
자체로 중요하다 — 이건 한 번의 운영자 동작이고, 51건을 쓰면 동작을 잘못
표현할 뿐 아니라 500행 감사 캡을 클릭 한 번에 먹는다. 행별 who/when/note 는
각 증거 payload 에 그대로 남으므로 개별 행이 설명 불가가 되지는 않는다.

원자성: 남의 제출물 증거 id 가 섞이면 아무것도 쓰지 않고 전체가 실패한다.
절반만 표시된 상태로 끝나면 운영자는 무엇이 반영됐는지 알 수 없다.

상한은 500 이다. 일괄 판정 상한(200)보다 큰 이유는 단위가 다르기 때문이다 —
그쪽은 케이스 수고 이쪽은 한 케이스 안의 증거 행 수다.

그룹 액션은 irrelevant(무관)로 보낸다. 행별 "미사용" 버튼은 false_positive
(오탐)에 매핑돼 있는데, 한 제공자의 결과 전체를 오탐이라 단정하는 것은 과한
주장이다. 점수 효과는 같고(스코어러는 둘 다 건너뛴다) 주장만 정직해진다.

확인 대화는 두지 않았다. 이 동작은 행마다 되돌릴 수 있고, 여기서 줄이려는
것이 바로 그 클릭이다. 되돌릴 수 없는 느낌이 드는 것은 판정이고 확인 대화는
거기에만 있다.

실렌더(Playwright 1440x900, 실제 서버): 51행 그룹에서 1타로
{"status":"irrelevant","evidence_ids":[51개]} 전송, 콘솔 에러 0.

검증: ruff clean, pytest 636 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 14:34:05 +09:00
changukyu
994852d920 feat: 신뢰 출처 — 자사·계약처 도메인은 위험 신호가 아니다 (SCORER_VERSION v4)
8/8 실효성 평가가 "남은 것: 신뢰출처 판정 자체. 목록만 정해지면
cleared_reference 와 같은 패턴으로 바로 붙는다"로 남겨둔 항목이다. 전제였던
"호스트가 저장 시점에 파괴되는" 문제는 9/18 에 고쳤고, 저장 위치는 지식DB 로
정해졌다.

  지식DB 의 trusted_host 항목 — 이름이 호스트, 별칭이 같은 주체의 다른 도메인
  신뢰 철회는 기존 활성/비활성 수명주기 그대로 (두 번째 스위치를 만들지 않는다)

핵심은 **행 단위 제외**라는 것이다. 케이스 단위로 끄면 자사 사본이 있다는
이유로 스크래퍼 게시까지 덮인다. 실측: 우리 CDN 완전일치 + 블로그 게시가 같이
있을 때 신뢰 등록 전 80/high, 등록 후 35/medium — 블로그만 남는다.

제외된 행은 점수에서 빠지되 사유로는 남는다("신뢰 출처 posar.info — 위험
점수에서 제외"). 증거 목록에는 그 행이 그대로 보이므로, 왜 점수가 증거보다
조용한지 운영자에게 말해주지 않으면 도구를 불신하게 된다.

경계 처리:
  서브도메인은 같은 주체다 (cdn.posar.info)
  posar.info.evil.test 는 접미사가 아니라 다른 도메인이다
  점 없는 값은 무시한다 — "com" 하나가 웹 전체를 신뢰 처리하면 안 된다
  엔티티 라벨은 영향 없다 — "어디에 떠 있나"가 아니라 "무엇이 찍혔나"다
  신선한 CSE 행은 host 키가 없어 domain(displayLink)에서 해석한다

호스트 해석이 store_serialization._result_host 와 따로 사는 이유는 층 때문이다.
analysis 는 server 를 import 할 수 없고, 한쪽은 값을 영속화하고 다른 쪽은
운영자 목록과 대조한다. 우선순위는 같게 맞추고 그 사실을 양쪽 주석에 적었다.

빈 목록이면 v3 과 동작이 같다. 등록하지 않은 배포는 아무것도 달라지지 않는다.

다이어그램 사실 게이트(9/21 신설)가 v3 -> v4 를 3개 다이어그램에서 자동으로
잡았다. 스펙을 고치고 rebuild-diagrams.ps1 로 재생성했다 — 게이트의 첫 실사용이고
의도대로 동작했다.

검증: ruff clean, pytest 632 passed (스코어러 7건 + 스토어 3건 추가).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 12:18:31 +09:00
changukyu
4e8e4b3181 feat: 선례 승계 — 재제출을 다시 심사하지 않는다
8/8 실효성 평가의 결론은 "검색·기록 노동은 줄여주나 심사 건수는 전혀 못 줄인다"
였다. 이 커밋은 그 축을 직접 건드리는 첫 기능이다.

중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능이다 — 재저장(JPEG q90)
/80%축소/50%축소/밝기+10% 변형 24건 중 24건 그룹핑, 같은 24건에서 SHA-256
완전일치는 0건. 그런데 그 결과를 쓰는 곳이 없었다. 서버는 store_duplicates.py
에서 priorDecisions 와 conflicting 을 계산해 내려보내는데, 콘솔은 그것을 큐
배지의 title 속성에만 넣었다. 마우스를 올린 사람만 "이거 지난주에 반려한
이미지"임을 알 수 있었고, 재제출은 처음부터 다시 심사됐다.

서버는 건드리지 않았다. 자리만 옮겼다.

  큐 배지        "동일 이미지 3건 · 선례 반려" (title 이 아니라 본문에)
  판정 패널      결정 메모 바로 위에 선례 배너 + [선례 승계] 1타

승계는 별도 판정 경로를 만들지 않는다. 메모를 채워 기존 setDecision 을 그대로
타므로 반려 메모 필수 검사 · 판정 시점 스냅샷 · 감사 이벤트가 유지되고, 승계
사실은 메모로 감사에 남는다("선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은
판정").

판정 불일치는 승계 대상이 아니다. 같은 이미지에 서로 다른 판정이 있다는 것은
자동 처리할 상황이 아니라 사람이 봐야 하는 신호이므로, conflicting 이면 버튼을
아예 내지 않고 색을 갈라 표시한다.

실렌더 확인(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
세 상태(승계 가능 / 판정 불일치 / 선례 없음)의 배너 문구와 버튼 유무가 의도대로,
승계 클릭 시 POST 본문이 위 메모 그대로, 콘솔 에러 0, 가로 오버플로 없음.

검증: ruff clean, pytest 622 passed (정적 3건 추가).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 12:05:38 +09:00
changukyu
624f435b82 docs: 부채 표의 두 수치를 직접 실측값으로 정정
감사 에이전트가 낸 수치를 그대로 부채 표에 옮겼는데, 저장소에 사실로 박히는
값이라 직접 재현해 확인했다.

  architecture 라벨 임계: 19·20자 통과, 21·22자 하드 실패 — 주장대로였다.
  lifecycle 캐스케이드: 상태 1개 + 전이 1개 추가에 오류 5건, 그중 손대지 않은
  기존 t-reject 관련이 3건. 표에는 4건으로 적혀 있었다.

검증 조건(waiting 레인 col 1)도 함께 적어 다음 사람이 같은 실측을 재현할 수
있게 했다. 캐스케이드가 실재한다는 결론은 그대로다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 11:33:32 +09:00
changukyu
be5d42a418 test: 코드↔그림 리터럴 드리프트 게이트, 다이어그램을 내부 문서로 확정
용도를 내부 문서로 못박았다. 고객 인수물이 아니므로 납품 품질 기준을 적용하지
않는다 — 감사에서 나온 한글 폰트 미번들과 영어 뷰어 UI 는 archify 한계라 spec
으로는 못 고치는데, 사내 머신에는 한글 폰트가 있으므로 low 로 내렸다. 승격
조건(그 둘이 먼저 blocker)을 PROJECT_MAP 다이어그램 절에 적어 두었다.

그 위에 두 번째 게이트를 올린다. 기존 test_diagram_sync 가 "산출물이 spec 보다
낡았나"를 본다면, test_diagram_facts 는 "spec 이 코드보다 낡았나"를 본다.
그림에는 코드에서 뽑은 상수가 라벨과 카드 문장으로 하드카피돼 있어서,
SCORER_VERSION 을 올리거나 믹스인을 하나 추가하면 코드는 통과하고 그림만
조용히 틀려졌다. CLAUDE.md 가 그 값들을 규칙으로 못박아 두었는데 정작 같은
값을 그린 다이어그램에는 보호가 없었다.

  SCORER_VERSION · SCHEMA_VERSION · 믹스인 개수 · app-*.js 개수 · 바디 상한
  · 인증 실패 10회/60초 · 프롬프트 12행 상한 · 풀 상한 60/20 · 기본 모델
  · 런타임 의존성 3종

"지문 유사도 0.9+ -> 80점" 은 이름 있는 상수가 아니라 risk_scoring 안의 인라인
리터럴이라, import 대신 실제로 채점해서 확인한다. 라벨이 주장하는 값과 스코어러가
내는 값이 같은지를 직접 본다.

차단을 실증했다 — SCORER_VERSION v3->v4 로 3건, MAX_PROMPT_EVIDENCE 12->16 으로
2건이 실패한다. 영향받는 다이어그램 전부에서 잡힌다.

발견 가능성도 정리했다. 감사에서 "3.3MB 산출물이 어떤 문서 색인에도 없다"가
나왔으므로 README 색인에 등재하고, PROJECT_MAP 까지 가지 않아도 되도록
docs/diagrams/README.md 에 직접 수정 금지·재생성 명령·CSP 주의를 두었다.

검증: ruff clean, pytest 619 passed (사실 게이트 +19).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 11:32:12 +09:00
changukyu
93a4a82bff test: 다이어그램 드리프트 게이트 신설, 재생성 절차를 실행 가능하게
감사에서 나온 결론은 "그림은 지금 정확하지만 정확함을 지킬 장치가 없다" 였다.
코드 유래 리터럴 18개가 전부 코드와 일치하고 deliver 는 바이트 결정적인데,
드리프트를 막는 게이트·테스트·훅이 0개였고 문서에 적힌 재생성 절차는 그대로는
실행되지 않았다 — `archify` 실행 파일은 PATH 에 없고 실제로는 저장소 밖 전역
스킬을 node 로 불러야 한다. 다음 사람은 첫 명령에서 막혔을 것이다.

  scripts/rebuild-diagrams.ps1       validate -> deliver -> visual-check -> manifest
  scripts/update_diagram_manifest.py 수신증의 단일 작성자
  docs/diagrams/manifest.json        spec/html sha256 + generator 버전
  tests/test_diagram_sync.py         게이트 14건

게이트는 브라우저도 Node 도 archify 도 없이 돈다. 산출물이 바이트 결정적이라
수신증 대조만으로 충분하고, 그래서 폐쇄망 CI 에서도 돈다. 두 사고 시나리오를
실제로 만들어 차단을 확인했다 — HTML 의 단어 하나를 고치면 html sha 가,
spec 의 태그 하나를 고치면 spec sha 가 어긋나 verify 가 실패한다. 노드 id 가
HTML 에 data-node-id 로 박히므로 의미적 연결도 함께 본다.

git 위생 두 건도 같이 정리했다. *.visual-check.json 이 개발 머신 절대 경로와
크롬 설치 경로를 평문으로 담은 채 미추적·비ignore 상태였다(24개) — 납품
저장소가 removable media 로 건너가는 구조라 한 번 커밋되면 제거가 어렵다.
그리고 15,000줄 생성 HTML 을 텍스트로 머지하려 드는 것을 막았다.

재생성해도 4종 sha256 이 전부 불변이라 바이트 결정성을 다시 확인했다.

감사에서 나온 나머지 부채(한글 폰트 미번들, 영어 뷰어 UI, architecture 좌표
하드코딩, lifecycle 손튜닝 8개소, 콘솔 CSP 비호환)는 PROJECT_MAP 부채 표와
다이어그램 절에 등재했다. 폰트와 UI 는 archify 쪽 한계라 spec 으로는 못 고친다.

검증: ruff clean, pytest 600 passed (게이트 +14).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 10:51:55 +09:00
changukyu
d45018c4d0 docs: 다이어그램 4종 세트 완성, 범례 한글화
아키텍처 · 운영자 심사 워크플로 · 제출물 상태 전이를 신설하고, 기존 증거
데이터 흐름까지 4종의 범례를 한글화했다. 렌더러 기본 범례가 "User UI /
Agent logic / Context-trace" 나 "policy / PII" 처럼 타 도메인 문구였다 —
legend.entries 로 라벨만 바꿨고 종류와 개수는 그대로다.

  system-architecture      폐쇄망 경계와 그 밖의 외부 검색 API. 나가는 경로는
                           store_remote_fetch 하나뿐이고 앞에 호출 게이트가 있다
  operator-review-workflow 4레인(제출·자동·심사·차단). 비상 차단이 걸려도
                           커버리지 부족 표시로 심사가 이어지는 경로 포함
  submission-lifecycle     분석 실패와 보류가 종료가 아니라 분석 중으로
                           돌아오는 두 고리, 판정 후 보존 상태

작성 중 내린 고도 판단 두 개를 남긴다.

  아키텍처에서 analyzer/scorer 를 "분석 · 채점" 한 컴포넌트로 합쳤다. 이
  고도에서 그 분리는 엣지를 벌지 못하고, store 노드에 엣지 7개가 몰려
  통로가 부족해졌다. 채점 내부는 dataflow 다이어그램이 담당한다.

  라이프사이클에서 "재분석 요청"을 상태에서 뺐다. decisionStatus 값이 아니라
  운영자 액션이고, 보류가 같은 의미를 담아 긴 복귀 엣지를 하나로 줄인다.

검증: 4종 모두 showcase 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0),
visual-check 통과(1440x900 · 2048x1320 각 Light/Dark, 오버플로 0).
html sha256 — dataflow a62f0f5c / architecture f5a4b6e1 /
workflow 402914a3 / lifecycle 62e934a3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-19 00:34:51 +09:00
changukyu
e4fffc40be docs: 증거 데이터 흐름 다이어그램 신설
수집 → 경계 검사 → 정규화·채점 → 영속화 → 소비 5단으로, 9/18 리뷰 수정에서
나온 불변식을 그림으로 고정한다.

  제공자별 풀·버킷(RiskScorer v3) · 라운드트립이 원 점수를 재현
  (_evidence_payload <-> _domain_evidence_from_ui) · host 는 저장 시 해석하고
  복원 시 우선 반환 · 지식DB 지문 매칭이 풀링 밖 유일한 high 승격 경로

작성 중 확인한 사실도 반영했다: 프로덕션 LLM 경로는 영속화된 payload 를
_domain_evidence_from_ui 로 복원해 넣는다(store_operations.py:487). 그래서
프롬프트가 host 를 보는 것은 복원 경로 덕분이다 — analysis/evidence_enrichment.py
의 EvidenceEnricher 는 테스트에서만 쓰인다.

소스는 evidence-dataflow.dataflow.json (archify dataflow v1). 그림을 고치려면
JSON 을 고쳐 재생성한다. 산출물은 자체 완결 HTML 로 외부 요청이 없다.

검증: archify showcase 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0),
visual-check 통과(1440x900 · 2048x1320 각 Light/Dark, 오버플로 0,
최소 투영 텍스트 6.71px >= 6px). html sha256 268f3352 (822,655 B).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-19 00:18:09 +09:00
changukyu
1605fd3641 docs: 9/18 코드리뷰 전수 처리 기록, 테스트-LLM 실접속 부채 등재
INDEX 에 반증 1건 포함 결과를 기록하고, PROJECT_MAP 부채 표에 "테스트가 로컬
ollama 에 실접속" 항목을 추가했다 — 이번 작업에서 발견했지만 15건 범위 밖이라
수정하지 않았다. 풀 캘리브레이션 부채 행의 v2 표기도 v3(제공자별)로 갱신.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-18 23:54:40 +09:00
changukyu
e38dad83c9 fix: 증거 행 레이아웃·호스트 노출·보존 케이스 설명, 프롬프트 스탬프 갱신
1) 긴 호스트가 제목을 세로 한 줄짜리 컬럼으로 만들었다

   .evidence-title 은 flex-wrap 없는 flex 행이고 source-chip·host-chip 둘 다
   축소를 거부한다(flex: 0 0 auto). 그래서 제목에 남는 폭이 수십 px 이 되고
   overflow-wrap: anywhere 가 그걸 한 단어씩 세로로 쪼갰다. 컨테이너에
   flex-wrap, 제목에 min-width: 14ch 를 줘서 제목이 자기 줄로 내려간다.

   실렌더 측정 (같은 DOM 에 수정 전 CSS 런타임 주입, Playwright):
     360px  제목 51x95px (5줄) -> 246x19px (1줄), 행 높이 95 -> 50
     1280px 제목 9x380px (~20줄) -> 205x19px, 행 높이 380 -> 50

   주석은 데스크톱에서는 해결됐다고 적혀 있었지만, 45자 CDN 호스트로는
   1280px 에서도 재현된다 — 폰 전용 문제가 아니었다.

2) 20ch 로 잘린 호스트를 읽을 방법이 없었다

   칩 title 이 "게시 호스트" 고정 문구여서, 20자 접두어가 같은 두 CDN 호스트를
   링크를 열지 않고 구분할 수 없었다 — 모노스페이스를 고른 이유가 그 판별이다.
   툴팁에 실제 호스트를 싣는다.

3) 클라이언트 호스트 폴백이 서버보다 짧았다

   _result_host 는 (url, result_url, page_url, source_page_url, blogger_link,
   image_url) 를 순회하는데 evidenceHost 는 3개만 봤다. host 키가 생기기 전에
   저장된 네이버 블로그 행이 bloggerLink 만 가진 경우 칩이 조용히 사라졌다 —
   "레거시 증거가 자기 출처에 눈멀지 않게" 라는 폴백의 목적과 반대다.

4) 보존된 케이스가 설명 없는 깨진 이미지였다

   assetMissing 을 읽는 소비자가 GUI·API·내보내기 어디에도 없었다. 판정은 DB 에
   남지만 콘솔에서는 "판정 후 파일 아카이브" 와 "업로드 손상" 이 구분되지
   않았다. 큐 썸네일과 케이스 이미지에 표시 + 파생물 노트에 사유를 붙인다.

5) PROMPT_VERSION 갱신 (ko-v3-structured -> ko-v4-structured-host)

   847f7a8 이 PROMPT_DATA_FIELDS 의 domain -> host 로 모든 증거 줄의 내용을
   바꿨는데 스탬프는 그대로여서, 한 버전이 서로 다른 두 프롬프트를 가리켰다.
   화이트리스트가 다른 모듈에 있어도 프롬프트의 일부라는 점을 llm_prompts
   독스트링의 규칙에 명시했다. 렌더된 줄이 host= 를 담는지 보는 행위 테스트도
   추가했다(튜플 멤버십 검사로는 안 잡힌다).

   CLAUDE.md 요구대로 실모델 프로브(qwen2.5:7b, 로컬 ollama): grounded 3 /
   dropped 0 / verified True, 형식 예시 반향 없음, 인용 라벨 정확(E1=full match).
   다만 모델은 host 를 문장에 쓰지 않고 page_title 로 서술했다 — 호스트는
   운영자 칩에는 닿지만 서술에는 아직 반영되지 않는다(SYSTEM_PROMPT 과제).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-18 23:54:00 +09:00
changukyu
4ba83ebc65 fix: 저장·복구 정합성 5종 — 호스트 재직렬화, 고아 크롭, assetMissing 생애주기
1) `_result_host` 가 자기가 만든 host 를 안 읽어 재직렬화 시 퇴행 (잠재)

   라운드트립은 provider 를 버리고 domain(= provider 이름)만 남긴다. 그래서
   복구된 행을 다시 직렬화하면 `explicit != provider` 가 성립해 호스트가
   "google_custom_search" / "google" / "internal" 로 되돌아갔다 — 847f7a8 이
   고친 바로 그 버그가, 테스트 전부 초록인 채로. host 우선 반환으로 고쳤고
   3회 왕복 안정성을 테스트로 고정했다. 현재 `_evidence_payload` 호출부는
   모두 신선한 증거라 잠재였지만, 증거 편집 엔드포인트나 마이그레이션이
   하나 생기면 바로 라이브가 된다.

   `_hostname_of` 도 함께 고쳤다: urlparse("//google") 이 "google" 을 호스트로
   보고하므로 점 없는 토큰은 거부한다. 잘못 저장된 host="google" 행은 URL
   파싱으로 자동 복구된다.

2) 복구가 남긴 고아 faceCrops 참조

   백업 복구는 보존기간 대상 미디어를 의도적으로 스킵하는데 DB 는 그대로
   복사되므로, 행은 존재하지 않는 크롭 썸네일을 계속 광고한다. 재생성 게이트가
   `"faceCrops" not in submission` 이라 복구된 행은 리로드에서도 건너뛰고,
   purge 는 존재하는 파일만 순회하므로 참조도 못 지운다 — 영구히 깨진 생체
   썸네일에 회수 경로가 없었다. 게이트를 파일 존재 확인으로 바꾸고(원본이
   있으면 재생성), 원본까지 없으면 참조를 정리하고 감사 이벤트를 남긴다.

3) assetMissing 이 한 번 켜지면 안 꺼졌다

   파일이 돌아와도 486행에서 조기 continue 하므로 플래그가 영구 거짓이 되고,
   1회 마킹 가드가 이후의 진짜 소실에 대한 감사 이벤트까지 막았다. 복귀 시
   해제 + "Submission asset restored" 이벤트를 추가해, 두 번째 소실도 감사에
   남는다.

4) 플래그와 감사 이벤트가 트랜잭션 밖 2회 쓰기였다

   사이에서 죽으면 플래그만 남고 이벤트는 없으며, 가드 때문에 이후 어떤
   리로드도 이벤트를 쓰지 않는다 — 감사 로그가 유일한 기록인 납품에서 판정
   보존의 근거가 사라진다. purge_expired_face_crops / set_provider_enabled 와
   같은 방식으로 한 트랜잭션에 묶었다.

5) 스코어러 마이그레이션이 감사 흔적 없이 밴드를 바꿨다

   가중치 변경은 다음 리로드에서 전 행을 리스코어한다. 운영자가 high 로
   반려한 케이스가 medium 으로 표시되는데 "새 근거" 와 "스코어러 교체" 를
   구분할 방법이 없었다. 라이브 스탬프 `scoredVersion` 을 도입해 버전이
   바뀌면서 밴드가 움직인 행에만 "Risk rescored" 이벤트를 1회 남긴다 —
   동일 버전의 일상 리스코어는 조용하므로 500행 감사 캡을 쓰지 않는다.
   as-created 스탬프(scorerVersion)는 그대로 보존된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-18 23:53:31 +09:00
changukyu
b0aa4c5bcc fix: 위험도 점수 v3 — 제공자별 풀링·버킷, 죽은 category 분기 제거
/code-review 지적 3건을 처리했고, 그 과정에서 리뷰가 놓친 라이브 버그 1건이 나왔다.

1) 제공자 간 확증이 dedup 으로 통째로 사라지던 버그 (리뷰 미지적)

   `counted_web_buckets` 가 match 타입만으로 키를 만들었다. CSE 웹 결과는
   google_custom_search.py:301 에서 match="page" 를 달고 오는데, Vision 이
   먼저 match="page" 행을 보고하면 같은 "page" 버킷이라 CSE 히트가 점수도
   사유도 없이 버려졌다 — 운영자는 두 번째 제공자가 이미지를 찾았다는 사실
   자체를 볼 수 없었다. 버킷을 (provider, match) 로 쪼갰다.

2) WEB_PRESENCE 풀을 제공자별로 분리

   풀이 교정하려던 상관은 한 응답 안에 있다. Vision 의 "full copy" 와
   "hosting page" 는 한 사실의 두 시각이지만, CSE 가 독립적으로 찾은 게시
   페이지는 다른 사실이다. 한 풀에 묶으니 주석 자신이 "high 에 도달해야
   한다"고 말하는 교차 확증이 60/medium 에 갇혔다.

   Vision 자기 상관 뷰 4개: 125 -> 60/medium (의도대로 유지)
   Vision full + CSE page: 45+35 = 80/high (이전엔 45/medium — CSE 가 dedup)

3) category 탈출구는 되살리지 않고 제거

   `category in {character, celebrity, work}` 분기는 writer 가 없다. src 전체에
   그 키를 쓰는 곳이 없고(Vision 엔티티 행은 {entity, entity_id, match,
   provider}), MID -> 카테고리 해석은 폐쇄망에서 불가능한 지식그래프 조회다.
   즉 "유명인은 상한 제외"라고 읽히는 주석이 실제로는 도달 불가 경로를
   문서화하고 있었다. 분기를 지우고, 미등록 유명인이 라벨만으로 high 가 안
   되는 것은 의도된 미탐 트레이드임을 명시했다. 실존하는 승격 경로는
   지식DB/주의목록(FINGERPRINT, 풀링 대상 아님)이며 테스트로 고정했다.

4) match 없는 레거시 행의 +45 는 보존

   현재 생산자는 모두 match 를 채우므로 url 폴백은 레거시 전용이다. 라운드
   트립이 result_url -> url 별칭을 만들기 때문에 이 분기를 지우면 과거 행의
   점수가 바뀐다 — 주석으로 레거시 전용임을 못박고 가중치는 유지했다.

SCORER_VERSION v2 -> v3. v2 주석이 WEB_ENTITY_MAX_POINTS 만 적고 두 변경 중
영향이 더 큰 WEB_PRESENCE_MAX_POINTS 를 빠뜨렸던 것도 함께 보강했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-18 23:53:02 +09:00
changukyu
847f7a852d fix: 게시 호스트를 영속화에서 복구하고 콘솔·LLM 에 노출
P1 3번("매칭 호스트 기반 신뢰출처 구분")을 착수하며 전제를 검증했더니 틀렸다.
호스트는 "이미 저장돼 있고 점수만 안 쓰는" 게 아니라 저장 시점에 파괴되고
있었다.

  google_custom_search.py:240,296 이 실호스트(displayLink)를 data["domain"] 에
  넣는데, store_serialization.py:298 이 data["provider"] 를 우선하므로 그 값이
  API 이름으로 덮어써진다. Vision·Naver 는 호스트를 아예 안 넣고 URL 만 준다.
  결과적으로 저장된 모든 행의 domain 이 "google" 이고, LLM 프롬프트 12줄이 전부
  domain=google 이었다 — 저작권 심사에서 가장 결정적인 "어디에 떠 있나"를 모델도
  운영자도 볼 수 없었다. 같은 케이스의 payload 55행 중 41행이 url 을 갖고 있는데도.

domain 필드는 재사용할 수 없다. provider 커버리지 동기화와 CSE 쿼리 시그니처
매칭이 그 값을 키로 쓰고(store_serialization.py:195, 599) 콘솔도 provider
라벨로 렌더한다. 그래서 host 를 별도 키로 신설했다.

  _result_host(): data["domain"] 이 provider 와 다르면 그것을, 아니면 결과/페이지
  URL 에서 호스트를 파싱한다. www 제거·소문자화. 파싱 실패는 "호스트 없음"이지
  영속화 실패가 아니다.

  점수에는 영향이 없다 — RiskScorer 는 domain 도 host 도 읽지 않는다. 증거 id 도
  안 바뀐다: _evidence_id 는 도메인 객체의 data 를 해싱하고 payload 를 해싱하지
  않으므로 payload 키 추가가 id 를 움직이지 않는다.

  PROMPT_DATA_FIELDS 의 domain -> host. 화이트리스트 크기는 그대로.

  콘솔은 증거 카드 제목 줄에 monospace host-chip 을 붙인다. 기존 저장 행에는 host
  키가 없으므로 evidenceHost() 가 저장된 URL 에서 파생한다 — 292행 마이그레이션도,
  bootstrap 마다 행당 파싱도 필요 없다(렌더되는 행만 지나간다).

실렌더 검증(127.0.0.1:9512, 실 DB)에서만 드러난 레이아웃 결함 2건을 고쳤다.
코드 리딩으로는 보이지 않았다.
  1) 긴 CDN 호스트(cloudfront-ap-northeast-1.images.arcpublishing.com)가 제목 줄
     폭을 다 먹어, evidence-title 의 overflow-wrap:anywhere 와 결합해 제목이 한
     글자씩 세로로 깨졌다. 칩을 max-width 20ch 로 묶고 제목에 flex:1 1 auto 를 줘
     남는 폭을 갖게 했다.
  2) 그 수정 후에는 반대로 biz.chosun.com(14자)까지 잘렸다. 칩을 flex:0 0 auto 로
     고정했다 — 제목("구글 full image match found")은 전 행이 동일한 상용구이고
     호스트가 그 행의 실제 내용이므로, 들어갈 수 있는 호스트가 잘리는 쪽이 잘못이다.
  최종: 칩 82개 전부 유효 호스트 형태(TLD com/edu/hk/jp/kr/net/org), 가시 칩 41개 중
  잘리는 것 4개(모두 20자 초과 CDN), 콘솔 에러 0.

PROVENANCE 배점은 보류한다. 두 조건이 다 안 맞는다. (a) detect_screen_capture 의
카테고리가 geometry 와 subtitle_band 둘뿐인데 2개를 요구하므로 라벨의 실제 의미는
"geometry AND 자막"이고, 자막 없는 방송 캡처는 구조적으로 도달 불가다 — 임계를
낮추는 게 아니라 독립적인 세 번째 신호가 필요하고 그건 새 탐지기다. 한계를 실측과
함께 상수 옆에 기록했다. (b) "새 탐지 신호는 0점 라벨로 출시한 뒤 decision_stats
상관을 보고 가중치 부여"가 2026-07-25 결정이고, 판정 0건인 지금 배점하면 그 결정을
위반한다.

신뢰출처 판정 자체는 "어느 도메인이 우리 것/라이선스 계약처인가"라는 운영자
데이터가 있어야 한다. 호스트가 살아났으므로 목록만 정해지면 cleared_reference 와
같은 패턴(점수 0 + 사유 노출)으로 붙는다.

테스트 4건 추가. pytest 574 passed(착수 전 570), 린트 클린.
2026-08-09 08:51:12 +09:00
changukyu
150e729aef fix: 신호 풀 도입으로 위험도 포화 해소 (SCORER_VERSION v1 -> v2)
기능 실효성 평가에서 확정된 판별력 붕괴를 고친다. 신호조합 224개 전수에서
77.7% 가 high, 56% 가 정확히 100점에 붙고 low 는 3.6% 뿐이었다. 큐 기본 정렬이
riskScore 내림차순인데 전건이 100 이면 정렬이 no-op 이므로, 점수가 운영자에게
주는 우선순위 정보가 0비트였다.

동인은 하나가 아니라 둘이었고 둘 다 "같은 사실을 여러 행으로 센다"는 같은
형태다.

라벨 풀 (WEB_ENTITY_MAX_POINTS = 20)
  category 없는 raw Vision 웹 엔티티가 이름별 버킷이라 서로 dedup 되지 않고
  round(40*conf) 씩 무한 누적됐다. Vision 은 maxResults=10 이라 모든 이미지에
  라벨을 붙이므로, 이미지 매칭이 0건이어도 0.8 짜리 라벨 3개면 100/high 였고
  웹에 전혀 없는 오리지널 사진이 66/medium 이었다. 모집단 전체에 상수로
  존재하는 신호는 판별에 기여할 수 없다. medium 임계(30) 아래로 묶어 라벨이
  케이스를 물들일 수는 있어도 밴드를 정하지는 못하게 했다.

  category(character/celebrity/work) 가 붙은 엔티티는 풀에서 제외한다 — 그것은
  raw 라벨이 아니라 권리 분류이고 엔티티별 +45 를 유지한다. 조사 단계에서
  "test_two_distinct_web_entities_stack_into_high_risk 가 이 동작을 고정하므로
  함께 개정해야 한다"고 봤으나 틀렸다. 그 테스트는 category 있는 엔티티를
  다루므로 두 분기를 가르면 개정 없이 통과한다.

웹 존재 풀 (WEB_PRESENCE_MAX_POINTS = 60)
  Vision 응답 1회가 "이 이미지가 웹에 있다"는 하나의 사실을 full / partial /
  page / visual 4개 상관된 뷰로 동시에 보고하는데, 스코어러가 이를 독립 신호로
  합산해 45+35+35+10 = 125 -> 상한 100 이 됐다. 널리 게시된 평범한 이미지와
  명백한 침해물이 같은 100/high 로 수렴한 이유다. high 임계(70) 아래로 묶었다.

  원칙: 웹에 존재한다는 사실만으로는 high 가 되지 않는다. high 가 되려면 두
  번째 종류의 신호(인물, 지식DB 지문 매칭, 승격된 검색 히트)가 필요하다. 존재
  자체는 침해가 아니고 그 판단은 운영자의 몫이다.

측정 (224조합, v1 -> v2)
  웹 신호만으로 high 가 되는 조합   2/7 -> 0/7
  100점 포화                        66.5% -> 51.3%
  웹에 없는 오리지널 사진, 라벨만   100/high -> 20/low
  이미 안전 확인한 자산(cleared)    100/high -> 20/low
  웹 존재 광범위(4개 매치 타입)     100/high -> 60/medium
  완전일치 사본 + 식별가능 인물      80/high ->  80/high (불변)
  명백한 침해(전 신호)              100/high -> 100/high (불변)

  전체 high 비율은 82.6% -> 76.3% 로 크게 안 움직인다. 이 격자는 신호 축을 균등
  샘플링하므로(지식DB 강매칭이 1/4) 실제 큐 분포의 대리값이 아니며, 실 DB 6건도
  100x4 -> 95x4 로 밴드가 바뀌지 않는다 — 그 4건은 전부 "완전일치 사본 + 인물"
  이라 high 가 맞다. 고친 것은 거짓 high 이지 진짜 high 가 아니다.

  두 상한은 신호의 구조적 성질에서 나온 값이지 캘리브레이션 결과가 아니다.
  판정 30~50건이 쌓이면 decision_stats 교차표로 임계와 함께 재조정해야 한다.

라운드트립 버킷 보존 테스트(test_review_fixes.py)는 신뢰도 1.0 에서 두 엔티티가
모두 캡에 붙어 성질이 가려졌다. 검증 대상은 점수가 아니라 엔티티 이름 버킷이
직렬화를 넘어 살아남는가이므로, 풀 아래(신뢰도 0.2)에서 재작성하고 동일 엔티티
반복이 여전히 1건으로 접히는지도 함께 본다.

테스트 5건 추가. pytest 570 passed(착수 전 565), 린트 클린.
2026-08-09 01:39:02 +09:00
changukyu
02395ac3aa fix: 판정 기록 소실 · 비상차단 미작동 · 생체 크롭 보존기간 초과 차단
"이 도구가 실제 업무에 도움이 되는가"를 6축으로 조사하고 축마다 결론을
반증시켰다(docs/reviews/2026-08-08-utility-assessment.md). 결론은 "검색·기록
노동은 줄여주나 심사 건수는 못 줄인다"이고, 그 과정에서 실행으로 확정된
P0 결함 4건을 여기서 고친다. 판별력 붕괴(P1)는 별도 작업이다.

폴더 정리가 판정 기록을 지웠다 (sqlite_store.py)
  _prune_missing_submission_files 가 원본 파일이 없는 submission 행을 판정
  여부와 무관하게 delete 했고, foreign_keys=on 이라 evidence 가 cascade 로
  함께 사라졌다. 심사 끝난 이미지를 아카이브로 옮기는 평범한 운영 행위가
  승인/반려를 DB 와 내보내기 양쪽에서 지운다. 실증: rejected 판정 후
  export 1행 -> 파일 제거 -> submissions 0행 / evidence 0행 / export 0행.
  판정된 케이스는 파일이 없어도 보존하고 assetMissing 마커를 남긴다. 마커가
  필요한 이유는 이 스윕이 매 리로드마다 돌기 때문이다 — 표시 없이 보존만
  하면 같은 감사 이벤트가 리로드마다 쌓여 500행 캡을 먹는다.

비상 차단 스위치가 아무것도 막지 않았다 (store_enrichment.py)
  emergency_disable_external_providers 는 DB 의 enabled/operatorDisabled 만
  쓰는데 자동 보강 경로가 그 플래그를 아예 읽지 않았다. 게이트가 걸린 곳은
  수동 검색뿐이라, 버튼을 눌러도 폴더 리로드·업로드·rerun-enrichment 가
  그대로 Naver/Google 로 나갔다. 스텁 어댑터 대조실험에서 CONTROL 5회 /
  차단 후 5회로 완전히 동일했다.
  _external_calls_blocked(provider_id) 를 신설해 자동 외부 호출 5개 경로에
  배선했다: auto naver, auto CSE, rerun Vision, 얼굴 크롭 웹탐지, 시드 배치
  (후자는 ExternalApiPolicy(disabled=True) 로 전환).
  enabled 가 아니라 operatorDisabled 를 본다. enabled 는 연결성/헬스에도
  반응하므로 운영자가 멈추라고 한 적 없는 트래픽까지 죽인다. 얼굴 크롭은
  생체 파생물을 전송하므로 상위 게이트에 의존하지 않고 독립 게이트를 건다.

생체 크롭이 보존기간을 넘겨 살아남았다 (store_backup.py)
  스냅샷이 face-crops 를 통째로 담는데 프루닝은 나이가 아니라 개수 10개
  기준이라, 90일에 파기됐어야 할 크롭이 암호화 없는 zip 안에 남았다. 복구는
  더 나빴다 — copyfileobj 로 새로 쓰므로 mtime 이 리셋되고, purge 가 mtime 을
  보므로 파기 대상이 복구 직후부터 다시 90일을 살았다.
  스냅샷에서 제외하고, 복구 시에도 레거시 아카이브의 크롭을 건너뛴다. 크롭은
  원본에서 재분석으로 재생성되므로 손실 비용이 없다.

오프라인 설치 문서가 계약 위반 모델을 지목했다 (offline-install.md)
  qwen2.5:0.5b-instruct 반입을 지시했는데, 같은 리포가 이 모델을 55.7초 +
  프롬프트 형식 예시 반향(출력 계약 위반)으로 기록해 두고 기본값을 7b 로
  고정해 뒀다. 문서대로 반입하면 타깃에서 요약이 조용히 죽는다. 모델명을
  정정하고, 반입물 목록에 없던 ollama 바이너리와 GPU 전제를 추가했으며,
  "env 를 비우면 LLM 없이 돈다"는 서술을 실측으로 정정했다 — 실제로는
  127.0.0.1:11434 로 폴백해 provider 가 enabled=True 로 보고된다.

회귀 테스트 3건 추가. pytest 565 passed(착수 전 562), 린트 클린.
2026-08-09 01:23:21 +09:00
changukyu
76a06f2ff1 docs: CLAUDE.md / PROJECT_MAP 신설, 7/11 체크리스트 재확인
세션 진입 문서가 없어 41.6k 라인 · 70모듈 구조를 매번 재발견하고 있었다.

- CLAUDE.md: 진입점 + 이 프로젝트에만 해당하는 규칙(폐쇄망 의존성 금지,
  app-*.js 로드 순서 계약, SCORER_VERSION 갱신 의무, 직렬화 라운드트립 불변식,
  LLM 은 실모델로 프로브, cp949 비ASCII 출력 주의)
- docs/PROJECT_MAP.md: 파일·라우트·스키마 위치, app-*.js 로드 순서, 알려진 부채
- README: verify 명령, app-*.js 구조, 인증 스로틀 동작 설명

7/11 리메디에이션 체크리스트를 코드와 대조해 갱신했다. 미체크 항목 중 6건은
이미 수정돼 있었고(naver 멀티페이지 쿼터, #10 Origin/Referer CSRF, env_file
BOM, daily-limit fail-open, __main__ 경로 앵커링, 그리고 Batch 8 전체가
Batch 6 과 중복) 각 줄에 확인 근거를 남겼다. 남은 미체크는 전부 근거가 기재된
명시적 연기다.
2026-08-08 09:49:55 +09:00
changukyu
60903ec1f7 test: 업로드 E2E 플레이크 해소 (대기 10초 -> 30초 상수)
test_browser_uploads_image_and_selects_new_submission 이 전체 스위트에서만
실패하고 단독 실행에서는 통과했다. 업로드는 케이스가 큐에 나타나기 전에
서버에서 실분석(디코드/지문/YuNet)을 돌리므로, 다른 스펙과 CPU 를 다투는
전체 실행에서 10초를 넘겼다.

UPLOAD_ANALYSIS_TIMEOUT_MS(30초)로 올리고 이유를 상수 옆에 남겼다. 모킹된
부트스트랩 대기와 달리 이 경로만 여유가 필요하다는 점이 코드에서 보이게 했다.
upload_and_open 의 기본값도 같은 상수를 쓴다.
2026-08-08 09:49:55 +09:00
changukyu
278372d171 fix: SSRF DNS-rebinding TOCTOU 차단 + 실패 인증 스로틀
7/11 체크리스트에서 미착수로 남아 있던 보안 2건. 나머지 미체크 항목은 코드와
대조한 결과 이미 수정돼 있었거나(6건) 근거가 기재된 명시적 연기다.

SSRF DNS-rebinding (store_remote_fetch.py)
  _assert_public_http_url 이 호스트를 해석해 통과 여부를 정하고 나면,
  http.client 가 소켓을 열 때 같은 이름을 한 번 더 해석했다. 리바인딩
  호스트는 첫 조회에 공인 주소를, 두 번째에 127.0.0.1 을 답하면 그만이었다.
  _guarded_create_connection 이 getaddrinfo 를 1회만 수행하고, 그 답변의
  주소를 전부 검증한 뒤 그중 하나로만 연결한다 — 검사한 주소와 실제 다이얼
  하는 주소가 갈라지지 않는다. 답변에 내부 주소가 하나라도 섞이면 거부한다
  (공인+내부 혼합이 리바인딩 패턴 자체이고, connect 가 어느 항목에 닿을지는
  예측 대상이 아니다). HTTPConnection._create_connection 을 갈아끼우는
  방식이라 connect() 의 나머지(TCP_NODELAY, 터널링)와 HTTPS 의 TLS 검증
  (self.host 기준)은 그대로 유지된다.

실패 인증 스로틀 (auth_rate_limit.py)
  COPYRIGHTER_AUTH_TOKEN 이 데이터 라우트의 유일한 게이트인데 틀린 토큰의
  비용이 요청 1회였다. 클라이언트 주소별 고정 윈도우 10회/60초, 초과 시
  429 + Retry-After. 성공하면 카운터를 비운다.
  자격증명을 실제로 제시하고 틀린 경우만 센다 — 콘솔은 토큰 입력 전에도 API
  를 여러 건 호출하므로, 그 401 들을 세면 정직한 페이지 로드 두 번으로
  운영자가 잠긴다. Origin 거부는 유효 세션의 CSRF 검사 실패지 추측이 아니라
  세지 않는다. 추적 테이블은 1024 클라이언트로 상한(만료분 우선 회수).

테스트 26건 추가. 리바인딩 회귀 재현(사전검사는 통과시키고 connect 에서 거부),
스로틀의 429/Retry-After, 무자격증명 요청이 스로틀에 안 잡히는 것, 성공 시
카운터 초기화 포함.
2026-08-08 09:49:31 +09:00
changukyu
fc4e04f65c refactor: app.js 5,428줄을 관심사별 app-*.js 15개로 분할
단일 파일이 프로젝트 상한(800줄)의 6.8배였다. 분할은 순수 라인범위 추출이며
어떤 구문도 수정·재배치·재포맷하지 않았다 — 15개 파트의 연결 결과가 원본
app.js 와 바이트 단위로 동일함을 확인하고 나눴다(125,689B).

클래식 스크립트(모듈 아님)라 하나의 전역 스코프를 공유하므로 로드 순서가 곧
계약이다. 원본에서 로드 시점에 실행되는 최상위 구문은 파일 끝의 리스너 등록
2개뿐이었고(DOMContentLoaded/hashchange), 나머지 참조는 전부 함수 본문 안이라
순서만 지키면 의미가 보존된다. 그 순서를 index.html 에 명시하고 테스트로 고정:

- test_index_loads_every_app_part_exactly_once_in_declared_order
  각 파트가 정확히 1회, 선언 순서대로 로드되는지 + 디스크의 app-*.js 가
  전부 배선됐는지 + 구 app.js 가 남아있지 않은지
- test_app_parts_stay_within_the_file_size_budget (800줄 상한)

정적 스펙 31곳은 app.js 본문을 문자열로 검사하고 있었다. 이 스펙들의 관심사는
'어느 파일에 있는가'가 아니라 '콘솔에 그 동작이 있는가'이므로, 파트를 로드
순서대로 이어붙여 읽는 _read_app() 으로 바꿨다.

검증: Playwright E2E 6건이 실브라우저에서 통과(콘솔 에러 0 단언 포함).
운영 콘솔 실렌더 확인 — 큐/케이스 심사 정상, 스크립트 15개 전부 200,
새로고침 후 콘솔 메시지 0건.
2026-08-08 09:48:52 +09:00
changukyu
bc14cd2177 chore: 단일 verify 게이트 신설 (pytest.ini + ruff.toml + verify.ps1)
린트와 테스트가 각각 수동 실행이었고, 어느 조합이 커밋 게이트인지 코드에
기록된 곳이 없었다. ruff 는 dev 의존성에도 없어 전역 환경에서만 돌고 있었다.

- verify.ps1: ruff -> pytest 순서로 도는 단일 명령. -SkipLint / -Coverage
- pytest.ini: testpaths, pythonpath(. src) 로 맨 pytest 도 동작, 300s 행 가드
- ruff.toml: py313 / line-length 120 / 기본 룰셋 명시 (ruff 업그레이드가
  게이트를 조용히 넓히거나 좁히지 못하게)
- requirements-dev.txt 에 ruff==0.12.0 핀

린트 18건 정리 (전부 무해하지만 게이트가 통과해야 하므로):
- env_clients.py / store_enrichment.py: logger 정의를 임포트 아래로 (E402 15건)
- test_sqlite_store.py: lambda 할당 -> def (E731 2건)
- test_store_workflow.py: 미사용 임포트 제거 (F401)
2026-08-08 09:47:57 +09:00
changukyu
fa00f382e2 docs: INDEX 에 실렌더 검증 기록 2026-07-26 00:30:24 +09:00
changukyu
d20e6d667e fix: 운영자 콘솔 실렌더 검증에서 드러난 결함 5종
코드 리딩으로는 나오지 않는 결함들이다. 서버를 실제로 띄우고 브라우저로
케이스 화면을 열어서 찾았다.

## 1. 서버가 기동조차 하지 않았다 (납품 치명)

COPYRIGHTER_AUTH_TOKEN 없이 실행하면 경고 문구 안의 em dash 가 한국어
Windows 콘솔(cp949)에서 UnicodeEncodeError 를 일으켜, 포트를 바인딩하기도
전에 프로세스가 죽었다. 납품 대상이 정확히 그 환경이다.

기동 진단 문자열을 ASCII 로 바꾸고, stdout/stderr 를 errors="replace" 로
재설정한다. 진단 메시지가 기동을 막을 수 있어서는 안 된다 — 죽는 것은
프로세스가 아니라 표현 불가능한 문자여야 한다.

## 2. 요약이 모델이 본 적 없는 근거를 인용했다 (거버넌스)

프롬프트는 MAX_PROMPT_EVIDENCE 로 잘리는데 source_urls /
source_evidence_ids 는 여전히 전체 증거에서 뽑혔다. 실측: 79행짜리 케이스가
12행만 읽은 요약을 만들고 화면에는 "연결 근거 78건" 이 떴다. 정책 검사는
통과했고 숫자는 허구였다.

앞선 커밋의 입력 경계는 이 구멍의 절반만 막았다. 이제 연결 근거는 검증된
claim 이 실제로 인용한 행이고, 인용이 하나도 살아남지 못하면 모델에게
보여준 행으로 폴백한다. verified 도 이제 "요약이 검증됐다" 를 뜻한다 —
이전에는 "케이스에 증거가 있다" 의 재진술이었다.

## 3. 조작 감지 지표가 저장되지 않았다

grounded_claims / dropped_claims 를 evidence.data 에는 넣었으나
_evidence_payload 에 매핑하지 않아 영속화되지 않았다. 모델이 지어내기
시작해도 아무도 볼 수 없는 상태였다.

## 4. 클라이언트가 서버의 그룹 분류를 무시했다

서버가 group=provenance 를 보내는데 app.js 의 evidenceGroupFor 가 자체
매핑으로 internal 로 재분류했다. 배관 목록에 서버 쪽만 넣고 클라이언트
쪽을 빠뜨렸다. 증거 출처 필터 드롭다운에도 항목이 없었다.

## 5. 기본 모델이 두 곳에서 서로 달랐다

OllamaGenerateLlmClient.__init__ 는 qwen2.5:3b-instruct(미설치), env_clients
는 qwen2.5:7b. 런타임 404 가 날 때까지 아무것도 실패하지 않았다. 클라이언트가
값을 소유하고 env_clients 가 재수출하도록 바꿔 드리프트를 구조적으로 막는다.

## 실렌더 확인 결과

- 출처 표기 그룹이 자체 섹션으로 렌더 (제목/건수/설명/칩)
- 요약이 [E1, E2, ...] 인용이 붙은 한국어 2단으로 렌더
- sourceIds 78 -> 10, verified=True, grounded=2 dropped=0
- qwen2.5:7b 6.0초, initialRiskScore 21/low vs 실시간 100/high 분기 확인

pytest 534 passed.
2026-07-26 00:29:54 +09:00
changukyu
eb888e19b9 docs: INDEX 에 C단계·실모델 프로브 기록 2026-07-25 23:59:26 +09:00
changukyu
5a9fcc0bea feat: 출처 신호 2종 (EXIF/IPTC 권리 표기 + 방송캡처 포렌식), 0점 라벨
두 신호를 하나의 EvidenceSource.PROVENANCE 와 하나의 GUI 그룹으로 묶는다.
운영자 관점에서 둘 다 같은 질문("이 이미지가 어디서 왔나")에 답하고, 새
EvidenceSource 는 배관 12곳을 요구하므로 둘을 합쳐 한 번만 지불한다.

## EXIF/IPTC 권리 표기

저작권자가 파일에 직접 써 넣은 값을 지금까지 100% 버리고 있었다.
build_external_derivative 가 raw 픽셀로 재구성하며 메타를 전량 파기하는데,
그 전에 읽는 코드가 없었다. 추출 훅은 원본 바이트가 아직 살아 있는
InternalAnalyzer.analyze 진입부에 둔다.

읽는 것은 EXIF Copyright/Artist 와 IPTC CopyrightNotice/Credit/Source/By-line
뿐이다. 화이트리스트가 프라이버시 경계이므로 상수로 두고 테스트로 고정한다 —
GPS, 바디 시리얼, Make/Model 은 같은 EXIF 블록에 나란히 있어도 읽지 않는다.

메타데이터는 위조가 쉬우므로 reason 에 "자기신고" 를 명시한다. 이것이
클리어런스 증명으로 읽히면 안 된다.

XMP/C2PA 는 이번 범위에서 뺀다 — Pillow 의 getxmp() 는 defusedxml 이 없으면
경고와 함께 빈 dict 를 반환하고, defusedxml 은 requirements.txt 에 없다.
폐쇄망 타깃에서 조용히 no-op 이 되므로 별도 반입 심사 안건이다.

## 방송캡처 포렌식

결정적 영상처리만 사용한다(레터박스, 자막 밴드 엣지 밀도, 방송 프레임 크기).
모델 0, 신규 의존성 0.

레터박스와 방송 프레임 크기는 하나의 카테고리(geometry)로 묶는다. 레터박스된
1280x720 은 한 관측을 두 번 말한 것이고, 따로 세면 시네마틱 크롭 하나로
라벨이 붙는다. 서로 독립인 카테고리가 2개 이상일 때만 발화한다.

## 0점

둘 다 점수 0으로 출시한다(사용자 결정). 캘리브레이션 데이터가 없는 상태에서
정당화할 수 있는 가중치가 없다 — decision_stats 의 밴드x판정 교차표로 실제
반려와의 상관을 본 뒤 부여한다. reason 은 노출하므로 0점이어도 운영자가
바로 활용할 수 있다.

## 실측

실제 제출 이미지 6장: 캡처 오탐 0건, 권리 메타 히트 0건. 정밀도는 확인됐고
가치는 이 코퍼스에서 아직 입증되지 않았다 — 메신저/SNS 를 거친 이미지는 메타가
이미 제거돼 있어 히트율이 낮을 것이라는 사전 지적과 일치한다.

자막 밴드 절대 밀도 하한은 0.02 -> 0.01. 비율(밴드/프레임 = 2.5배)이 판별자이고
절대 하한은 "밴드에 뭔가 있긴 한가" 게이트일 뿐이며, 하한이 높으면 실제 한
줄짜리 자막이 걸러진다. 합성 픽스처 기반이므로 실제 캡처 이미지 확보 시
재확인이 필요하다.

pytest 531 passed.
2026-07-25 23:59:00 +09:00
changukyu
6bbaaf6e23 fix: 형식 예시 반향 차단, 기본 모델을 실측으로 qwen2.5:7b 로 확정
실모델 프로브에서 인용 검증만으로는 못 잡는 실패 모드가 드러났다.

## 형식 예시 반향(template echo)

qwen2.5:0.5b 는 프롬프트의 형식 예시를 그대로 돌려주면서(claim text 가
"<한국어 문장>") 실제 라벨 12개를 전부 인용했다. 집합 포함 검사는 이것을
완벽히 통과시킨다 — 인용은 전부 진짜이기 때문이다. 결과적으로 완전히 비어
있는 출력이 "근거 있음" 판정을 받았다.

그라운딩 검증은 "인용이 진짜인가"와 "내용이 진짜인가"를 둘 다 봐야 한다.
형식 예시 문자열을 llm_prompts 의 상수로 두고 그 반향을 거부한다. 앵글
브래킷으로 감싼 다른 스탠드인도 같은 실패로 취급한다.

가짜 transport 테스트로는 이 결함을 잡을 수 없었다 — 스크립트된 응답은
언제나 잘 만들어진 JSON 이기 때문이다.

## 기본 모델

저장된 실제 증거(제출물 #3, 54행 중 12행 선별, 프롬프트 1,507자)로 GPU
타깃에서 측정:

  qwen2.5:0.5b-instruct  55.7s  형식 예시 반향, 계약 위반
  qwen2.5:7b              8.8s  계약 준수, claim 2 + 확인필요 1   <- 채택
  qwen3:8b               35.7s  계약 준수, thinking 모델이라 4배 느림

3b 는 빌드 머신에 없고 7b 는 이미 있어 에어갭 반입 비용이 0 이다. 브레인스토밍
문서의 "7B 는 인터랙티브 불가" 는 CPU 전제였고 이 배포에는 해당하지 않는다.

pytest 517 passed.
2026-07-25 23:48:23 +09:00
changukyu
5c05382944 docs: INDEX 에 B단계 기록 2026-07-25 23:34:27 +09:00
changukyu
6be263a69d feat: LLM 을 요약기에서 증거 판독기로 (구조화 인용 + 입력 경계 + 3B)
사용자 결정: LLM 역할을 "요약"에서 "증거 판독 + 구조화 판단"으로 확대,
타깃에 GPU 있음. 최종 판정은 여전히 사람이 한다.

## 입력 경계

프롬프트가 증거 data 딕셔너리를 통째로 f-string 에 박아 제출건당
13,733~62,291 자를 만들고 있었다. 화이트리스트 9개 필드 + 상위 12건으로
제한한다. promotion_reason 을 제외한 것이 핵심 — 모델이 그대로 베껴서
"요약"이 증거 목록 복붙이 됐다. 얼굴 크롭 좌표 / privacy_note /
thumbnail_url 도 모델 입력에서 뺀다(요약 입력이 아니다).

선별은 위험을 실제로 지는 행 우선: 지문 유사도 0.9 이상 -> full/partial/page
매칭과 promoted -> 얼굴 -> 나머지.

## 구조화 인용과 검증

format=json 으로 {claims:[{text, evidence}], needs_check:[]} 를 받고, 각
claim 이 인용한 라벨이 실제로 프롬프트에 들어간 라벨 집합의 부분집합인지
검사해 아니면 버린다. 화면에 렌더되는 텍스트는 검증을 통과한 claim 으로만
조립하므로 근거 없는 문장이 운영자에게 도달할 경로가 없다.

인용을 JSON 필드로 받는 것은 의도적이다. 산문을 문장 분해해 개체(URL, 숫자)를
대조하는 방식은 'confirming the image's authenticity [E3]' 같은 근거 없는
서술어를 100% 통과시켜, 오히려 환각 문장에 "근거 확인됨" 도장을 찍는다.
집합 포함 검사는 그 실패 모드가 구조적으로 발생하지 않는다.

증거 id 대신 [E1] 라벨을 쓴다 — 24자 해시를 소형 모델에게 받아쓰게 하면
오타와 조작을 구별할 수 없다.

grounded_claims / dropped_claims 를 증거에 기록한다. 모델이 지어내기
시작하면 수치로 드러난다.

## 모델과 컨텍스트

기본 모델 qwen2.5:0.5b-instruct -> qwen2.5:3b-instruct. 0.5B 는 출력 계약을
지키지 못했다(영어로 답하고 증거 목록을 반복했다).

num_ctx 를 명시한다. Ollama 기본 컨텍스트는 수천 토큰이고 넘으면 조용히
자르는데, source_urls / source_evidence_ids 는 잘리지 않은 전체 증거에서
뽑혀 붙었다. 즉 모델이 읽은 적 없는 근거를 근거라고 표시하고 있었다.
num_predict / keep_alive / timeout(30->120s) 도 환경변수로 노출.

## 시드 잠금 해제

_ensure_llm_summary 호출 2곳을 _write_lock 밖으로 옮긴다. record_decision 이
같은 락을 쓰므로, 폴더 재적재 중에는 운영자가 보고 있지도 않은 이미지의
요약이 끝날 때까지 판정 버튼이 대기했다. 시드 1회당 20건 상한을 두되 조용히
자르지 않고 로그를 남긴다(only-if-missing 이라 다음 재적재에서 이어진다).

이 이동은 provider 커버리지 회귀를 만들었다 —
_sync_submission_provider_state 가 잠금 구간 안에서, 즉 나레이션 전에 돌아
요약이 존재하는데도 llm 을 not_run 으로 보고했다. 나레이션이 실제로
발생했을 때만 커버리지를 다시 계산한다.

pytest 515 passed.
2026-07-25 23:34:02 +09:00
changukyu
4f140efd9b 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).
2026-07-25 23:08:19 +09:00
changukyu
8b5b3f5cf9 docs: 2026-07-24 review report, implementation checklist, spec revision
- Add the 2026-07-24 multi-agent review report (43 confirmed findings, runtime
  smoke, feature proposals with judge scores) and the implementation checklist
  tracking what was done vs. deliberately deferred (with reasons).
- Record the batch-decision decision: the v1 "no bulk approve/reject" GUI-spec
  restriction is revised, not silently overridden, and lists the guardrails that
  made lifting it acceptable.
- Fix the README `python -m rights_filter.server` invocation (needs
  PYTHONPATH=src) and start a docs/INDEX.md continuity log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E6GD9gxUtfUiVB3JHejGpU
2026-07-24 14:18:27 +09:00
changukyu
a2d0ef7a36 feat: operator throughput features (keyboard review, batch, dupes, export, backup)
- Keyboard review mode: J/K to move, A/H to decide and jump to the next
  unreviewed case, R to focus the memo, N for next unreviewed, ? for help, with
  a live position/backlog indicator; disabled while typing.
- Batch decisions: POST /api/submissions/decision-batch applies one decision to
  many selected cases through per-case transactions/audit/watchlist, reports
  per-case success and failure, caps at 200, requires a memo for reject and
  confirms when high-risk cases are included. Lifts the v1 "no bulk
  approve/reject" spec restriction under those guardrails (spec revised, static
  layout test updated to assert the guardrails instead of the absence).
- Decision-memo presets: GET/POST /api/decision-memo-presets (upsert by label),
  one-click chips in the decision pane, "save current memo" action.
- Submission duplicate / prior-case index: seed backfills fingerprints onto the
  submission record (idempotent), exact + pHash>=0.97 clustering, queue badge
  and group select, prior-decision chips and a conflicting-decision flag.
- Decision export: GET /api/decisions/export with separate internal and
  applicant field whitelists (applicant view never carries score/evidence),
  CSV with a BOM.
- Online backup/restore: POST /api/admin/backup snapshots the WAL database via
  the sqlite backup API plus the media dirs into a timestamped zip with
  retention rotation; `--restore` is an offline CLI that keeps the pre-restore
  database. GET /api/admin/backups lists snapshots.

Also folds in the co-located HTTP hardening (non-object/non-UTF8 JSON body ->
400, non-ASCII Authorization/cookie compared as bytes -> 401, media OSError ->
404) and the line-buffered startup logging that share these files.

Verified against a live server (auth gate, seeding, duplicate index, batch
decision guardrails, presets, export, backup + restore round-trip). pytest
tests/rights_filter tests/operator_gui: 457 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E6GD9gxUtfUiVB3JHejGpU
2026-07-24 14:18:11 +09:00
changukyu
db9ee9a7ca fix: remediate 2026-07-24 multi-agent review findings
HIGH:
- face detection: guard OpenCV builds lacking CascadeClassifier and isolate
  per-submission analysis failures in BatchAnalyzer, so a broken cv2 or one bad
  image no longer takes down every seed/reload/import/upload/rerun with no HTTP
  response
- risk scoring: gate Google custom image search (match=search_image) to 0
  points so the persistence round-trip (result_url aliased to url) can no longer
  flip the same evidence to +45 and inflate bands
- serialization: exclude SEARCH_SKIPPED/ENRICHMENT_FAILURE rows from the auto
  search dedup signatures so a quota-skipped query stays retryable instead of
  being treated as already done forever
- external_policy: add a daily-window reset + lock (mirrors SearchApiPolicy) so
  Vision is not permanently disabled once the daily limit is hit
- image_store: skip a malformed manifest row (and coerce bad width/height)
  instead of letting one ValueError paralyse loading/re-analysis for the queue

MEDIUM/LOW:
- enrichment: only count a face crop as transmitted when it actually left the
  system, removing false usage debits and a false "personal image sent to
  external provider" audit record; pick lastFailure from the failing item;
  preserve the collection set when every search was quota-skipped; dedup
  candidate counts; attribute Vision failures to the google provider badge
- store: search coverage requires a real result, not a failure row
- scoring: FACE_PERSON contributes once regardless of how many rows exist
- fingerprints: refuse near-matches on degenerate (near-uniform) average
  hashes while keeping identical-hash matches
- preprocessing: apply EXIF orientation for external derivatives, face crops,
  pHash and the PIL decode fallback
- promoter: scan title/description only, never the URL path/host tokens
- integrations: map Vision responses inside the adapter try; cap HTTPError
  bodies; skip the connect probe when a proxy applies; reservation-style quota
  accounting (try_reserve/release) to make the check-then-act atomic
- governance/admin: enforce the LLM-summary rule on the real persistence path;
  reject unknown manual-entry fields; upsert the rejected_image knowledge entry
  on repeat rejections; split policy skips out of the failures group
- env_file: strip a trailing inline comment from unquoted values

Environment drift fixed out-of-tree: removed the user-site OpenCV 5.0.0 that
shadowed the pinned opencv-python-headless==4.13.0.92.

Regression tests added; pytest tests/rights_filter is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E6GD9gxUtfUiVB3JHejGpU
2026-07-24 14:17:45 +09:00
changukyu
2c607e0df6 docs: 2026-07-11 multi-agent review report, remediation checklist, offline extras
- Add the 2026-07-11 multi-agent project-review report and the remediation
  checklist (per-item status, with deferred items and their rationale).
- offline-install: document Ollama model and Playwright Chromium transfer, a
  hash-verified lock, --only-binary, and correct the egress note (image
  comparison fetches arbitrary SSRF-guarded public URLs, not only API hosts).
2026-07-11 16:08:04 +09:00
changukyu
6ab6da3719 chore: startup path anchoring, env parsing and gitignore
- Launcher guards Python 3.13 before importing the package and chdirs to the
  repo root so cwd-relative default paths resolve consistently.
- main() logs the resolved .env path and warns on a missing static dir or a
  short auth token; env_file reads utf-8-sig so a BOM doesn't drop the first
  variable.
- .gitignore covers .claude/settings.local.json and .impeccable/.
2026-07-11 16:07:49 +09:00
changukyu
3535bfee15 fix: operator GUI robustness, persistence and dead-control fixes
- Image viewer controls (fit / 1:1 / rotate) now have handlers and reset per
  case (were dead buttons).
- 'auto-entry deactivate' is scoped to the selected case's sourceSubmissionId
  (was deactivating an arbitrary global entry).
- dismissCollectionCandidate persists via a real endpoint (was a local splice
  that resurrected on the next refresh).
- setDecision has an in-flight guard; rerunSelectedEnrichment /
  applyCoverageFilter get null guards and a best-effort refresh on partial
  failure; deactivateCorrectionEntry skips already-inactive entries, tolerates
  per-entry failures, and always refreshes (guarded).
- executeSuggestedQueries / runCandidateCollection commit only after success
  so a failed run doesn't wipe the previous view; applyBootstrap clears a stale
  error banner; renderCorrections guards derivedEntries.
- riskClass allowlists the band and case/similar/face-crop URLs route through
  safeUrl (XSS defence-in-depth).
2026-07-11 16:07:30 +09:00
changukyu
4ef12f5f95 feat: HTTP security hardening (CSP, CSRF, session tag, body caps)
- Trusted app-shell responses now carry a strict script-src CSP plus
  X-Frame-Options: DENY and nosniff (img-src stays permissive for external
  evidence thumbnails; the GUI uses no inline scripts).
- Session cookie stores an HMAC-derived tag, not the raw COPYRIGHTER_AUTH_TOKEN,
  so a leaked cookie no longer reveals the token/bearer/.env secret.
- Cookie-authenticated state-changing requests require a same-origin
  Origin/Referer (CSRF); header-authenticated requests are not forgeable.
- _body rejects negative/non-integer Content-Length (was reaching
  rfile.read(-1), an unbounded read-to-EOF) and returns a controlled 400.
- Handler timeout=30 bounds slow-client thread exhaustion; uploads use
  exclusive-create ('xb') to close a concurrent same-name overwrite TOCTOU.

Tests: app-shell security headers, negative Content-Length -> 400, cookie tag
(not raw token), cookie-authed cross-origin POST -> 403 / same-origin -> 200.
2026-07-11 16:07:13 +09:00
changukyu
1803b4e0b8 fix: harden outbound integrations (errors, quota, SSRF, size cap)
- cloud_vision: raise on a per-image error inside a 200 response so the adapter
  records a FAILURE instead of a silent "no results" that also debited quota;
  send the key via X-goog-api-key header (verified for Vision) not the URL.
- search_policy: reset the daily_limit counter on a calendar-day rollover (it
  never re-windowed, so a hit limit disabled the provider until restart) and
  guard calls_made with a lock.
- google_custom_search: map the API response inside the adapter's try so a
  malformed payload becomes ENRICHMENT_FAILURE, not an uncaught crash. (Key
  stays a query param — the header is unverified for this endpoint.)
- http_json: cap the response body (10 MiB) and convert decode/read/OS errors
  into HttpJsonError so nothing escapes the transport contract.
- store_remote_fetch: SSRF guard rejects any non-global address (`not
  is_global`), covering CGNAT 100.64/10 the flag list missed.
- env_clients: a malformed daily-limit env falls back to a conservative cap
  (not fail-open unlimited); clamp NAVER display sizes.
- preprocessing: warn when external-derivative re-encoding falls back to the
  original bytes (EXIF/GPS may not have been stripped).

Tests: Vision per-image error raises / converts to FAILURE; daily-limit
malformed falls back conservatively.
2026-07-11 16:06:55 +09:00
changukyu
6193ed242d fix: serialize store read-modify-write and harden seed/queue paths
Under ThreadingHTTPServer several store writers ran unlocked read-modify-write
cycles, so concurrent operator actions could silently lose each other's
updates or revert an in-flight change.

Concurrency (_write_lock; external I/O kept OUTSIDE the lock):
- record_decision now takes _write_lock so a decision isn't reverted by a
  concurrent rescore/sweep.
- manual_search runs the network search first, then re-reads the submission
  under the lock before the queryHistory RMW (was clobbering concurrent edits).
- _apply_provider_usage_delta merges only usage/status onto the fresh row
  instead of rewriting a stale pre-search snapshot (kept an in-flight emergency
  disable from being reverted).
- _sync_provider_payloads preserves an operator's disable via an
  operatorDisabled flag (connectivity-only disables still reconnect).
- set_provider_enabled / emergency_disable / _sync_submission_provider_state /
  collect_keyword_candidates now lock (+ single transaction where applicable).

Robustness:
- seed: a zero-byte/corrupt image now yields a 'failed' submission + audit
  instead of an IndexError that aborted the whole import.
- ensure_queue: key the UPDATE/SELECT on the existing row id on a folder_path
  match (was matching zero rows and returning None -> seed TypeError); audit
  queue activation / legacy migration.
- store_enrichment: lastFailure uses the first failed item; auto-search dedup
  reads only the submission's evidence; log a swallowed face-crop load error.
- image_store: documents the pre-existing same-stem id limitation (a prior
  sibling-dependent fix was reverted — it resurrected decided submissions).

Tests: operator-disable survives reseed, usage delta keeps an in-flight
disable, seed survives an unanalyzable image, ensure_queue mismatched-id,
dismiss persistence.
2026-07-11 16:06:28 +09:00
changukyu
ac47f0c5d0 fix: restore scoring signals lost in the evidence DB round-trip
Production scores come from _rescore_submission, which re-reads evidence via
_domain_evidence_from_ui and re-scores. Serialization dropped the promoted /
entity fields and the precise EvidenceSource, so on every rescore Naver
promotion (+40) and Google web-entity (+40) silently scored 0 and skip
evidence was mis-restored as FAILURE (+30 phantom).

- store_serialization: persist domainSource / promoted / promotionReason /
  entity / category in _evidence_payload and restore them in
  _domain_evidence_from_ui; resolve the source from the persisted enum
  (keyword heuristic kept only as a legacy fallback).
- search_result_promoter: structural no-result detection (covers image/blog/
  web variants), scan only result fields not the query, add Korean rights
  markers (excluding over-generic substrings).
- risk_scoring: dedup promoted Naver hits by query_signature; drop zero-point
  skip reasons from the operator-facing reasons list.

Tests: round-trip regression (promoted naver / web entity / two distinct
entities score > 0; skip adds no phantom points) + skip-reason exclusion.
2026-07-11 16:06:05 +09:00
133 changed files with 21089 additions and 4554 deletions

View file

@ -45,4 +45,17 @@ COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT=100
COPYRIGHTER_LLM_DAILY_LIMIT=100 COPYRIGHTER_LLM_DAILY_LIMIT=100
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen2.5:0.5b-instruct # Measured against real stored evidence on the GPU target:
# qwen2.5:0.5b-instruct 55.7s — returned the prompt's shape example verbatim
# qwen2.5:7b 8.8s — held the contract <- default
# qwen3:8b 35.7s — held the contract, 4x slower (thinking model)
# Pull on the build machine: `ollama pull qwen2.5:7b`.
OLLAMA_MODEL=qwen2.5:7b
# Context window. Ollama defaults to a couple of thousand tokens and silently
# truncates past it — the summary would then cite evidence the model never read.
OLLAMA_NUM_CTX=8192
OLLAMA_NUM_PREDICT=512
# Seconds. Structured output on a 3B model needs more headroom than the old 30s.
OLLAMA_TIMEOUT=120
# Keep weights resident between cases so a batch does not reload per case.
OLLAMA_KEEP_ALIVE=10m

7
.gitattributes vendored
View file

@ -19,3 +19,10 @@
*.webp binary *.webp binary
*.ico binary *.ico binary
*.pdf binary *.pdf binary
# 생성 산출물 — 소스는 같은 폴더의 spec JSON 이다.
#
# 15,000줄짜리 생성 HTML 이라 텍스트 diff 는 리뷰에 쓸모가 없고(최장 라인 41,844자),
# 두 브랜치가 각각 재생성하면 손으로 해소할 수 없는 충돌이 난다. 정답은 항상
# "재생성"이므로 머지를 텍스트로 시도하지 않게 해 도구가 그 사실을 강제하게 한다.
docs/diagrams/*.html linguist-generated=true -diff merge=binary

26
.gitignore vendored
View file

@ -12,6 +12,10 @@ htmlcov/
# Offline dependency wheelhouse (built on staging machine, not committed) # Offline dependency wheelhouse (built on staging machine, not committed)
wheelhouse/ 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) # Secrets & local environment (keep .env.example)
.env .env
.env.local .env.local
@ -31,6 +35,28 @@ artifacts/
tmp_* tmp_*
tmp_dbg_img/ tmp_dbg_img/
# Local tool state (do not rely on the machine-global gitignore)
.claude/settings.local.json
.impeccable/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# PowerShell module-analysis cache written into cwd by some automation
Microsoft/
# 다이어그램 검증 사이드카 (scripts/rebuild-diagrams.ps1 이 재생성한다)
#
# visual-check 산출물은 브라우저 증거이지 납품물이 아니다. 게다가 *.visual-check.json
# 은 개발 머신의 절대 경로와 크롬 설치 경로를 평문으로 담는다 - 폐쇄망 납품 저장소가
# removable media 로 건너가는 구조라 이력에 한 번 들어가면 제거가 어렵다.
docs/diagrams/*.visual-check.*
# 캘리브레이션 코퍼스 이미지 (저작권 있는 실이미지)
#
# 판정 임계를 검증하려면 실제 유통 이미지가 필요한데, 그것을 저장소에 넣으면
# 납품 저장소가 removable media 로 건너가는 구조에서 그대로 따라간다.
# 이미지는 로컬에만 두고 코퍼스 DB(data/)도 이미 제외되어 있다.
sample/
corpus/

36
CLAUDE.md Normal file
View file

@ -0,0 +1,36 @@
# POSA Copyrighter
이미지 저작권 위험 자동 분석 + 운영자 판정 콘솔. **폐쇄망(air-gapped) 납품 대상.**
## 먼저 읽기
`docs/PROJECT_MAP.md` (구조·라우트·스키마 위치) → `docs/INDEX.md` (작업 이력).
전체 소스 재스캔 금지 — MAP 에서 위치를 찾고 필요한 부분만 연다.
## 검증
```powershell
.\verify.ps1 # ruff + pytest. 커밋 전 게이트, CI 도 이 명령 하나
```
모든 패치를 적용한 뒤 **마지막에 1회**만 실행한다.
## 이 프로젝트의 규칙
- **폐쇄망 우선**: 새 런타임 의존성 추가는 기본적으로 금지. 필요하면 오프라인 휠 반입 절차
(`docs/operations/offline-install.md`)까지 함께 처리한다. CDN·원격 fetch·자동업데이트 금지.
- **`app-*.js` 로드 순서는 계약**: 클래식 스크립트 15개가 전역 스코프를 공유한다. 추가·제거·
재배치 시 `index.html` 과 `test_static_workbench.py` 의 `APP_SCRIPT_NAMES` 를 함께 고친다.
- **점수를 바꾸면 `SCORER_VERSION` 을 올린다** (`analysis/risk_scoring.py`). 이 스탬프가
가중치 변경과 스코어링 버그를 사후 구분하는 유일한 근거다.
- **직렬화 라운드트립은 원 점수를 재현해야 한다** (`store_serialization.py`). 과거에
`result_url` → `url` 별칭이 리스코어 시 0점을 45점으로 바꾼 사고가 있었다.
- **LLM 은 요약기가 아니라 증거 판독기**다. 인용 없는 claim 은 폐기한다. 프롬프트나 모델을
바꾸면 가짜 transport 가 아니라 **실모델로 프로브**한다 — 형식 예시 반향 결함은 실모델에서만
드러났다.
- **비ASCII 출력 주의**: 콘솔이 cp949 라 em-dash 등이 `UnicodeEncodeError` 로 프로세스를
죽인 이력이 있다. 스크립트 stdout 에 넣지 말 것.
## 커밋
의미 있는 묶음으로 1커밋 → `docs/INDEX.md` 에 결과 기록. 원격은 Forgejo(`tea` CLI, `gh` 아님).

View file

@ -34,15 +34,21 @@ cp .env.example .env
```bash ```bash
python run_copyrighter_server.py python run_copyrighter_server.py
# 또는 # 옵션은 런처에 그대로 전달됩니다
python -m rights_filter.server --host 127.0.0.1 --port 9500 python run_copyrighter_server.py --host 127.0.0.1 --port 9500
``` ```
> `python -m rights_filter.server` 로 직접 실행하려면 `src/` 가 import 경로에 있어야 합니다
> (`PYTHONPATH=src`, Windows PowerShell 은 `$env:PYTHONPATH="src"`). 런처는 이 경로 설정과
> 작업 디렉터리 고정을 대신 해 주므로 `run_copyrighter_server.py` 사용을 권장합니다.
주요 옵션: `--host`, `--port`, `--db`(SQLite 경로), `--images`(제출 이미지 폴더), `--static`(운영자 GUI 경로), `--env`(.env 경로). 주요 옵션: `--host`, `--port`, `--db`(SQLite 경로), `--images`(제출 이미지 폴더), `--static`(운영자 GUI 경로), `--env`(.env 경로).
기본값으로 실행하면 `http://127.0.0.1:9500` 에서 운영자 콘솔이 열립니다. 기본값으로 실행하면 `http://127.0.0.1:9500` 에서 운영자 콘솔이 열립니다.
> 보안: `COPYRIGHTER_AUTH_TOKEN`이 비어 있으면 데이터 라우트가 **인증 없이** 열립니다. 단일 호스트 개발 환경 외에는 반드시 토큰을 설정하세요. > 보안: `COPYRIGHTER_AUTH_TOKEN`이 비어 있으면 데이터 라우트가 **인증 없이** 열립니다. 단일 호스트 개발 환경 외에는 반드시 토큰을 설정하세요.
>
> 토큰을 설정하면 **틀린 토큰을 제시한 시도**는 클라이언트 주소별로 60초당 10회로 제한되고, 초과 시 `429`와 `Retry-After`를 반환합니다. 토큰을 아직 입력하지 않은 요청(콘솔 최초 로드)은 시도로 세지 않으므로 정상 운영자가 잠기지 않습니다.
## 프로젝트 구조 ## 프로젝트 구조
@ -51,17 +57,32 @@ src/rights_filter/ 분석·서버·통합·도메인 로직 (Python)
server/ http.server 기반 API + 정적 파일 서빙, SQLite 저장소 server/ http.server 기반 API + 정적 파일 서빙, SQLite 저장소
analysis/ 이미지 분석·위험도 산출 analysis/ 이미지 분석·위험도 산출
integrations/ Naver / Google / LLM 어댑터 integrations/ Naver / Google / LLM 어댑터
web/operator-gui/ 운영자 콘솔 (정적 SPA: index.html, app.js, styles.css) web/operator-gui/ 운영자 콘솔 (정적 SPA: index.html, app-*.js, styles.css)
tests/ pytest 테스트 tests/ pytest 테스트
operator_gui/ 운영자 GUI 정적 검증 + Playwright E2E operator_gui/ 운영자 GUI 정적 검증 + Playwright E2E
docs/ 기획·설계·운영 문서 docs/ 기획·설계·운영 문서
``` ```
## 테스트 콘솔 스크립트는 관심사별로 `app-*.js` 15개로 나뉘어 있고, **`index.html` 의 로드 순서가
의미를 갖습니다**(클래식 스크립트가 하나의 전역 스코프를 공유). 순서와 목록은
[`docs/PROJECT_MAP.md`](docs/PROJECT_MAP.md) 에 있으며 정적 테스트가 이를 고정합니다.
## 검증 / 테스트
커밋 전 게이트는 **단일 명령**입니다(CI 도 같은 명령을 호출합니다). ruff 린트와 pytest 전체를
순서대로 실행합니다.
```powershell
.\verify.ps1 # 린트 + 테스트 전체
.\verify.ps1 -SkipLint # 빠른 내부 루프 (테스트만)
.\verify.ps1 -Coverage # src/ 커버리지 리포트 포함
```
개별 실행이 필요할 때:
```bash ```bash
# 전체 python -m pytest # 또는 그냥 pytest — pytest.ini 가 경로를 설정합니다
python -m pytest python -m ruff check src tests # 설정: ruff.toml
# 운영자 GUI E2E (Playwright) # 운영자 GUI E2E (Playwright)
playwright install chromium # 빌드 머신에서 1회 — 브라우저 바이너리는 오프라인 번들에 포함 playwright install chromium # 빌드 머신에서 1회 — 브라우저 바이너리는 오프라인 번들에 포함
@ -73,6 +94,10 @@ python -m pytest tests/operator_gui/
## 문서 ## 문서
- [프로젝트 구조 지도 (PROJECT_MAP)](docs/PROJECT_MAP.md) — 파일·라우트·스키마 위치, 로드 순서, 알려진 부채
- [작업 이력 (INDEX)](docs/INDEX.md)
- [프로젝트 소개 및 기술 구현](docs/project-introduction-and-technical-implementation.md) - [프로젝트 소개 및 기술 구현](docs/project-introduction-and-technical-implementation.md)
- [운영 워크리스트](docs/operations/copyrighter-operation-worklist.md) - [운영 워크리스트](docs/operations/copyrighter-operation-worklist.md)
- [오프라인 설치 절차](docs/operations/offline-install.md) - [오프라인 설치 절차](docs/operations/offline-install.md)
- [캘리브레이션 코퍼스 절차](docs/operations/calibration-corpus.md) — 실이미지로 밴드 임계를 검증하는 법
- [구조 다이어그램 4종](docs/diagrams/) — 시스템 구성 · 증거 데이터 흐름 · 운영자 심사 · 제출물 상태 전이 (**내부 문서**, 납품물 아님)

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0

65
docs/INDEX.md Normal file
View file

@ -0,0 +1,65 @@
# Project INDEX
- 2026-09-26 · **페르소나 리뷰 권고 ④⑤ — 운영자 신원 + 게시처 성격.** ④ 감사 actor 31곳이 `"rights.ops"` 리터럴이라 "누가 반려했나"에 답이 없었다(법무 2-A 나머지 절반). `operator_identity.py` 의 ContextVar 로 요청 범위 운영자를 걸고 스토어는 `current_actor()` 를 읽는다 — 스토어 메서드 시그니처 무변경, 요청 밖(시드·테스트)은 기본값 유지. 콘솔: 좌측 하단 **운영자** 칸(localStorage), `X-Operator` 헤더(한글은 percent-encode, 서버가 되돌리고 제어문자 제거 — 로그 줄 위조 방지, 40자), 이름 없으면 단건·일괄 판정이 막힌다. 인증이 아니라 기록. E2E 는 init script 로 이름을 넣고 판정. ⑤ 기준 DB 유형 `news_host`(언론·공식)·`scraper_host`(무단 게시처) 신설 — **점수 무관**(판정 데이터 전 가중치 금지), 한 줄 요약이 `웹 완전일치 사본 4곳 (언론·공식 2 · 무단 게시처 1)`, 증거 호스트 칩 옆에 성격 배지(하위 도메인 포함). 테스트: HTTP actor(제어문자 제거 포함) · 스코어러 헤드라인 · 스토어 등록→헤드라인·점수 불변.
- 2026-09-25 · **페르소나 리뷰 권고 1~3.** ① 내부 판정 내보내기가 현재 점수·현재 `사용` 표시 대신 **`decisionSnapshot`(판정 시점)** 을 읽는다 — 판정 뒤 표시를 바꾸거나 리스코어돼도 "무엇을 보고 판정했나"가 바뀌지 않는다. 드리프트를 숨기지 않도록 `currentRiskScore`·`scoredVersion` 열 추가, 스냅샷 없는 옛 행은 현재값 + `no-snapshot`. 지원자용에는 둘 다 안 나간다(테스트). ② '위험' 명칭: 큐 열 `위험` → `검토 우선`(툴팁 "침해 판정이 아닙니다"), 점수 구성 패널에 "웹 노출·인물 식별성으로 매긴 검토 순서, 권리 증빙은 반영 안 됨" 한 줄, 가이드에 라이선스 인물 80 vs 무단 그림 50 예시로 명시. 통계 화면의 고위험/저위험 용어는 밴드 이름이라 유지. ③ `docs/operations/operating-modes.md` 신설 — A 전체 egress / B API 호스트만(결과 페이지 fetch 실패는 예외를 삼켜 **사본 비교가 조용히 빠짐**) / C 완전 폐쇄(**키를 둔 채 망만 막으면 매 호출이 실패 → 큐 전체 30/medium**, 키 제거·비상 차단 시 0 + `외부 검색 꺼짐`). offline-install.md 에서 링크.
- 2026-09-25 · **페르소나 × 소크라테스 3단논법 리뷰** (`docs/reviews/2026-09-25-persona-socratic-review.md`). 5 페르소나 9 논증. 붕괴 3: 법무 2-A(actor 익명 + **내보내기가 decisionSnapshot 이 아니라 현재 점수·현재 '사용' 표시를 읽음** — 코드 확인), 법무 2-B(점수는 권리 위험이 아니라 노출×식별성 — '위험' 명칭 과장), 결정자 5-A(판정 0건, 가중치 4회 변경 모두 판정 대조 없음). 권고 1~3(스냅샷 내보내기 · 명칭 교정 · 운영 모드 문서)은 S.
- 2026-09-25 · **codex 검토 잔여 ③④⑤ + 밀린 부채 4건.** ③ 파일명 충돌 — 제출 id 가 파일 stem 이라 `지원자A/photo.jpg`·`지원자B/photo.png` 가 한 건으로 합쳐져 둘째가 조용히 미심사였다. id 재유도는 하지 않고(기존 판정 보존, `_scan_records` 주석의 이유) `id_collisions()` 로 탐지 → 시드마다 `intakeConflicts` 로 bootstrap 에 싣고 파일당 1회 감사(`Submission file name collision`), 콘솔 투입 상태줄에 주황 경고 목록. 이름을 바꾸면 다음 리로드에서 사라진다. ④ 인용/무관 — 행 버튼 `미사용` 이 `false_positive` 로 가 대표 근거 하나를 고르면 나머지 유효 증거의 점수가 지워졌다 → 버튼을 `사용`/`무관` 으로, `사용` 재클릭은 `pending`(표시 해제, 점수 유지), 옛 `ignored` 는 `irrelevant` 로 보낸다. ⑤ 분모 0 은 `None` → 콘솔 `측정 불가`("미탐 0%" 가 측정한 적 없는 수치였다); 외부 검색이 꺼진(disabled·거버넌스) 케이스는 점수 0 그대로 두되 한 줄 요약에 `외부 검색 꺼짐` — low 가 "확인하고 깨끗함"으로 읽히지 않게. 부채: `operator-review-workflow` 1440×900 13px 넘침 → 겹치지 않는 제출·자동 레인 병합 + 노드 폭 120 → **다이어그램 5종 첫 REBUILD OK**, 카드의 v6 이전 문구("건너뛴 검색은 0점")도 교정; `tests/conftest.py` autouse 로 `OLLAMA_BASE_URL` 을 죽은 포트로 — ollama 가 실제로 떠 있는 상태에서 override 없이 test_http_app 38 passed, PROJECT_MAP 부채 행 삭제; 루트 `VERSION`(0.1.0) 단일 출처 + `rights_filter.version.release_version()`, `/health` 와 기동 로그에 버전·스코어러 버전. 가이드 근거 표시 행 갱신.
- 2026-09-23 · **판정 정정 일관성 — codex F4 · 4.2 · 4.3 · 4.5.** ① 내보내기 메모가 "마지막 비어 있지 않은 메모"라 반려(메모)→승인(무메모) 뒤 지원자용 CSV 에 반려 사유가 나갔다 → **마지막 판정의 메모만**(`_latest_decision_memo`, 검색 대기 마커 `HOLD_WAITING_MARKER` 는 벗겨냄). ② 보류·반려→승인 시 그 판정이 만든 주의 후보(`kb-watchlist-<id>`)를 **자동 철회**(`excluded`, 사유 "승인으로 판정 변경 - 자동 철회", 감사 `Watchlist candidate withdrawn`); 운영자가 이미 `승격` 한 확정 항목은 손대지 않고 감사에만 남긴다. ③ 철회·제외된 항목에 걸려 있던 **다른 케이스의 저장된 일치 행**은 `contributed=False` + `withdrawnAt` 로 퇴역시키고 리스코어(`_invalidate_matches_for_entry`, 기존 `exclude_watchlist_entry` 경로에도 적용 — 스코어러는 항목 상태가 아니라 저장된 행을 읽으므로 항목만 바꿔선 점수가 안 움직였다). `status` 컬럼은 CHECK 제약이라 못 쓰고 별도 스탬프. ④ 재보류·재반려가 `excluded`/`confirmed` 항목을 `watchlist`/active 로 되살리지 않는다. 테스트 3(내보내기 메모 · 승인 철회+리스코어+재보류 유지 · 검색 대기 보류). 가이드 승인 행 갱신.
- 2026-09-23 · **스코어러 v7 + 검색 대기 보류 — codex F1 차단.** 보류·반려로 자동 생성된 주의 후보(`entryStatus=watchlist`)와의 일치가 확정 항목과 같은 80/high 를 받아, 검색 장애로 보류한 정상 이미지의 재제출이 "알려진 권리 대상"처럼 올라왔다(`internal_analyzer` 는 status 를 실어 보내지만 스코어러가 안 봤다). 두 갈래로 끊었다: ① 스코어러 — `knowledge_entry_status == watchlist` 는 별도 축 **40/20, 상한 40, 단독 medium**("다시 보라"지 "알려진 대상"이 아니다), 확정(승격·수동 등록)만 80. 한 줄 요약도 `주의 후보 일치 0.95` 로 구분. 등록 제외 합이 135 로 상한에 닿을 수 있는 조합(주의 후보+웹 사본+인물+검색+라벨)은 순서를 잃어도 되는 케이스로 문서화. ② 스토어 — `record_decision(held)` 가 `has_coverage_gap()`(실패·일일 한도 미수행, 운영자가 무관 처리한 행 제외)이면 **주의 후보를 만들지 않고** 감사에 `검색 대기 (주의 후보 미생성)` 를 남긴다; 반려는 커버리지와 무관하게 생성. 테스트: 스코어러 5(+헤드라인 1) · 스토어 1(장애 보류 → 항목 0, 이어 반려 → watchlist 1) · 기존 `watchlist_similarity_is_high_risk` 는 인물 30 + 주의 후보 40 = 70 으로 그대로 high. 가이드·MAP 갱신, 다이어그램 태그 v7 재생성.
- 2026-09-23 · **codex 기능 검토** (`docs/reviews/2026-09-23-codex-feature-review.md`, 읽기 전용 샌드박스). 검증: F1 보류→주의 후보→재제출 시 80/high **CONFIRMED**(`internal_analyzer.py` 가 watchlist 도 knowledge_entry_id 로 내고 스코어러는 entry_status 를 안 본다) · F4 반려(메모)→승인(무메모) 시 내보내기 메모가 이전 반려 메모 **CONFIRMED**(`_latest_decision_memo` 가 마지막 비어있지 않은 메모를 고른다) · F2 '미사용' 버튼이 오탐과 구분 없이 점수 제거 **PLAUSIBLE**(UI 는 사용/미사용 2버튼, 서버 상태는 4종) · F6 파일명 stem 충돌 CONFIRMED(기존 문서화 한계) · 통계 분모 0 → 0% 표시 CONFIRMED(기존 부채). 채택 순위: ① 보류 사유 분리 + 주의 후보의 80점 자동 승격 차단 ② 판정 정정 일관성(메모·파생 기준) ③ 투입 충돌 감지 ④ 인용/무관/오탐 분리 ⑤ 검색 완결성 표시. 미채택: 출처별 신뢰도 자동 학습(codex 도 보류 권고), 지원자 통지 연동(L, 외부 시스템).
- 2026-09-23 · **심사 가이드 다이어그램** — `docs/diagrams/review-guide.html` (archify workflow v2, 5번째 다이어그램). 문서의 6단계를 3레인(읽기·근거 확인 / 판정 / 기준 DB 에 남는 것)으로: 한 줄 요약 → 점수 구성 → 증거 열기 → 근거 표시 → 판정 → 다음 미심사, 분기는 미수행 하한(→ 보류 후 재분석)과 판정 뒤 DB(주의 후보 자동 · 클리어 기준 등록 수동). 첫 안은 4레인·3항목 카드로 1440×900 에서 127px 넘쳐 레인 병합·제목 한 줄·카드 2항목으로 압축(노드 폭 148 로 종횡비 1.52 충족). validate 9/9 showcase · visual-check 4뷰포트 containment pass · manifest 5종. rebuild 스크립트·manifest 갱신기에 등록, PROJECT_MAP 표 추가.
- 2026-09-23 · **심사 가이드 v2** — `docs/operations/review-guide.md`. 기존 `operator-review-workflow` 다이어그램은 시스템 흐름(폴더→분석→채점)이라 "어떻게 심사하고 무엇을 보는가"에 답하지 못했다. 운영자 관점으로 다시 썼다: 한 건 심사 6단계(한 줄 읽기 → 점수 구성 → 동일→부분→페이지 순 증거 → 사용/무관/오탐 표시 → A/H/R → N), 한 줄 요약의 신호 9종과 판정 무게(라벨은 권리와 무관, 미수행은 안전 아님), 승인/보류/반려 기준과 **뒤에 일어나는 일**(보류·반려 → 주의 후보, 승인은 DB 에 안 남으니 `클리어 기준 등록` 별도), 반려 사유 프리셋, 키보드, 일괄 처리, 헷갈리는 것 5개, 종료 루틴(내보내기·통계·임계 재검토). 콘솔 라벨은 실제 index.html 문자열로 대조.
- 2026-09-22 · **한 줄 요약(headline) — 운영자의 첫 1초를 사유 원문 덤프에서 사실·규모로.** 코퍼스 DB 로 실 콘솔을 띄워 보니 큐의 '상위 근거' 칸이 `구글 visually s... 구글 페이지 with ...`(사유 원문 둘을 잘라 붙인 것)이었고 증거 제목도 `구글 full image match found` 로 절반 영어였다. 스코어러가 점수 계산과 별개로 집계(`_Tally`)해 `riskBreakdown.headline` 에 `웹 완전일치 사본 4곳 · 식별 가능 인물 · 이름 있는 검색 승격 2건 · 라벨 9개` 를 싣는다 — 순서는 예산표, 웹은 가장 강한 뷰만 **호스트 수(곳)** 로, 등록 일치·클리어 이력·미수행·실패·신뢰 출처 제외까지 한 줄. 큐 행과 케이스 요약 보드의 첫 줄이 이것으로 바뀌고 옛 케이스(headline 없음)만 예전 배치. `formatReason` 에 Vision 매치 4종·검색결과 사본·기준 DB 유사도 한국어 매핑 추가. 실렌더(claude-in-chrome, 코퍼스 20장)로 큐·케이스 확인 — 점수·밴드 변화 없음(SCORER_VERSION 유지).
- 2026-09-22 · **스코어러 v6 — 외부 검색 미수행의 조용한 통과를 막는다.** 배포 판단에서 파일럿 전 유일한 차단으로 꼽은 구멍: 네이버 일일 한도 100 = 20장이라 21장째부터 검색이 `SEARCH_SKIPPED` 0점·무사유로 빠져 점수가 **낮게** 나왔다(키 만료·비상 차단도 같은 경로). 정책 거절 사유 5종 중 하루 안에서 케이스마다 달라지고 어디에도 안 보이는 하나(`usage limit reached`)만 v5 의 실패 하한 30 에 합류시키고 사유에 `<provider> 미수행 — <사유>` 를 남긴다. `disabled` 는 키 미설정 배포와 비상 차단이 같은 사유를 쓰고 둘 다 콘솔 providerState 에 보이므로 그대로 0점·무사유(하한을 걸면 키 없는 배포의 큐 전체가 medium 이 된다 — 기본 스토어 테스트가 잡아냈다); 거버넌스 스위치 셋(compliance · metadata logging · online mode)도 배포 전체에 균일해 0점·무사유. 라운드트립은 신선 점수를 재현(테스트). 다이어그램 태그 v6 재생성.
- 2026-09-22 · **스코어러 v5 — 축 예산을 총합 기준으로 재배분, 실이미지 100점 포화 20/20 → 0/20.** 첫 실측이 보인 원인은 축 예산의 **합**(245 vs 상한 100)이었고, 캡 없는 두 축만 캡하는 시뮬레이션(80/50 · 60/40)으로는 20/20 이 그대로 100 이라 축 하나 손보기로는 안 됐다. 새 예산: 등록 권리 대상 일치 80(단독 high) · 웹 존재 40(검색결과 사본 유사도·Vision 매치·CSE 페이지가 제공자 무관 **한 풀** — 같은 사실의 다른 뷰, 뷰 강도로만 40/30/10 차등) · 인물 30/10 · 이름 있는 검색 승격 15 · 라벨 10 → 등록 제외 최대 95. 외부 검색 실패는 +30/행 가산에서 medium **하한**으로(네이버 장애 하루가 웹에 있는 모든 이미지를 high 로 밀던 경로 차단). 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 판별(둘 다 라운드트립 생존). 격자 432: high 92→75%, 포화 80→44%(남은 포화는 전부 등록 강한 일치 조합), 웹 신호만 high 13/24→**0/24** — v2 원칙 복원. 실이미지 20장 리스코어: 100점 20→**0**, 구별 점수 1→4(75/80/92/95), 밴드는 전부 high 유지 — 전부 웹에 있는 인물 사진이라 그것이 맞는 답이고 medium/low 임계는 여전히 미검증(저위험 기대 이미지 0장). 스코어러 테스트 45(신규 10) · 다른 파일의 인물 단독 35 고정값 4곳은 상수 참조로 · 다이어그램 spec 3종 v5(+카드 문구) · `calibration-corpus.md` 전후표. 다이어그램 4종 재생성(archify 2.17.0-dev.1) — `operator-review-workflow` 는 HEAD spec 그대로 새 `desktop-readability` 검사에 걸려(viewBox 1112 > 1085, 7px 라벨 투영 5.89px) 노드 폭 132→124 로 통과시켰으나, 그만큼 확대되어 1440×900 에서 세로 13px 넘침(visual-check containment fail; HEAD 는 같은 뷰포트에서 readability fail). 검증 9/9·showcase 는 통과, 사실 게이트·매니페스트도 통과 — 잔여는 툴체인 드리프트로 기록.
- 2026-09-22 · **실이미지 20장 첫 실측 — 20/20 이 100점/high, 포화율 100%.** 이 프로젝트 최초의 실이미지 코퍼스(`sample/` 20장 → `data/corpus.sqlite3`, 증거 1,615행, Vision 809 + 네이버 692). 8/8 평가는 합성 격자에서 high 77.7%·포화 56.2%였는데 **실이미지에서는 전부 상한**이다. 탐지 자체는 정상 — 15/20 은 웹에서 **완전일치(similarity 1.00) 사본**이 발견됐고 4장은 근사일치다. 문제는 밴드가 아니라 **순서가 없다는 것**이다(큐 정렬이 riskScore 내림차순인데 전부 100이라 no-op). 근본 원인은 축별 예산의 합이다 — 인물 35 + 웹게시 60 + 엔티티 20 = **115 로 이미 상한 초과**이고, 여기에 캡이 없는 두 축(검색결과 이미지 유사도 장당 평균 216점, 네이버 승격 평균 70점)이 더해진다. 최대 245점 대 상한 100. **캡 없는 두 축에 캡을 씌워도(지문 80/네이버 50, 60/40 모두 시뮬레이션) 20/20 이 그대로 100** — v2~v4 가 축을 하나씩 캡했지만 **합을 한 번도 보지 않았다.** 검색결과 유사도가 +80/건 무제한인 것은 v2 가 고친 것과 같은 오류다(같은 이미지가 웹에 있다는 **하나의 사실**을 URL 수만큼 셈). 코퍼스 한계도 명시: 20장 전부 웹 유통 인물 사진이라 **low 기대군이 0건** — 이 코퍼스로는 medium/low 임계를 영원히 검증할 수 없다(`lowBandRejectionRate` 분모 0). 첫 시도는 `.env` 로딩 누락으로 프로바이더가 전부 비활성이었고(외부 호출 0콜) 그 무효 런이 부수적으로 알려준 것 — **웹 증거 0건이어도 인물만으로 35/medium** 이고 `external_skipped` 는 점수·사유 어디에도 안 뜬다(키 만료·쿼터 고갈이 조용히 지나간다). `sample/`·`corpus/` 를 gitignore 에 추가(저작권 이미지 커밋 방지)
- 2026-09-22 · **캘리브레이션 측정 도구 신설 + 구조적 거동 첫 실측.** `SCORER_VERSION` 이 v1→v4 로 갔는데 v3(제공자별 풀링)·v4(신뢰 출처)는 분포를 한 번도 측정하지 않고 나갔다 — 8/8 평가의 224조합 하네스가 저장소에 없고(그 문서의 두 수치 174/224=77.7% 와 82.6% 가 서로 안 맞아 격자가 달랐음을 시사) 과거와 비교 가능한 재현이 불가능해, 격자를 명시적으로 고정한 `scripts/score_distribution.py`(432조합)를 새 기준선으로 만들었다. **실측 결과(v4)**: 신뢰 출처 미등록 시 high 92% · 100점 포화 80% · **웹 신호만으로 high 13/24**, 신뢰 출처 등록 시 high 63% · 포화 38% · 웹 전용 high 0/24. 드러난 것 — v2 가 세운 "웹 존재만으로 high 불가" 원칙이 깨져 있다(presence 풀 60 + entity 풀 20 = 80, 두 풀의 **합**은 캡되지 않음), 그리고 **v3 의 제공자별 풀링이 이를 키웠다**(웹 전용 high 4/12 → 9/12, 전체 high 88% → 97%). 대응 후보(웹 유래 총합 상한)는 미적용 — 적용하면 또 한 번의 미측정 변경이 되므로 결정 후 진행한다. 함께 `scripts/sweep_thresholds.py`(판정 시점 `decisionSnapshot.riskScore` 기준 임계 스윕, 가짜 판정 6건으로 동작 검증)와 `docs/operations/calibration-corpus.md`(이미지 구성·쿼터 준비·투입·판정·측정 절차) 신설. **실이미지 코퍼스와 판정은 사람이 해야 한다** — 판정을 내가 만들면 측정이 성립하지 않는다. **ruff clean · pytest 645 passed**
- 2026-09-21 · **반려 키보드 완결** — 승인·보류는 1타인데 반려만 메모 필수라 손이 마우스로 갔다(8/8 평가 P2-10). `R` 이 메모 필드에 포커스만 주고, 입력 중에는 전역 단축키가 막혀서 거기서부터는 **마우스로만 제출**할 수 있는 구조였다 — 법적으로 가장 중요한 경로가 가장 느렸다. `R` → 사유 선택 모드(프리셋 1~9 번호 표시) → 숫자 1타로 반려 완료, 자유 서술은 메모 안에서 `Ctrl+Enter`. 화면의 번호가 곧 단축키가 되도록 프리셋 칩에 키 배지를 붙였다(외울 필요 없음). **숫자는 사유 선택 모드 안에서만 의미를 갖는다** — 모드 밖에서 잘못 누른 키 하나로 반려가 나가면 안 되고, 그 가드 순서를 테스트로 고정했다. 실렌더(Playwright): `R`→`2` 로 `{"decision":"rejected","memo":"라이선스 확인 불가로 반려"}`, 메모+`Ctrl+Enter` 로 `{"decision":"rejected","memo":"자유 서술 사유"}`, 콘솔 에러 0. 반려 후 승인·보류와 같이 다음 미심사 케이스로 이동한다. **ruff clean · pytest 645 passed**(정적 3건)
- 2026-09-21 · **점수 구성 패널** — "증거 51개인데 왜 35점"에 답한다. 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 갖게 되면서 증거 개수와 점수의 간극이 벌어졌는데, 그 이유가 화면 어디에도 없었다 — 제가 그 간극을 만든 장본인이라 자체 부채였다. `ScoreResult.breakdown` 을 신설해 종류별 기여·풀 상한에 삼켜진 점수·제외된 행을 기록하고, 제출물에 `riskBreakdown` 으로 영속화한 뒤 증거 요약과 증거 목록 **사이**(그 질문이 생기는 지점)에 렌더한다. 실렌더 예: `지식DB 지문 매칭 80 / 웹 게시(google) 60 (원래 125 · 상한 60) / 웹 엔티티 라벨 20 (원래 114 · 상한 20) / 신뢰 출처 posar.info 3행 제외 / 합계 115점이 상한 100에 걸렸습니다`. **점수는 하나도 안 바뀐다** — 기존 스코어러 테스트 31건이 그대로 통과하므로 `SCORER_VERSION` 은 v4 유지(가중치·버킷·밴드 불변). 분해 합계가 항상 점수와 일치하는지도 테스트로 고정했다(분해가 다른 이야기를 하면 분해가 아니라 또 하나의 주장이 된다). **ruff clean · pytest 642 passed**(스코어러 5 + 정적 1)
- 2026-09-21 · **증거 일괄 표시** (`POST /api/evidence/status-batch`) — 운영자 되먹임이 순증 노동이던 문제를 끝낸다. 8/8 평가 기준으로 실 DB 제출물 하나를 100→35 로 내리려면 google 증거 **51행을 개별 클릭**해야 했다(단건 API 만 존재). 증거 그룹 헤더의 개수 옆에 `전부 무관` 1타를 붙였다. 서버는 **한 트랜잭션 · 리스코어 1회 · 감사 이벤트 1건** — 51행이면 감사도 51건이 되어 500행 캡을 한 번의 클릭으로 먹는다. 행별 who/when/note 는 그대로 남으므로 개별 행이 설명 불가가 되지는 않는다. 남의 증거 id 가 섞이면 **아무것도 표시하지 않고 전체 실패**(절반만 반영되면 운영자가 무엇이 됐는지 알 수 없다), 상한 500(일괄 판정 200 과 단위가 다름 — 그쪽은 케이스 수, 이쪽은 한 케이스의 증거 행 수). 그룹 액션은 `irrelevant`(무관)로 보낸다 — 행별 "미사용"은 `false_positive`(오탐)에 매핑되는데, 제공자 결과 전체를 오탐이라 단정하는 건 과한 주장이다. 실렌더(Playwright 1440×900): 51행 그룹에서 1타로 `{"status":"irrelevant","evidence_ids":[51개]}` 전송, 콘솔 에러 0. **ruff clean · pytest 636 passed**
- 2026-09-21 · **신뢰 출처** (`SCORER_VERSION` v3 → v4) — 자사·계약처 도메인에서 발견된 웹 탐지 행을 0점 처리한다. 8/8 평가가 "목록만 정해지면 `cleared_reference` 패턴으로 바로 붙는다"고 남긴 항목이고, 전제였던 host 파괴는 9/18 에 고쳤다. 저장 위치는 **지식DB**(사용자 결정) — `trusted_host` 항목의 이름이 호스트이고 별칭이 같은 주체의 다른 도메인이다. 신뢰 철회에 별도 스위치를 두지 않고 지식 항목의 기존 활성/비활성 수명주기를 그대로 쓴다. **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다(실측: 신뢰 등록 전 80/high → 후 35/medium). 서브도메인은 접미사 매치로 같은 주체 취급(`cdn.posar.info`), `posar.info.evil.test` 같은 유사 도메인은 불일치, 점 없는 값(`com`)은 무시. 신선한 CSE 행은 `host` 키가 없으므로 `domain`(displayLink)에서 해석한다. 제외된 행은 점수에서 빠지되 **사유로 남는다** — 증거 목록에 그 행이 그대로 보이므로 왜 조용한지 말해줘야 한다. 다이어그램 사실 게이트가 v3→v4 를 **3개 다이어그램에서 자동으로 잡아** 스펙 수정 + 재생성으로 이어졌다(게이트 신설 이틀 만의 첫 실사용). **ruff clean · pytest 632 passed**(스코어러 7 + 스토어 3)
- 2026-09-21 · **선례 승계** — 재제출을 다시 심사하지 않게 한다. 8/8 실효성 평가의 미해결 결론("검색·기록 노동은 줄여주나 심사 건수는 못 줄인다")을 직접 겨냥한 첫 기능이다. 중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능인데(재저장·80%/50%축소·밝기 변형 24/24 그룹핑, 같은 24건 SHA-256 일치 0건), 서버가 계산해 내려보내던 `priorDecisions`·`conflicting` 이 **큐 배지의 title 속성에만** 있었다 — 마우스를 올린 사람만 보는 자리라 아무것도 막지 못했다. 서버 변경 없이 콘솔만 고쳤다: 배지 본문에 선례 표시, 판정 패널(결정 메모 바로 위)에 선례 배너 + `선례 승계` 1타. 승계는 별도 경로를 만들지 않고 메모를 채워 기존 `setDecision` 을 타므로 반려 메모 필수·판정 스냅샷·감사 이벤트가 그대로 유지된다. **판정 불일치(conflicting)면 승계를 제안하지 않는다** — 그때가 사람이 봐야 하는 상황이다. 실렌더 확인(Playwright, 1440×900): 세 상태(승계 가능/불일치/선례 없음) 배너·버튼 유무가 의도대로였고 승계 클릭 시 `{"decision":"rejected","memo":"선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은 판정"}` 전송, 콘솔 에러 0. **ruff clean · pytest 622 passed**(정적 3건 추가)
- 2026-09-21 · 다이어그램 용도를 **내부 문서로 확정**하고 그 기준으로 부채를 재판정. 한글 폰트 미번들·영어 뷰어 UI 는 archify 한계라 spec 으로 못 고치는데, 사내 머신에는 한글 폰트가 있으므로 low 로 내렸다(인수물로 승격하면 둘이 먼저 blocker — PROJECT_MAP 다이어그램 절에 승격 조건으로 명시). 이어서 **코드↔그림 리터럴 드리프트 게이트** `tests/test_diagram_facts.py` 19건 신설 — 그림에 하드카피된 코드 유래 값(SCORER_VERSION · SCHEMA_VERSION · 믹스인 8개 · `app-*.js` 15개 · 바디 10MB · 인증 10회/60초 · 프롬프트 12행 · 풀 상한 60/20 · 기본 모델 · 런타임 의존성 3종)을 실제 코드 상수와 대조하고, 상수가 아닌 "지문 유사도 0.9+ → 80점" 은 실제로 채점해 확인한다. SCORER_VERSION v3→v4 와 MAX_PROMPT_EVIDENCE 12→16 을 실제로 넣어 **영향받는 세 다이어그램 전부에서 차단됨**을 실증. 발견 가능성도 정리 — README 문서 색인에 등재, `docs/diagrams/README.md` 신설(폴더 안에 '직접 수정 금지'·재생성 명령·CSP 주의). **ruff clean · pytest 619 passed**(사실 게이트 +19)
- 2026-09-21 · 다이어그램 감사 후 **드리프트 게이트 신설**. 에이전트 6축 감사가 주간 한도로 절반(2축)만 완주했고 검증 단계도 죽어, 완주분 15건을 저널에서 복구한 뒤 결정적 주장은 직접 재검증했다. 확정된 구멍 — 그림 4종의 재생성 절차가 **문서대로는 실행 불가**(`archify` 실행파일이 PATH 에 없음), 소스↔산출물 동기화를 보는 장치 0개, 사이드카 24개가 개발 머신 절대경로를 담은 채 미추적·비ignore, 임베드 폰트에 **한글 구간 0건**(`U+AC00` 매치 0 · 폴백은 중국어 폰트뿐). 실수정: `scripts/rebuild-diagrams.ps1`(validate→deliver→visual-check→manifest, 실행 확인)과 `scripts/update_diagram_manifest.py`, 수신증 `docs/diagrams/manifest.json`, 게이트 `tests/test_diagram_sync.py` 14건(HTML 직접 수정·spec 만 수정 두 시나리오 모두 실제로 차단됨을 실증), `.gitignore` 에 사이드카 제외(미추적 24→0), `.gitattributes` 에 `linguist-generated -diff merge=binary`. 재생성해도 4종 sha256 불변 — 바이트 결정성 재확인. 한글 폰트·영어 뷰어 UI·architecture 좌표 하드코딩·lifecycle 손튜닝 8개소는 PROJECT_MAP 부채 표에 등재. **ruff clean · pytest 600 passed**(게이트 +14)
- 2026-09-19 · 다이어그램 4종으로 확장 — `system-architecture`(폐쇄망 경계·SSRF 가드 앞 호출 게이트), `operator-review-workflow`(4레인, 비상 차단 시 심사 계속 경로), `submission-lifecycle`(분석 실패·보류가 분석으로 복귀하는 두 고리)를 신설하고 기존 `evidence-dataflow` 포함 4종의 **범례 항목**을 한글화했다(렌더러 기본값이 "User UI / Agent logic / policy·PII" 같은 타 도메인 문구였다). **뷰어 크롬은 영어로 남는다** — `locale` enum 이 en/zh-CN 뿐이고 템플릿이 `<html lang="en">` 을 하드코딩하므로 Light/Dark·Present·Export·범례 제목("Legend")은 저자가 바꿀 수 없다. 9/21 감사에서 확인. 작성 중 내린 고도 판단: 아키텍처에서는 analyzer/scorer 를 한 컴포넌트로 합쳤고(세부는 dataflow 담당), 라이프사이클에서 "재분석 요청"은 decisionStatus 가 아닌 운영자 액션이라 상태에서 제외해 복귀 엣지를 하나로 줄였다. 4종 모두 archify showcase 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0) + visual-check 통과(1440×900 · 2048×1320 각 Light/Dark, 오버플로 0). 수신증 — dataflow html a62f0f5c / architecture f5a4b6e1 / workflow 402914a3 / lifecycle 62e934a3
- 2026-09-19 · `docs/diagrams/evidence-dataflow.html` 신설 — 증거 데이터 흐름(수집 → 경계 검사 → 정규화·채점 → 영속화 → 소비) 자체 완결 다이어그램. 9/18 리뷰 수정의 불변식을 그림으로 고정한다: **제공자별 풀·버킷**(RiskScorer v3), **라운드트립이 원 점수를 재현**(`_evidence_payload` ↔ `_domain_evidence_from_ui`), **host 는 저장 시 해석·복원 시 우선 반환**, 지식DB 지문 매칭이 풀링 밖 유일한 high 승격 경로. 프로덕션 LLM 경로가 영속화 payload 를 복원해 쓴다는 사실(`store_operations.py:487`, `EvidenceEnricher` 는 테스트 전용)도 반영. archify showcase 검증 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0), 브라우저 증거 통과(1440×900 · 2048×1320 각 Light/Dark 4컷, 오버플로 0, 최소 투영 텍스트 6.71px ≥ 6px). spec sha256 42c334cd · html sha256 268f3352 (822,655 B). CDN·원격 fetch 없음 — 폐쇄망 규칙 준수
- 2026-09-18 · `/code-review` 15건 전수 처리 (**헤드라인 1건은 반증**). 반증: "CSE 웹 증거가 라운드트립 후 0→45점" — 리뷰가 `match` 키 없는 합성 Evidence 로 재현한 것이고, 실제 CSE 웹 결과는 `google_custom_search.py:301` 에서 `match="page"` 를 달고 오며 `matchType` 이 충실히 왕복한다(신선/복구 모두 35점). 직렬화 불변식 위반 없음. 대신 그 조사에서 **리뷰가 놓친 라이브 버그**가 나왔다: `counted_web_buckets` 가 제공자를 안 가려 Vision 이 `match="page"` 를 먼저 보고하면 **CSE 웹 히트가 점수도 사유도 없이 통째로 버려졌다**. 실수정 — **점수 v2 → v3**(풀·버킷을 제공자별로: Vision 자기 상관 뷰 4개는 60/medium 유지, Vision full + CSE page 는 45+35=80/high 복원 · 도달 불가였던 `category` 탈출구 제거 + 미등록 유명인 미탐은 의도된 트레이드임을 명시, 승격 경로는 지식DB/FINGERPRINT), **저장·복구 5종**(`_result_host` 가 자기 host 를 안 읽어 재직렬화 시 API 이름으로 퇴행하던 잠재 버그 + 점 없는 토큰 호스트 거부 · 복구가 남긴 고아 `faceCrops` 참조를 재생성/정리 · `assetMissing` 해제 경로 신설로 두 번째 소실도 감사에 남김 · 플래그+감사를 한 트랜잭션으로 · 스코어러 마이그레이션 밴드 변경에 `scoredVersion` 스탬프 + 감사 1회), **콘솔 4종**(긴 CDN 호스트가 제목을 세로 컬럼으로 만든 flex 버그 — 실측 360px 51×95px→246×19px, **1280px 9×380px→205×19px 로 폰 전용이 아니었음** · 20ch 로 잘린 호스트를 툴팁에 · `bloggerLink` 폴백 누락 · 보존 케이스 설명 표시), **PROMPT_VERSION ko-v4-structured-host**(실모델 프로브 qwen2.5:7b — grounded 3/dropped 0/verified True, 반향 없음). 테스트 +14건. **ruff clean · pytest 586 passed**. 발견된 기존 결함 1건은 미수정 기록: 로컬 ollama 가 떠 있으면 `test_http_app.py` 3건이 실호출 타임아웃으로 실패한다(기본 생성 store 가 `127.0.0.1:11434` 기본값에 붙음 — clean HEAD 에서도 동일). 커밋 b0aa4c5 / 4ba83eb / e38dad8
- 2026-08-09 · P1 2차 — **게시 호스트 복구**. P1 3번의 전제("호스트는 저장돼 있고 점수만 안 쓴다")가 틀렸다: CSE 가 실호스트를 `data["domain"]` 에 넣는데 `store_serialization.py:298` 이 `provider` 를 우선해 **저장 시점에 덮어썼고**, Vision·Naver 는 URL 만 준다. 결과적으로 LLM 프롬프트 12줄이 전부 `domain=google` 이라 "어디에 떠 있나"를 모델도 운영자도 못 봤다. `domain` 은 provider 커버리지·CSE 시그니처 매칭의 키라 재사용 불가 → **`host` 별도 키 신설**(`_result_host`: CSE displayLink 또는 URL 파싱, www 제거·소문자). 점수 무영향·`_evidence_id` 무변화(도메인 객체 해싱이라 payload 키 추가가 id 를 안 바꿈). `PROMPT_DATA_FIELDS` 의 `domain`→`host`, 콘솔 증거 카드에 monospace `host-chip`(레거시 행은 `evidenceHost()` 가 저장 URL 에서 파생 — 마이그레이션 불요). **실렌더 검증**(실 DB, 칩 82개 전부 유효 호스트, 콘솔 에러 0)에서만 드러난 결함 2건 수정: 긴 CDN 호스트가 제목을 한 글자씩 세로로 깨뜨림 → 칩 20ch 상한 + 제목 `flex:1 1 auto`, 이후 짧은 호스트까지 잘림 → 칩 `flex:0 0 auto` 고정. PROVENANCE 배점은 **보류**(카테고리가 2종뿐이라 라벨이 사실상 "geometry AND 자막"이고, "새 신호는 0점 출시 후 상관 확인" 결정과 충돌 — 한계를 `provenance.py` 상수 옆에 기록). **pytest 574 passed**(착수 전 570)
- 2026-08-09 · P1 1차 — 판별력 복구를 위한 신호 풀 도입, **`SCORER_VERSION` v1 → v2** — 포화 동인이 둘이었다: `category` 없는 raw Vision 라벨이 이름별 버킷으로 무한 누적(`WEB_ENTITY_MAX_POINTS=20`, medium 미만으로 캡)되고, full/partial/page/visual 이 **하나의 사실을 4개 상관된 뷰로** 보고하는데 합산돼 125→100 포화(`WEB_PRESENCE_MAX_POINTS=60`, high 미만으로 캡). `category`(character/celebrity/work) 엔티티는 권리 분류이므로 풀에서 제외하고 +45 유지. 원칙: **웹에 존재한다는 사실만으로는 high 가 되지 않고, 두 번째 종류의 신호가 필요하다.** 224조합 재측정 — 웹 신호만으로 high 가 되는 조합 **2/7 → 0/7**, 100점 포화 66.5%→51.3%(전체 high 비율 82.6%→76.3%은 격자가 균등샘플이라 대리값이 아님을 명기). 웹에 없는 오리지널·cleared 자산이 100/high → 20/low 로 교정. 라운드트립 버킷 보존 테스트는 캡에 가려지지 않도록 저신뢰도로 재작성. **pytest 570 passed**(착수 전 565) → [평가 리포트 6절](reviews/2026-08-08-utility-assessment.md)
- 2026-08-08 · 기능 실효성 평가(6축 조사 + 축별 적대적 반증, 에이전트 12) 후 P0 4종 수정 — 결론은 **"검색·기록 노동은 줄여주나 심사 건수는 못 줄인다"**(신호조합 224개 전수 시 high 77.7%·low 3.6%, 지배 동인은 match 스택이 아니라 **web entity 누적** — 이미지 매칭 0건이어도 entity 3개면 100/high, 웹에 없는 오리지널도 66/medium). 실수정: **폴더 정리가 판정 기록을 지우던 경로 차단**(판정 완료 가드 + `assetMissing` 1회 마킹; 실증으로 export 1행→0행 확인됨), **비상 차단 스위치 실배선**(`_external_calls_blocked` 신설 → 자동 Naver/CSE/Vision/얼굴크롭 5개 경로; 이전엔 눌러도 외부 호출 5회 동일), **생체 크롭의 보존기간 초과 생존 차단**(스냅샷 제외 + 레거시 아카이브 복구 스킵 — 복구가 mtime을 리셋해 90일을 재시작시켰음), **오프라인 설치 문서의 계약 위반 모델 정정**(0.5b→7b + ollama 바이너리·GPU 전제·거짓 disable 서술). DB 6건은 첫 커밋보다 2주 앞선 구빌드 스냅샷이라 검증 코퍼스가 아님을 확인. **pytest 565 passed**(착수 전 562), 린트 클린 → [docs/reviews/2026-08-08-utility-assessment.md](reviews/2026-08-08-utility-assessment.md)
- 2026-08-08 · 프로젝트 분석 후 인프라·구조·보안 정비 — **원격 백업 복구**(20커밋이 로컬에만 있었음: `main` 2 + 작업브랜치 18 → Forgejo push), **단일 verify 게이트 신설**(`verify.ps1` + `pytest.ini` + `ruff.toml`, ruff 0.12.0 dev 핀; 린트 18건 정리), **`app.js` 5,428줄 → `app-*.js` 15개 분할**(순수 라인범위 추출로 바이트 단위 라운드트립 검증, 로드 순서를 정적 테스트로 고정), **보안 2종 실수정**(SSRF DNS-rebinding TOCTOU — 단일 DNS 해석 후 그 답변으로만 연결하는 `_guarded_create_connection`; 실패 인증 스로틀 — 자격증명 제시 실패만 10회/60초, `429`+`Retry-After`), 7/11 체크리스트 재확인(6건은 이미 수정돼 있던 낡은 항목, Batch 8 은 Batch 6 과 중복이라 정리), `CLAUDE.md`·`docs/PROJECT_MAP.md` 신설. E2E 플레이크(업로드 대기 10초 → `UPLOAD_ANALYSIS_TIMEOUT_MS` 30초) 해소. **pytest 562 passed**(착수 전 533/1 failed), 린트 클린. 실렌더 확인: 큐·케이스 심사 정상 렌더, 스크립트 15개 전부 200, 새로고침 후 콘솔 메시지 0
- 2026-07-24 · 멀티에이전트 프로젝트 점검(에이전트 109, 적대적 검증 포함) — pytest 377/379, 런타임 스모크 9/9 통과, 신규 CONFIRMED 43건(HIGH 7) + 7/11 체크리스트 중 3건 실수정 확인 + 기능 제안 상위 5 선정 → [docs/reviews/2026-07-24-project-review.md](reviews/2026-07-24-project-review.md)
- 2026-07-26 · 운영자 콘솔 실렌더 검증 + 결함 5종 수정 (`d20e6d6`) — 서버 기동 불가(cp949에서 em-dash `UnicodeEncodeError`로 프로세스 사망, 납품 환경 치명) · 요약이 모델이 못 본 78건을 "연결 근거"로 주장(B단계가 절반만 막았음) · `grounded/droppedClaims` 미영속화 · app.js가 서버 그룹 분류를 자체 매핑으로 덮어씀 · 기본 모델이 클라이언트/배선에서 불일치(미설치 모델 지목). 실렌더 확인: 출처 표기 그룹 자체 섹션 렌더, `[E1,E2...]` 인용 2단 요약, `sourceIds` 78→10, qwen2.5:7b 6.0초. **pytest 534 passed**
- 2026-07-25 · C단계: 출처 신호 2종 (`5a9fcc0`) — EXIF/IPTC 권리 표기 추출(화이트리스트 6필드, GPS·시리얼·Make/Model 미판독) + 방송캡처 포렌식(레터박스·자막밴드·프레임크기, 독립 카테고리 2개 이상). 하나의 `EvidenceSource.PROVENANCE`·하나의 GUI 그룹으로 배관 12곳 비용 1회 지불. **둘 다 0점 라벨**(캘리브레이션 전). 실측: 실제 이미지 6장에서 캡처 오탐 0·권리메타 히트 0(정밀도 확인, 가치는 이 코퍼스에서 미입증). XMP/C2PA는 `defusedxml` 미반입으로 제외. **pytest 531 passed**
- 2026-07-25 · 실모델 프로브로 형식 예시 반향 결함 발견·차단 (`6bbaaf6`) — qwen2.5:0.5b가 프롬프트의 형식 예시를 그대로 출력하며 실제 라벨 12개를 전부 인용해 집합 포함 검사를 통과. 가짜 transport 테스트로는 불가능한 발견. 기본 모델을 실측으로 `qwen2.5:7b` 확정(0.5b 55.7s 계약위반 / 7b 8.8s 준수 / qwen3:8b 35.7s 준수)
- 2026-07-25 · B단계: LLM을 요약기에서 증거 판독기로 (`6be263a`) — 입력 화이트리스트 9필드+상위 12건 상한(`promotion_reason` 제외), `format=json` 구조화 인용 + 라벨 집합 포함 검사로 미인용 claim 폐기, 기본 모델 0.5B→3B 및 `num_ctx`/`num_predict`/`keep_alive`/`timeout` 노출, 시드 LLM 호출을 `_write_lock` 밖으로(시드당 20건 상한). 잠금 이동이 만든 provider 커버리지 회귀(요약 존재하는데 `not_run` 보고)를 나레이션 후 재계산으로 수정. **pytest 515 passed**
- 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)

235
docs/PROJECT_MAP.md Normal file
View file

@ -0,0 +1,235 @@
# PROJECT MAP
> 어디에 무엇이 있는지에 대한 참조 문서. **작업 전 여기서 위치를 찾고 필요한 부분만 연다**
> (전체 소스 재스캔 금지). 새 파일·라우트·스키마가 생기면 해당 섹션을 갱신한다.
>
> 무엇을 했는지의 기록은 [INDEX.md](INDEX.md)에 있다. 이 문서는 **현재 구조**만 다룬다.
## 한 줄 요약
이미지 저작권 위험을 자동 분석하고, 운영자가 콘솔에서 근거를 확인해 승인/보류/반려를
판정하는 폐쇄망(air-gapped) 도구. Python 3.13 표준 `http.server` + SQLite + 정적 SPA.
런타임 서드파티는 이미지 처리 3종(`numpy`/`opencv-python-headless`/`pillow`)뿐.
## 검증 (단일 명령)
```powershell
.\verify.ps1 # ruff + pytest 전체 — 커밋 전 게이트, CI도 이 명령
.\verify.ps1 -SkipLint # 빠른 내부 루프
.\verify.ps1 -Coverage # src/ 커버리지 포함
```
설정은 `pytest.ini`(testpaths·pythonpath·timeout)와 `ruff.toml`(py313·line-length 120)에 있다.
## 실행
```powershell
python run_copyrighter_server.py # 기본 127.0.0.1:9500
```
런처가 `PYTHONPATH=src`와 작업 디렉터리를 고정한다. 옵션: `--host --port --db --images --static --env`.
---
## 소스 지도 (`src/rights_filter/`)
### `analysis/` — 이미지 분석과 위험도 산출
| 파일 | 역할 |
|---|---|
| `preprocessing.py` | 디코드, 외부 전송용 파생물 생성(메타데이터 제거), 얼굴 크롭 파생물 |
| `fingerprints.py` | 이미지 지문. `_perceptual_hash`는 **DCT pHash가 아니라 8x8 평균해시(aHash)** — 이름과 실제가 다름(알려진 부채) |
| `face_person_detection.py` | YuNet(ONNX) 얼굴 검출 + 인물 등급(`identifiable`/`background`). 가중치는 `models/`(gitignore) |
| `internal_analyzer.py` | 내부 분석 오케스트레이션 |
| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다. v5~v7 축 예산표(등록 일치 80 · **주의 후보 일치 40**(v7, 미확정 보류·반려 이력) · 웹 존재 40 한 풀 · 인물 30/10 · 검색 승격 15 · 라벨 10 · 미수행(일일 한도)·실패는 하한 30, disabled·거버넌스 스위치 미수행은 0)는 파일 상단 주석이 정본. `has_coverage_gap()` 은 스토어가 "검색 대기 보류"를 판별해 주의 후보 생성을 건너뛰는 데 쓴다 — 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 웹 풀에 간다. `RiskScorer(trusted_hosts, host_labels)` — host_labels(`news_host`/`scraper_host` 기준 DB 유형)는 한 줄 요약의 `(언론·공식 n · 무단 게시처 m)` 에만 쓰이고 점수는 안 바꾼다. `breakdown.headline` 은 점수와 무관한 한 줄 요약(`_Tally`·`_headline`) — 큐 '상위 근거' 칸과 케이스 요약 보드 첫 줄이 이것을 쓴다 |
| `evidence_normalizer.py`, `evidence_enrichment.py` | 증거 정규화·보강 |
| `search_query_generation.py`, `search_result_promoter.py` | 자동 검색 쿼리 생성, 검색 결과의 증거 승격 |
| `llm_assistance.py`, `llm_prompts.py` | LLM을 **요약기가 아닌 증거 판독기**로 사용. 입력 화이트리스트 9필드 + 상위 12건 상한, `format=json` 구조화 인용, 라벨 집합 포함 검사로 미인용 claim 폐기 |
| `provenance.py` | 출처 신호 — EXIF/IPTC 권리 표기 추출(화이트리스트 6필드), 방송캡처 포렌식. **둘 다 현재 0점 라벨**(캘리브레이션 전) |
| `derivatives.py`, `reason_builder.py` | 파생물 기록, 사유 문자열 조립 |
### `server/` — HTTP + 저장소
`http_app.py` (559줄) 가 라우팅·인증·보안 헤더를 담당하고, 저장소는 `CopyrighterStore`
하나를 **믹스인 8개**로 조립한다 (`sqlite_store.py:77`).
| 파일 | 역할 |
|---|---|
| `http_app.py` | `build_server()`, `CopyrighterHandler`. 라우팅 / Bearer+세션쿠키 인증 / CSP·CSRF / 바디 상한 10MB |
| `auth_rate_limit.py` | 실패한 인증 시도 스로틀(고정 윈도우 10회/60초, 클라이언트 주소별) |
| `sqlite_store.py` | `CopyrighterStore` 조립 + 시드·리스코어 |
| `store_schema.py` | DDL·제약·타입 컬럼 마이그레이션. `SCHEMA_VERSION` |
| `store_persistence.py` | `_put`/`_all` 등 저수준 upsert (PK 충돌 시 update) |
| `store_serialization.py` | 도메인 ↔ DB payload 왕복. **라운드트립은 원 점수를 재현해야 한다**(과거 위반 이력 있음) |
| `store_enrichment.py` | provider 상태 동기화, 얼굴 증거/크롭, 재분석, 자동 Naver/Google 검색 드라이버 |
| `store_operations.py` | 운영자 액션(판정·메모·재분석 요청) |
| `store_workflow.py` | 일괄 판정(`MAX_BATCH_DECISIONS`), 워크플로 |
| `store_search_candidates.py` | 검색 후보 수집·유사도 비교 |
| `store_page_scrape.py` | 후보 페이지/스타일시트 파싱 |
| `store_remote_fetch.py` | **네트워크 경계**. SSRF 가드 — 사전 URL 검사 + `_guarded_create_connection`(단일 DNS 해석으로 rebinding TOCTOU 차단) + 리다이렉트 홉 재검증 |
| `store_queue.py`, `store_duplicates.py`, `store_backup.py`, `store_text.py`, `store_url_utils.py`, `store_constants.py` | 큐, 지문 중복, 백업/복구, 텍스트·URL 유틸, 상수 |
| `image_store.py` | 제출 이미지 파일 저장소 + manifest |
| `env_file.py` | `.env` 로더 (utf-8-sig — BOM이 첫 키에 붙지 않도록) |
### `integrations/` — 외부 어댑터
`naver_search.py`, `google_custom_search.py`, `cloud_vision_web_detection.py`,
`web_detection_result_mapper.py`, `http_json.py`.
쿼터/정책은 `search_policy.py`(`SearchApiPolicy` — `try_reserve`/`release` 로 원자적 예약)와
`external_policy.py`(`ExternalApiPolicy`). **둘 다 날짜 경계 윈도우 리셋 있음**.
`env_clients.py` 가 `.env` → 어댑터/정책 조립(`build_provider_runtime`).
### `domain/` · `governance/` · `jobs/` · `admin/`
- `domain/records.py` — `Evidence`, `EvidenceSource`, `ScoreResult` 등 핵심 레코드
- `governance/` — 접근·보정·보존 정책 (얼굴 크롭 보존기간 등)
- `jobs/batch_analyzer.py` — 배치 분석, `retry_policy.py`, `usage_limits.py`
- `admin/` — 운영자 리뷰 핸들러·프레젠터
---
## 운영자 콘솔 (`web/operator-gui/`)
빌드 스텝 없음. 전부 클래식 스크립트(모듈 아님)라 **하나의 전역 스코프를 공유**한다.
`index.html` → `styles.css` + 보조 스크립트 4개(`operator-labels.js`, `submission-import.js`,
`evidence-guidance.js`, `operator-search.js`) → **`app-*.js` 15개**.
### `app-*.js` 로드 순서 (변경 금지 — 순서가 곧 계약)
```
app-state.js 상태·라벨 임포트 (나머지 전부가 읽는다)
app-api.js 토큰 프롬프트, fetch 래퍼, bootstrap/refresh
app-format.js 커버리지 계산, HTML/URL 이스케이프, 라벨 포맷터
app-selectors.js 상태 파생 조회 (선택 케이스, 위험/출처 클래스, 증거 필터)
app-render-queue.js 큐 목록, 임포트 상태, 케이스 리뷰 셸
app-render-evidence.js 증거 그룹화·정렬·마크업
app-render-panels.js 지식DB·후보·보정·provider·감사 패널, renderAll
app-navigation.js 뷰/탭 전환, 케이스 선택, 판정, 수동 검색, 재분석
app-keyboard-batch.js 키보드 심사 모드, 중복 그룹, 일괄 판정
app-decision-tools.js 메모 프리셋, 판정 내보내기, 백업, cleared reference, LLM 초안
app-stats.js 판정 통계·캘리브레이션
app-submissions.js 제출물 리로드/임포트/업로드, 지식 항목 생성
app-collections.js 후보 수집·기각·선택·승격
app-admin.js 지식/보정/provider/증거상태 관리 액션
app-bootstrap.js bindEvents, init, DOM 리스너 ← 유일하게 로드 시점에 실행됨
```
이 순서는 `tests/operator_gui/test_static_workbench.py` 의
`test_index_loads_every_app_part_exactly_once_in_declared_order` 가 고정한다.
파일을 추가·제거·재배치하면 그 테스트의 `APP_SCRIPT_NAMES` 와 `index.html` 을 함께 고친다.
---
## HTTP 라우트 (`http_app.py`)
인증: `COPYRIGHTER_AUTH_TOKEN` 설정 시 `/api/*` 와 미디어 라우트는 Bearer 헤더 또는 세션 쿠키
필요. 정적 셸과 `/health` 는 열려 있다(운영자가 토큰을 입력할 수 있어야 하므로).
**GET** — `/health` · `/api/bootstrap` · `/api/review-queue` · `/api/submissions/{id}/review` ·
`/api/providers` · `/api/providers/health` · `/api/audit-events` · `/api/decision-memo-presets` ·
`/api/decisions/export` · `/api/admin/backups` · `/api/stats/decisions` ·
`/media/` · `/knowledge-media/` · `/collected-media/` · `/face-crop-media/`
**POST** — `/api/submissions/{id}/search-auto|rerun-enrichment|decision|clear-reference` ·
`/api/submissions/reload|import-folder|upload-image|decision-batch` ·
`/api/evidence/{id}/status` · `/api/evidence/status-batch` · `/api/search/manual` · `/api/knowledge/manual` ·
`/api/knowledge/{id}/promote-watchlist|exclude-watchlist|deactivate|reactivate` ·
`/api/collections/keyword` · `/api/collections/candidates/promote-batch` ·
`/api/collections/candidates/{id}/promote|dismiss` ·
`/api/decision-memo-presets` (+ `/{id}/delete`) · `/api/admin/backup` ·
`/api/providers/emergency-disable`
**PATCH** — `/api/knowledge/{id}`
---
## SQLite 스키마 (`store_schema.py`, `SCHEMA_VERSION = 2`)
제약 있는 테이블 7개 — `submissions`, `evidence`, `providers`, `knowledge_entries`,
`collection_candidates`, `corrections`, `audit_events`. 추가로 `submission_queues`.
각 테이블은 조회·정렬용 타입 컬럼 + 전체 도메인 객체를 담은 `payload` JSON 컬럼 구조.
`evidence.submission_id` 는 `submissions(id)` 에 `on delete cascade`.
WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore).
---
## 테스트 (`tests/`, 562건)
- `tests/rights_filter/` — 단위·통합. `conftest.py` 가 `src` 를 sys.path 에 넣는다
- `tests/operator_gui/` — 정적 검증 + Playwright E2E.
- Page Object: `pages/workbench_page.py` (`OperatorWorkbench`). 업로드 대기는
`UPLOAD_ANALYSIS_TIMEOUT_MS`(30초) — 업로드는 서버에서 실분석을 돌리므로 일반 대기보다 길다
- Chromium 없으면 **자동 skip**(`importorskip`)
- 정적 테스트는 `app-*.js` 전체를 이어붙여(`_read_app()`) 내용을 검사한다 —
어느 파일에 있는지가 아니라 콘솔에 그 동작이 있는지를 본다
---
## 다이어그램
소스 JSON 과 산출물 HTML 이 같은 폴더에 쌍으로 있다. **HTML 직접 수정 금지** —
고칠 것은 언제나 spec JSON 이고, 재생성은 스크립트 하나다:
```powershell
.\scripts
ebuild-diagrams.ps1 # validate -> deliver -> visual-check -> manifest
.\scripts
ebuild-diagrams.ps1 -SkipVisualCheck # 브라우저 없는 호스트
```
게이트 두 개가 이 계약을 강제한다(둘 다 브라우저·Node·archify 없이 도는 순수 비교):
- `tests/test_diagram_sync.py` — **산출물이 spec 보다 낡았나.** `manifest.json` 의
sha256 대조. HTML 을 직접 고치거나 spec 만 고치고 재생성을 잊으면 막힌다.
- `tests/test_diagram_facts.py` — **spec 이 코드보다 낡았나.** 그림에 하드카피된
코드 유래 값(SCORER_VERSION, SCHEMA_VERSION, 믹스인 개수, `app-*.js` 개수,
바디 상한, 프롬프트 12행 상한, 풀 상한, 기본 모델, 런타임 의존성 3종)을
실제 코드 상수와 대조한다. 지식DB 엣지의 "0.9+ → 80점" 은 상수가 아니라
인라인 리터럴이라 실제로 채점해서 확인한다.
네 시나리오(HTML 직접 수정 · spec 만 수정 · SCORER_VERSION 상승 · 프롬프트 상한 변경)
모두 실제로 만들어 차단을 확인했다.
**문서 재생성 전용 요구사항** (런타임 아님, 폐쇄망 타깃에는 불필요):
Node 18+ 와 archify 스킬(`$env:ARCHIFY_HOME`, 기본값 `~/.claude/skills/archify`),
visual-check 단계에만 Chrome/Chromium. 산출 당시 generator 는 `archify 2.17.0-dev.1`
이고 `manifest.json` 에 기록된다 — 스킬 버전이 바뀌면 같은 spec 에서도 바이트가
달라지므로, 해시가 깨졌을 때 코드 변경과 툴 변경을 구분하는 근거가 된다.
**용도: 내부 문서다 (2026-09-21 결정).** 고객 인수물이 아니므로 납품 품질 기준을
적용하지 않는다 — 아래 부채 표의 한글 폰트·영어 뷰어 UI 항목이 low 인 이유가 이것이다.
인수물로 승격하면 그 둘이 먼저 blocker 가 되므로, 승격 시 이 줄부터 고친다.
모두 자체 완결 HTML 이라 외부 요청이 없다(9/21 감사에서 실측: src/href/@import/fetch
모두 0건). 단 **file:// 로 여는 산출물**이다 — 운영자 콘솔의 static_root 로 서빙하면
app-shell CSP(`script-src 'self'`, `http_app.py:514`)가 인라인 스크립트 4개를 차단해
상호작용이 죽는다. 서빙이 필요하면 CSP 완화가 아니라 별도 라우트에서 nonce 정책을 쓴다.
| 파일 | 내용 | 타입 |
|---|---|---|
| `evidence-dataflow.html` | 증거 데이터 흐름 5단(수집·경계 검사·정규화/채점·영속화·소비). 라운드트립 불변식이 중심 | dataflow |
| `system-architecture.html` | 시스템 구성. 폐쇄망 경계와 그 밖의 외부 검색 API, SSRF 가드 앞의 호출 게이트 | architecture |
| `operator-review-workflow.html` | 운영자 심사 4레인(제출·자동·심사·차단). 비상 차단 시 심사가 이어지는 경로 포함 | workflow v2 |
| `submission-lifecycle.html` | 제출물 상태 전이. 분석 실패·보류가 종료가 아니라 분석으로 복귀하는 두 고리 | lifecycle |
| `review-guide.html` | 심사 가이드(운영자 관점). 한 줄 요약 → 점수 구성 → 동일→부분→페이지 → 근거 표시 → 판정 → 다음. 미수행 하한 분기와 기준 DB 에 남는 것(주의 후보 자동 · 클리어 기준 등록 수동). 본문은 `docs/operations/review-guide.md` | workflow |
## 알려진 부채
| 항목 | 위치 | 비고 |
|---|---|---|
| `_perceptual_hash` 가 pHash가 아닌 aHash | `analysis/fingerprints.py:47` | 균일/저디테일 이미지 유사도 오탐. 교체 시 0.9 임계 재보정 필요 |
| src 4파일이 800줄 초과 | `store_page_scrape.py`(972) `store_operations.py`(968) `sqlite_store.py`(904) `store_enrichment.py`(853) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 |
| 웹 신호만으로 high 도달 | `analysis/risk_scoring.py` | v2 설계 원칙은 "웹 존재만으로 high 불가"인데 presence 풀(60) + entity 풀(20) = 80 이라 합이 캡되지 않는다. v3 의 제공자별 풀링이 이를 키웠다 — 웹 전용 조합의 high 가 4/12 → 9/12(9/22 실측, `scripts/score_distribution.py`). 대응 후보는 웹 유래 총합 상한이고 적용 시 `SCORER_VERSION` 을 올려야 한다 |
| 밴드 임계·풀 상한 미캘리브레이션 | `analysis/risk_scoring.py` | 판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v3 의 두 풀(`WEB_PRESENCE_MAX_POINTS` 60 은 **제공자별** / `WEB_ENTITY_MAX_POINTS` 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 `decision_stats` 교차표가 첫 근거가 된다 — 절차는 `docs/operations/calibration-corpus.md`, 확정 판단은 `scripts/sweep_thresholds.py` |
| 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 |
| 다이어그램에 한글 폰트 미번들 (내부 문서라 low) | `docs/diagrams/*.html` | 임베드된 JetBrains Mono 의 unicode-range 6개에 **한글 구간이 없다**(`U+AC00` 매치 0). 폴백 스택은 중국어 폰트뿐(`Noto Sans Mono CJK SC`/`PingFang SC`/`Microsoft YaHei`). 렌더 텍스트의 40~72% 가 한글이라 타깃 폰트에 의존한다 — 한국어 폰트 없는 최소 설치 호스트에서는 두부(tofu). archify 에 저자용 폰트 옵션이 없어 spec 으로는 못 고친다(레이아웃 폭은 전각으로 측정되므로 깨짐이 아니라 글자 문제). **내부 문서로 확정(9/21)** 되어 현재는 low — 사내 머신에는 한글 폰트가 있다. 인수물로 승격하면 blocker |
| 뷰어 UI 가 영어 고정 (내부 문서라 low) | `docs/diagrams/*.html` | `locale` enum 이 `en`/`zh-CN` 뿐이고 템플릿이 `<html lang="en">` 하드코딩. 저자 내용은 한국어인데 조작 UI·aria 라벨·범례 제목은 영어. 스크린리더가 한글 본문을 영어로 읽는다 |
| architecture spec 만 좌표 하드코딩 | `docs/diagrams/system-architecture.architecture.json` | 컴포넌트 11개 전부 `pos`+`size` 수기, `size` 는 [124,62] 고정. 라벨 **21자부터 validate 하드 실패**(19·20자 통과, 21·22자 실패 — 9/21 실측. 현재 `store_remote_fetch` 18자 → 여유 2글자). workflow spec 은 기하 하드코딩 0개이므로 같은 저장소에 더 나은 패턴이 있다 |
| lifecycle 기하 손튜닝 8개소 | `docs/diagrams/submission-lifecycle.lifecycle.json` | `labelDy`×5·`channelX`×2·`yOffset`×1 이 현재 배치 전제의 절대 픽셀이다. 상태를 하나 추가하면 **손대지 않은 기존 전이까지** 검증 실패 — 9/21 실측: `waiting` 레인 col 1 에 상태 1개 + 전이 1개만 넣었는데 오류 5건이 났고 그중 3건이 손대지 않은 기존 `t-reject` 쪽이었다. 상태 추가 시 전량 재검증 필요 |
| 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 |
## 운영자 신원 (2026-09-26)
`server/operator_identity.py` — 콘솔이 `X-Operator` 헤더(percent-encoded)로 보낸 이름을 `http_app` 이 요청마다 `operator_scope` 로 걸고, 스토어의 감사 actor 는 전부 `current_actor()` 를 읽는다(요청 밖 기본값 `rights.ops`). 콘솔 이름은 `localStorage` `copyrighter.operatorName`, 판정 경로는 `requireOperatorName` 으로 막힌다. 인증이 아니라 기록이다.

View file

@ -0,0 +1,418 @@
# 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 스냅샷이 수백 건 쌓인 뒤.
### 실기동 실측 (2026-07-25, GPU 타깃) — 추정치를 대체함
이 문서의 지연 수치는 전부 CPU 개발 머신 실측의 외삽이었다. 실제 GPU 타깃에서
저장된 실제 증거(제출물 #3, 증거 54행 중 12행 선별, 프롬프트 1,507자)로 재측정한 결과:
| 모델 | 지연 | 계약 준수 | 비고 |
|---|---:|---|---|
| `qwen2.5:0.5b-instruct` | 55.7s | ❌ | **프롬프트의 형식 예시(`<한국어 문장>`)를 그대로 출력**하면서 실제 라벨 12개를 전부 인용 |
| `qwen2.5:7b` | **8.8s** | ✅ | 근거 claim 2건 + 확인필요 1건. **채택** |
| `qwen3:8b` | 35.7s | ✅ | claim 4건이지만 4배 느림(thinking 모델) |
**이 실측이 바꾼 것 3가지**:
1. **기본 모델을 3B가 아니라 `qwen2.5:7b`로 정했다.** 3B는 이 빌드 머신에 없고 7B는
이미 있어서 에어갭 반입 비용이 0이며, GPU에서 8.8초면 인터랙티브다. 문서 상단의
"7B는 현재 근거로 인터랙티브 불가"는 CPU 전제였고 이 배포에는 적용되지 않는다.
2. **인용 검증만으로는 부족하다는 것이 드러났다.** 0.5B의 출력은 실제 라벨을 인용했으므로
집합 포함 검사를 **완벽히 통과**했다 — 완전히 비어 있는데도 "근거 있음"으로 판정됐다.
형식 예시 문자열을 상수로 두고 그 반향(echo)을 거부하는 가드를 추가했다.
*교훈: 그라운딩 검증은 "인용이 진짜인가"와 "내용이 진짜인가"를 둘 다 봐야 한다.*
3. **가짜 transport 테스트만으로는 이 결함을 절대 못 잡았다.** 스크립트된 응답은 언제나
잘 만들어진 JSON이기 때문이다. 실모델 프로브가 필요했다.
### 남은 결정 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건이 영구히 비어 있는 화면으로 남는다.

42
docs/diagrams/README.md Normal file
View file

@ -0,0 +1,42 @@
# 구조 다이어그램
**내부 문서다** (2026-09-21 결정). 고객 인수물이 아니므로 납품 품질 기준을 적용하지
않는다 — 한글 폰트 미번들과 영어 뷰어 UI 가 부채 표에서 low 인 이유가 이것이다.
인수물로 승격한다면 그 둘이 먼저 blocker 가 된다.
| 파일 | 내용 | 타입 |
|---|---|---|
| `system-architecture.html` | 시스템 구성. 폐쇄망 경계와 그 밖의 외부 검색 API | architecture |
| `evidence-dataflow.html` | 증거 데이터 흐름. 라운드트립 불변식이 중심 | dataflow |
| `operator-review-workflow.html` | 운영자 심사 4레인. 비상 차단 시 경로 포함 | workflow v2 |
| `submission-lifecycle.html` | 제출물 상태 전이. 분석으로 되돌아오는 두 고리 | lifecycle |
## `.html` 을 직접 고치지 않는다
소스는 같은 폴더의 spec JSON 이다. HTML 은 `archify deliver` 산출물이라 다음 재생성에서
조용히 덮인다. 고칠 것은 언제나 JSON 이고, 재생성은 스크립트 하나다 (저장소 루트에서 실행):
```powershell
.\scripts\rebuild-diagrams.ps1 # validate -> deliver -> visual-check -> manifest
.\scripts\rebuild-diagrams.ps1 -SkipVisualCheck # 브라우저 없는 호스트
```
이 계약은 말이 아니라 테스트가 강제한다 — HTML 을 손으로 고치면
`tests/test_diagram_sync.py` 의 sha256 대조에서 `verify.ps1` 이 실패한다.
## 열람
`file://` 로 연다. 운영자 콘솔의 static_root 로 서빙하면 app-shell CSP
(`script-src 'self'`, `http_app.py:514`)가 인라인 스크립트를 차단해 그림은 뜨되
테마 전환·Export·가이드 뷰가 죽는다. 서빙이 필요하면 CSP 완화가 아니라 별도
라우트에서 nonce 정책을 쓴다.
## 재생성 요구사항 (문서 전용, 런타임 아님)
Node 18+ 와 archify 스킬(`$env:ARCHIFY_HOME`, 기본값 `~/.claude/skills/archify`),
visual-check 단계에만 Chrome/Chromium. 폐쇄망 타깃에는 필요 없다.
산출 당시 generator 는 `manifest.json` 에 기록된다. 스킬 버전이 바뀌면 같은 spec 에서도
바이트가 달라지므로, 해시가 깨졌을 때 코드 변경과 툴 변경을 구분하는 근거가 된다.
자세한 부채 목록은 [PROJECT_MAP 의 다이어그램 절과 부채 표](../PROJECT_MAP.md)에 있다.

View file

@ -0,0 +1,322 @@
{
"schema_version": 1,
"diagram_type": "dataflow",
"meta": {
"title": "증거 데이터 흐름 — 수집에서 판정까지",
"output": "docs/diagrams/evidence-dataflow.html",
"quality_profile": "showcase",
"views": [
{
"id": "collect-boundary",
"label": "수집 경계",
"focus": [
"vision",
"cse",
"naver",
"policy",
"normalizer"
],
"note": "외부 응답이 Evidence 가 되기까지 통과하는 데이터 등급 경계."
},
{
"id": "scoring",
"label": "채점",
"focus": [
"normalizer",
"scorer",
"knowledge"
],
"note": "제공자별 풀·버킷과, 풀링 대상이 아닌 지식DB 승격 경로."
},
{
"id": "roundtrip",
"label": "라운드트립",
"focus": [
"payload",
"sqlite",
"restore",
"scorer"
],
"note": "저장과 복원이 원 점수를 재현해야 하는 고리."
}
],
"viewBox": [
1080,
500
],
"legend": {
"mode": "auto",
"entries": {
"emphasis": {
"label": "주 경로"
},
"security": {
"label": "등급 통제"
},
"database": {
"label": "저장소"
},
"default": {
"label": "데이터 흐름"
}
}
}
},
"stages": [
{
"label": "수집"
},
{
"label": "경계 검사"
},
{
"label": "정규화 · 채점"
},
{
"label": "영속화"
},
{
"label": "소비"
}
],
"nodes": [
{
"id": "vision",
"type": "external",
"label": "Cloud Vision",
"sublabel": "웹 탐지 · 엔티티",
"tag": "match",
"brand": "google-cloud",
"stage": 0,
"row": 0
},
{
"id": "cse",
"type": "external",
"label": "Google 맞춤 검색",
"sublabel": "이미지 · 웹문서",
"tag": "match=page",
"stage": 0,
"row": 1
},
{
"id": "naver",
"type": "external",
"label": "네이버 검색",
"sublabel": "블로그 · 웹문서",
"tag": "promoted",
"stage": 0,
"row": 2
},
{
"id": "knowledge",
"type": "database",
"label": "지식DB · 신뢰 출처",
"sublabel": "지문 · 자사 도메인",
"tag": "내부",
"stage": 2,
"row": 2
},
{
"id": "policy",
"type": "security",
"label": "데이터 등급 검사",
"sublabel": "payload 허용 목록",
"tag": "SEARCH_EVIDENCE",
"stage": 1,
"row": 1
},
{
"id": "normalizer",
"type": "backend",
"label": "증거 정규화",
"sublabel": "Evidence 레코드",
"tag": "source",
"stage": 2,
"row": 0
},
{
"id": "scorer",
"type": "backend",
"label": "RiskScorer",
"sublabel": "한 풀 · 제공자별 버킷",
"tag": "v7",
"stage": 2,
"row": 1
},
{
"id": "payload",
"type": "backend",
"label": "_evidence_payload",
"sublabel": "도메인 → payload",
"tag": "host 해석",
"stage": 3,
"row": 0
},
{
"id": "sqlite",
"type": "database",
"label": "evidence 테이블",
"sublabel": "payload JSON · WAL",
"tag": "cascade",
"stage": 3,
"row": 1
},
{
"id": "restore",
"type": "backend",
"label": "증거 복원",
"sublabel": "payload → 도메인",
"tag": "복원",
"stage": 3,
"row": 2
},
{
"id": "console",
"type": "frontend",
"label": "운영자 콘솔",
"sublabel": "증거 카드 · host 칩",
"tag": "판정",
"stage": 4,
"row": 0
},
{
"id": "llm",
"type": "backend",
"label": "LLM 증거 판독",
"sublabel": "상위 12행 화이트리스트",
"tag": "ko-v4",
"stage": 4,
"row": 1
}
],
"flows": [
{
"id": "vision-payload",
"from": "vision",
"to": "policy",
"label": "웹 탐지 결과",
"classification": "외부 응답",
"route": "top-channel"
},
{
"id": "cse-payload",
"from": "cse",
"to": "policy",
"label": "검색 결과",
"classification": "외부 응답",
"route": "straight"
},
{
"id": "naver-payload",
"from": "naver",
"to": "policy",
"label": "블로그 · 웹문서",
"classification": "외부 응답",
"route": "bottom-channel"
},
{
"id": "allowed-fields",
"from": "policy",
"to": "normalizer",
"label": "허용 필드만 통과",
"classification": "등급 통제",
"variant": "security"
},
{
"id": "evidence-record",
"from": "normalizer",
"to": "scorer",
"label": "채점 입력 신호",
"classification": "주 경로",
"variant": "emphasis",
"labelDy": 36
},
{
"id": "fingerprint-match",
"from": "knowledge",
"to": "scorer",
"label": "지문 유사도 0.9+ → 80점",
"classification": "풀링 제외",
"labelDy": -20
},
{
"id": "score-band",
"from": "scorer",
"to": "payload",
"label": "점수 · 밴드 · 사유",
"classification": "주 경로",
"variant": "emphasis"
},
{
"id": "serialize",
"from": "payload",
"to": "sqlite",
"label": "payload JSON · host",
"classification": "주 경로",
"variant": "emphasis",
"labelDy": 36
},
{
"id": "reload-restore",
"from": "sqlite",
"to": "restore",
"label": "리로드 · 판정 시 복원",
"classification": "읽기 경로",
"labelDy": 36
},
{
"id": "roundtrip-invariant",
"from": "restore",
"to": "scorer",
"label": "원 점수 재현",
"classification": "불변식",
"variant": "emphasis"
},
{
"id": "bootstrap-read",
"from": "sqlite",
"to": "console",
"label": "bootstrap 조회",
"classification": "읽기 경로"
},
{
"id": "evidence-reading",
"from": "restore",
"to": "llm",
"label": "증거 판독 요청",
"classification": "읽기 경로"
}
],
"cards": [
{
"dot": "emerald",
"title": "주 경로",
"items": [
"외부 응답은 데이터 등급 검사를 통과한 필드만 Evidence 가 된다",
"RiskScorer v7 는 웹 존재를 제공자 무관 한 풀로 센다 (presence 40 / entity 10)",
"점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다",
"신뢰 출처(자사·계약처 도메인)에서 발견된 행은 0점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다"
]
},
{
"dot": "rose",
"title": "라운드트립 불변식",
"items": [
"_evidence_payload 로 직렬화하고 _domain_evidence_from_ui 로 복원한다",
"복원된 증거를 다시 채점하면 원 점수가 재현되어야 한다",
"result_url → url 별칭이 0점을 45점으로 바꾼 사고가 있었다",
"host 는 저장 시 해석하고 복원 시 우선 반환한다 — API 이름으로 되돌아가지 않는다"
]
},
{
"dot": "orange",
"title": "소비 경로",
"items": [
"콘솔은 영속화된 payload 를 읽는다 — 호스트 칩은 20ch 로 잘리고 전체는 툴팁에 있다",
"LLM 은 복원된 증거 상위 12행만 본다 — 인용 없는 claim 은 폐기된다",
"지식DB 지문 매칭은 풀링 대상이 아니며, 라벨만으로는 닿지 않는 high 승격 경로다"
]
}
]
}

BIN
docs/diagrams/evidence-dataflow.html generated Normal file

Binary file not shown.

View file

@ -0,0 +1,46 @@
{
"note": "archify deliver 는 바이트 결정적이다. 이 수신증은 tests/test_diagram_sync.py 가 강제한다 - HTML 을 직접 고치거나 spec 만 고치고 재생성을 잊으면 커밋 게이트가 막는다. 갱신은 손으로 하지 말고 scripts/rebuild-diagrams.ps1 로 한다.",
"generator": "archify 2.17.0-dev.1",
"diagrams": [
{
"type": "architecture",
"spec": "system-architecture.architecture.json",
"html": "system-architecture.html",
"specSha256": "c72af7a58e0383cccc9ad6174f5035c720164d5db3418623af0a14d21fd47a7d",
"htmlSha256": "03e4e43d660e934f168444692c18821ae9aecdc31587015b75d61e32c0c8968e",
"htmlBytes": 816524
},
{
"type": "dataflow",
"spec": "evidence-dataflow.dataflow.json",
"html": "evidence-dataflow.html",
"specSha256": "9b6ffffdd65c5dfecec1e5b682617d68df2211e12928677a9b72f6ba9589e7ea",
"htmlSha256": "0d650a9b93674672ec503b68a8b615735444c247221a49f8d938e9eda07d962f",
"htmlBytes": 823181
},
{
"type": "workflow",
"spec": "operator-review-workflow.workflow.json",
"html": "operator-review-workflow.html",
"specSha256": "3c87c75bb7ed1eea55e65a5afe4740d568592e2e55d744ad1fbb1265a0ea9194",
"htmlSha256": "b68bec55d2e02a92322bb0e46ddda1811d5e540cb79b8fe35631e4ef5852fd8c",
"htmlBytes": 818197
},
{
"type": "workflow",
"spec": "review-guide.workflow.json",
"html": "review-guide.html",
"specSha256": "588cc3dc2fbd223776bcf0a4f040b4b1b71d784036d25948c09db7efc9f952b3",
"htmlSha256": "40b96cae655186f9cf7f339259a5a4a64f1b94b347e99f4d7e37efc5b0f5f042",
"htmlBytes": 814047
},
{
"type": "lifecycle",
"spec": "submission-lifecycle.lifecycle.json",
"html": "submission-lifecycle.html",
"specSha256": "b53d44dc70393c7a0a7746d774563c61e412eceafed5d10fd1a5e0e509aa3ac9",
"htmlSha256": "ef1930ffbae38a5430c22bc49b3a5a96c259346f8810fb2eb4c347f2212bb6d7",
"htmlBytes": 812648
}
]
}

Binary file not shown.

View file

@ -0,0 +1,345 @@
{
"schema_version": 2,
"diagram_type": "workflow",
"meta": {
"title": "운영자 심사 워크플로",
"output": "docs/diagrams/operator-review-workflow.html",
"quality_profile": "showcase",
"views": [
{
"id": "happy-path",
"label": "정상 경로",
"focus": [
"folder",
"seed",
"analyze",
"search",
"score",
"evidence",
"decide"
],
"note": "폴더 투입에서 판정까지 가는 주 경로."
},
{
"id": "stop-path",
"label": "비상 차단",
"focus": [
"search",
"stop",
"skipped",
"evidence"
],
"note": "외부 호출이 멈췄을 때 심사가 어떻게 이어지는지."
},
{
"id": "assist",
"label": "판독 보조",
"focus": [
"search",
"llm",
"evidence"
],
"note": "LLM 이 증거를 읽고 초안을 내는 보조 경로."
}
],
"legend": {
"mode": "auto",
"entries": {
"external": {
"label": "외부 입력"
},
"backend": {
"label": "서버 처리"
},
"frontend": {
"label": "운영자 화면"
},
"security": {
"label": "차단 · 정책"
},
"database": {
"label": "저장소"
}
}
}
},
"lanes": [
{
"id": "auto",
"label": "제출 · 자동 분석 · 검색"
},
{
"id": "review",
"label": "운영자 심사"
},
{
"id": "exception",
"label": "차단 · 보존",
"variant": "exception"
}
],
"phases": [
{
"id": "p-intake",
"label": "제출 · 시드",
"fromCol": 0,
"toCol": 1
},
{
"id": "p-auto",
"label": "자동 분석 · 검색",
"fromCol": 2,
"toCol": 3,
"variant": "emphasis"
},
{
"id": "p-judge",
"label": "채점 · 심사 · 판정",
"fromCol": 4,
"toCol": 5
}
],
"groups": [
{
"id": "g-exception",
"label": "외부 호출이 멈춘 경우",
"lane": "exception",
"fromCol": 3,
"toCol": 4,
"variant": "security"
},
{
"id": "g-assist",
"label": "판독 보조",
"lane": "review",
"fromCol": 3,
"toCol": 3,
"variant": "dashed"
}
],
"mainPath": [
"folder",
"seed",
"analyze",
"search",
"score",
"evidence",
"decide"
],
"nodes": [
{
"id": "folder",
"lane": "auto",
"col": 0,
"type": "external",
"label": "큐 폴더 · 업로드",
"sublabel": "이미지 · manifest",
"width": 120
},
{
"id": "seed",
"lane": "auto",
"col": 1,
"type": "backend",
"label": "시드 · 리로드",
"sublabel": "신규 레코드만",
"tag": "판정 보존",
"width": 120
},
{
"id": "analyze",
"lane": "auto",
"col": 2,
"type": "backend",
"label": "내부 분석",
"sublabel": "지문 · 얼굴 · 출처",
"width": 120
},
{
"id": "search",
"lane": "auto",
"col": 3,
"type": "backend",
"label": "자동 검색",
"sublabel": "쿼리 생성 · 승격",
"width": 120
},
{
"id": "score",
"lane": "auto",
"col": 4,
"type": "backend",
"label": "위험도 산출",
"sublabel": "점수 · 밴드 · 사유",
"tag": "v7",
"width": 120
},
{
"id": "llm",
"lane": "review",
"col": 3,
"type": "backend",
"label": "LLM 증거 판독",
"sublabel": "인용 검증 · 12행",
"width": 120
},
{
"id": "evidence",
"lane": "review",
"col": 4,
"type": "frontend",
"label": "증거 검토",
"sublabel": "호스트 · 썸네일 · 링크",
"width": 120
},
{
"id": "decide",
"lane": "review",
"col": 5,
"type": "backend",
"label": "판정",
"sublabel": "승인 · 보류 · 반려",
"tag": "감사 이벤트",
"width": 120
},
{
"id": "stop",
"lane": "exception",
"col": 3,
"type": "security",
"label": "비상 차단 스위치",
"sublabel": "쿼터 · provider 토글",
"width": 120
},
{
"id": "skipped",
"lane": "exception",
"col": 4,
"type": "security",
"label": "검색 건너뜀",
"sublabel": "한도 도달은 검토 하한",
"width": 120
},
{
"id": "retained",
"lane": "exception",
"col": 5,
"type": "database",
"label": "보존된 케이스",
"sublabel": "원본 파일 없음",
"tag": "assetMissing",
"width": 120
}
],
"edges": [
{
"id": "e-intake",
"from": "folder",
"to": "seed",
"variant": "default"
},
{
"id": "e-seed-analyze",
"from": "seed",
"to": "analyze",
"variant": "emphasis"
},
{
"id": "e-analyze-search",
"from": "analyze",
"to": "search",
"variant": "default"
},
{
"id": "e-search-score",
"from": "search",
"to": "score",
"variant": "emphasis"
},
{
"id": "e-score-evidence",
"from": "score",
"to": "evidence",
"variant": "emphasis"
},
{
"id": "e-evidence-decide",
"from": "evidence",
"to": "decide",
"label": "근거 확인 후",
"variant": "emphasis"
},
{
"id": "e-search-stop",
"from": "search",
"to": "stop",
"label": "외부 호출 요청",
"variant": "security"
},
{
"id": "e-stop-skipped",
"from": "stop",
"to": "skipped",
"label": "차단됨",
"variant": "security",
"role": "error"
},
{
"id": "e-skipped-evidence",
"from": "skipped",
"to": "evidence",
"label": "커버리지 부족 표시",
"variant": "default",
"role": "branch"
},
{
"id": "e-search-llm",
"from": "search",
"to": "llm",
"label": "판독 요청",
"variant": "dashed",
"role": "async"
},
{
"id": "e-llm-evidence",
"from": "llm",
"to": "evidence",
"label": "인용된 초안",
"variant": "dashed"
},
{
"id": "e-decide-retained",
"from": "decide",
"to": "retained",
"label": "파일 소실 시",
"variant": "default",
"role": "branch"
}
],
"cards": [
{
"dot": "emerald",
"title": "주 경로",
"items": [
"리로드는 신규 레코드만 분석한다 — 판정은 보존",
"유사도가 맞는 검색 결과만 증거로 승격"
]
},
{
"dot": "rose",
"title": "멈췄을 때",
"items": [
"비상 차단은 시드·재분석·얼굴 크롭까지 막는다",
"한도로 빠진 검색은 검토 하한 30 + 미수행 사유"
]
},
{
"dot": "cyan",
"title": "판독 보조",
"items": [
"LLM 은 증거 판독기 — 인용 없는 claim 은 폐기",
"프롬프트 입력은 상위 12행"
]
}
]
}

BIN
docs/diagrams/review-guide.html generated Normal file

Binary file not shown.

View file

@ -0,0 +1,292 @@
{
"schema_version": 2,
"diagram_type": "workflow",
"meta": {
"title": "심사 가이드",
"output": "docs/diagrams/review-guide.html",
"quality_profile": "showcase",
"views": [
{
"id": "one-case",
"label": "한 건 심사",
"focus": [
"headline",
"breakdown",
"evidence",
"mark",
"decide",
"next"
],
"note": "한 줄 요약에서 다음 미심사 건까지, 보통 1~2분."
},
{
"id": "unchecked",
"label": "검색이 안 돌았을 때",
"focus": [
"breakdown",
"unchecked",
"decide"
],
"note": "증거가 적은 것이 안전하다는 뜻이 아니다 — 중간 하한으로 올라오고 보류 후 재분석한다."
},
{
"id": "after",
"label": "판정 뒤에 남는 것",
"focus": [
"decide",
"watchlist",
"cleared"
],
"note": "보류·반려는 기준 DB 에 자동으로 남고, 승인은 직접 등록해야 남는다."
}
],
"legend": {
"mode": "auto",
"entries": {
"frontend": {
"label": "운영자가 보는 것"
},
"backend": {
"label": "운영자가 하는 것"
},
"database": {
"label": "기준 DB"
},
"security": {
"label": "커버리지 공백"
}
}
}
},
"lanes": [
{
"id": "read",
"label": "읽기 · 근거 확인"
},
{
"id": "judge",
"label": "판정"
},
{
"id": "kb",
"label": "기준 DB 에 남는 것",
"variant": "exception"
}
],
"phases": [
{
"id": "p-read",
"label": "무엇이 나왔나",
"fromCol": 0,
"toCol": 1
},
{
"id": "p-check",
"label": "무엇을 보나",
"fromCol": 2,
"toCol": 3,
"variant": "emphasis"
},
{
"id": "p-judge",
"label": "어떻게 판정하나",
"fromCol": 4,
"toCol": 5
}
],
"groups": [
{
"id": "g-kb",
"label": "다음에 같은 이미지가 오면",
"lane": "kb",
"fromCol": 4,
"toCol": 5,
"variant": "dashed"
}
],
"mainPath": [
"headline",
"breakdown",
"evidence",
"mark",
"decide",
"next"
],
"nodes": [
{
"id": "headline",
"lane": "read",
"col": 0,
"type": "frontend",
"label": "한 줄 요약",
"sublabel": "웹 사본 N곳 · 인물 · 검색",
"tag": "첫 1초",
"width": 148
},
{
"id": "breakdown",
"lane": "read",
"col": 1,
"type": "frontend",
"label": "점수 구성",
"sublabel": "몇 점이 어디서 왔나",
"width": 148
},
{
"id": "unchecked",
"lane": "judge",
"col": 2,
"type": "security",
"label": "미수행 · 실패",
"sublabel": "중간 30 하한",
"width": 148
},
{
"id": "evidence",
"lane": "read",
"col": 2,
"type": "frontend",
"label": "증거 열기",
"sublabel": "동일 → 부분 → 페이지 순",
"width": 148
},
{
"id": "mark",
"lane": "read",
"col": 3,
"type": "backend",
"label": "근거 표시",
"sublabel": "사용 · 무관 · 오탐",
"tag": "리스코어",
"width": 148
},
{
"id": "decide",
"lane": "judge",
"col": 4,
"type": "backend",
"label": "판정",
"sublabel": "A 승인 · H 보류 · R 반려",
"tag": "반려는 사유 필수",
"width": 148
},
{
"id": "next",
"lane": "judge",
"col": 5,
"type": "frontend",
"label": "다음 미심사",
"sublabel": "N · 승인·보류는 자동",
"width": 148
},
{
"id": "cleared",
"lane": "kb",
"col": 4,
"type": "database",
"label": "클리어 기준 등록",
"sublabel": "승인만으로는 안 남는다",
"width": 148
},
{
"id": "watchlist",
"lane": "kb",
"col": 5,
"type": "database",
"label": "주의 후보",
"sublabel": "보류 · 반려는 자동",
"width": 148
}
],
"edges": [
{
"id": "e-headline-breakdown",
"from": "headline",
"to": "breakdown",
"variant": "emphasis"
},
{
"id": "e-breakdown-evidence",
"from": "breakdown",
"to": "evidence",
"variant": "emphasis"
},
{
"id": "e-evidence-mark",
"from": "evidence",
"to": "mark",
"variant": "emphasis"
},
{
"id": "e-mark-decide",
"from": "mark",
"to": "decide",
"label": "근거 행을 사용으로",
"variant": "emphasis"
},
{
"id": "e-decide-next",
"from": "decide",
"to": "next",
"variant": "emphasis"
},
{
"id": "e-breakdown-unchecked",
"from": "breakdown",
"to": "unchecked",
"label": "검색이 안 돌았으면",
"variant": "security",
"role": "branch"
},
{
"id": "e-unchecked-decide",
"from": "unchecked",
"to": "decide",
"label": "보류 후 다음 날 재분석",
"variant": "security",
"role": "branch"
},
{
"id": "e-decide-cleared",
"from": "decide",
"to": "cleared",
"label": "승인 후 직접",
"variant": "dashed",
"role": "branch"
},
{
"id": "e-decide-watchlist",
"from": "decide",
"to": "watchlist",
"label": "보류 · 반려",
"variant": "default",
"role": "branch"
}
],
"cards": [
{
"dot": "emerald",
"title": "무엇을 보는가",
"items": [
"웹 완전일치 사본이 가장 중요한 사실 — 동일 그룹의 출처 하나가 나머지 70행보다 중요하다",
"라벨은 무엇이 찍혔는지만 말한다 — 권리 판단에 쓰지 않는다"
]
},
{
"dot": "rose",
"title": "판정 뒤에 남는 것",
"items": [
"보류 · 반려 → 주의 후보 자동 등록, 같은 이미지가 다시 오면 잡힌다",
"승인은 DB 에 남지 않는다 — 반복 제출되는 정상 이미지는 클리어 기준 등록"
]
},
{
"dot": "cyan",
"title": "키보드",
"items": [
"J / K 이동 · N 다음 미심사 · A 승인 · H 보류 · R 반려 · ? 도움말",
"입력칸에 커서가 있으면 단축키가 먹지 않는다 — Esc"
]
}
]
}

BIN
docs/diagrams/submission-lifecycle.html generated Normal file

Binary file not shown.

View file

@ -0,0 +1,280 @@
{
"schema_version": 1,
"diagram_type": "lifecycle",
"meta": {
"title": "제출물 상태 전이",
"output": "docs/diagrams/submission-lifecycle.html",
"quality_profile": "showcase",
"viewBox": [
1080,
630
],
"views": [
{
"id": "main",
"label": "주 단계",
"focus": [
"intake",
"analyzing",
"pending",
"decision",
"approved"
],
"note": "접수에서 승인까지 이어지는 다섯 단계."
},
{
"id": "recovery",
"label": "복구 경로",
"focus": [
"analyzing",
"failed",
"held"
],
"note": "끝나지 않고 분석으로 되돌아오는 두 고리."
},
{
"id": "terminal",
"label": "종료 상태",
"focus": [
"decision",
"approved",
"rejected",
"retained"
],
"note": "되돌아오지 않는 상태와, 판정을 남긴 채 파일만 사라진 상태."
}
],
"legend": {
"mode": "auto",
"entries": {
"start": {
"label": "시작"
},
"active": {
"label": "진행 중"
},
"waiting": {
"label": "대기"
},
"decision": {
"label": "판정"
},
"success": {
"label": "정상 종료"
},
"failure": {
"label": "실패 · 종료"
}
}
}
},
"lanes": [
{
"id": "main",
"label": "제출물 단계"
},
{
"id": "waiting",
"label": "대기 · 보강"
},
{
"id": "exceptions",
"label": "복구 경로"
},
{
"id": "terminal",
"label": "종료 상태"
}
],
"states": [
{
"id": "intake",
"type": "start",
"label": "접수",
"sublabel": "폴더 · 업로드",
"lane": "main",
"col": 0,
"step": "01",
"tag": "신규"
},
{
"id": "analyzing",
"type": "active",
"label": "분석 중",
"sublabel": "지문 · 얼굴 · 검색",
"lane": "main",
"col": 1,
"step": "02"
},
{
"id": "pending",
"type": "active",
"label": "심사 대기",
"sublabel": "점수 · 밴드 산출됨",
"lane": "main",
"col": 2,
"step": "03",
"tag": "unreviewed"
},
{
"id": "decision",
"type": "decision",
"label": "판정",
"sublabel": "운영자 결정",
"lane": "main",
"col": 3,
"step": "04"
},
{
"id": "approved",
"type": "success",
"label": "승인",
"sublabel": "사용 가능",
"lane": "main",
"col": 4,
"step": "05",
"tag": "approved"
},
{
"id": "held",
"type": "waiting",
"label": "보류",
"sublabel": "추가 확인 필요",
"lane": "waiting",
"col": 0,
"tag": "held"
},
{
"id": "failed",
"type": "failure",
"label": "분석 실패",
"sublabel": "provider 실패",
"lane": "exceptions",
"col": 0,
"tag": "복구 가능",
"yOffset": 78
},
{
"id": "rejected",
"type": "failure",
"label": "반려",
"sublabel": "사용 불가",
"lane": "terminal",
"col": 1,
"tag": "rejected"
},
{
"id": "retained",
"type": "failure",
"label": "보존",
"sublabel": "원본 파일 없음",
"lane": "terminal",
"col": 2,
"tag": "assetMissing"
}
],
"transitions": [
{
"id": "t-intake",
"from": "intake",
"to": "analyzing",
"variant": "emphasis"
},
{
"id": "t-analyzed",
"from": "analyzing",
"to": "pending",
"variant": "emphasis",
"route": "straight"
},
{
"id": "t-review",
"from": "pending",
"to": "decision",
"variant": "emphasis"
},
{
"id": "t-approve",
"from": "decision",
"to": "approved",
"label": "승인",
"variant": "emphasis"
},
{
"id": "t-hold",
"from": "pending",
"to": "held",
"label": "추가 확인 필요",
"labelDy": 24
},
{
"id": "t-fail",
"from": "analyzing",
"to": "failed",
"label": "provider 실패",
"variant": "security",
"labelDy": -24
},
{
"id": "t-retry",
"from": "failed",
"to": "analyzing",
"label": "재시도",
"variant": "emphasis",
"labelDy": 6,
"channelX": 240,
"toSide": "bottom"
},
{
"id": "t-reject",
"from": "decision",
"to": "rejected",
"label": "반려",
"variant": "security",
"labelDy": 24
},
{
"id": "t-retain",
"from": "rejected",
"to": "retained",
"label": "원본 파일 소실",
"variant": "dashed",
"labelDy": 60
},
{
"id": "t-rerun",
"from": "held",
"to": "analyzing",
"label": "재분석 요청",
"variant": "emphasis",
"channelX": 170,
"toSide": "left"
}
],
"cards": [
{
"dot": "emerald",
"title": "주 단계",
"items": [
"접수된 제출물은 분석을 거쳐 심사 대기 상태에서 점수와 밴드를 갖는다",
"판정은 운영자만 내린다 — 자동 분석은 밴드까지만 만든다"
]
},
{
"dot": "rose",
"title": "되돌아오는 상태",
"items": [
"분석 실패와 재분석 요청은 종료가 아니라 분석 중으로 돌아온다",
"보류는 운영자가 증거를 보강하려는 상태다 — 재분석은 기존 증거를 지우지 않는다"
]
},
{
"dot": "amber",
"title": "보존",
"items": [
"판정을 남긴 케이스는 원본 파일이 사라져도 지워지지 않는다",
"파일이 돌아오면 보존 표시는 해제되고 감사에 남는다"
]
}
]
}

View file

@ -0,0 +1,338 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "POSA Copyrighter 시스템 구성",
"output": "docs/diagrams/system-architecture.html",
"quality_profile": "showcase",
"viewBox": [
1080,
500
],
"views": [
{
"id": "request-path",
"label": "요청 경로",
"focus": [
"console",
"http",
"store",
"sqlite"
],
"note": "정적 콘솔에서 스토어와 SQLite 까지 가는 주 경로."
},
{
"id": "analysis",
"label": "분석 · 채점",
"focus": [
"store",
"images",
"analysis"
],
"note": "제출 이미지에서 증거와 점수가 만들어지는 고리."
},
{
"id": "egress",
"label": "외부 경계",
"focus": [
"store",
"gate",
"fetch",
"providers",
"ollama"
],
"note": "폐쇄망을 넘는 유일한 경로와 그 앞의 두 관문."
}
]
},
"components": [
{
"id": "console",
"type": "frontend",
"label": "운영자 콘솔",
"sublabel": "정적 SPA · 빌드 없음",
"tag": "app-*.js 15",
"pos": [
36,
170
],
"size": [
124,
62
]
},
{
"id": "throttle",
"type": "security",
"label": "인증 스로틀",
"sublabel": "10회 / 60초",
"tag": "주소별",
"pos": [
250,
60
],
"size": [
124,
62
]
},
{
"id": "http",
"type": "backend",
"label": "http_app",
"sublabel": "라우팅 · 인증 · CSP",
"tag": "바디 10MB",
"pos": [
250,
170
],
"size": [
124,
62
]
},
{
"id": "images",
"type": "database",
"label": "이미지 저장소",
"sublabel": "제출물 · manifest",
"pos": [
464,
60
],
"size": [
124,
62
]
},
{
"id": "store",
"type": "backend",
"label": "CopyrighterStore",
"sublabel": "믹스인 8개 조립",
"tag": "판정 · 감사",
"pos": [
464,
170
],
"size": [
124,
62
]
},
{
"id": "sqlite",
"type": "database",
"label": "SQLite",
"sublabel": "payload JSON · WAL",
"tag": "SCHEMA v2",
"pos": [
464,
280
],
"size": [
124,
62
]
},
{
"id": "analysis",
"type": "backend",
"label": "분석 · 채점",
"sublabel": "지문 · 얼굴 · 출처",
"tag": "RiskScorer v7",
"pos": [
678,
60
],
"size": [
124,
62
]
},
{
"id": "gate",
"type": "security",
"label": "외부 호출 게이트",
"sublabel": "쿼터 · 비상 차단",
"pos": [
678,
280
],
"size": [
124,
62
]
},
{
"id": "ollama",
"type": "external",
"label": "로컬 LLM",
"sublabel": "ollama · qwen2.5:7b",
"tag": "선택",
"pos": [
892,
170
],
"size": [
124,
62
]
},
{
"id": "fetch",
"type": "security",
"label": "store_remote_fetch",
"sublabel": "SSRF 가드",
"tag": "DNS 1회 해석",
"pos": [
892,
280
],
"size": [
124,
62
]
},
{
"id": "providers",
"type": "external",
"label": "외부 검색 API",
"sublabel": "Vision · CSE · 네이버",
"pos": [
678,
420
],
"size": [
124,
62
]
}
],
"boundaries": [
{
"kind": "region",
"label": "폐쇄망 (air-gapped) — 런타임 서드파티는 이미지 처리 3종",
"wraps": [
"console",
"throttle",
"http",
"images",
"store",
"sqlite",
"analysis",
"gate",
"ollama",
"fetch"
]
},
{
"kind": "security-group",
"label": "네트워크 경계",
"wraps": [
"gate",
"fetch"
]
}
],
"connections": [
{
"id": "console-api",
"from": "console",
"to": "http",
"label": "REST",
"variant": "emphasis"
},
{
"id": "auth-throttle",
"from": "throttle",
"to": "http",
"label": "인증 시도 제한",
"variant": "security",
"labelDy": 24
},
{
"id": "http-store",
"from": "http",
"to": "store",
"variant": "emphasis"
},
{
"id": "store-sqlite",
"from": "store",
"to": "sqlite",
"label": "payload JSON",
"variant": "emphasis",
"labelDy": 24
},
{
"id": "store-images",
"from": "store",
"to": "images"
},
{
"id": "analysis-store",
"from": "analysis",
"to": "store",
"label": "증거 · 점수 · 밴드",
"labelDy": 24
},
{
"id": "store-gate",
"from": "store",
"to": "gate",
"label": "외부 호출 요청"
},
{
"id": "gate-fetch",
"from": "gate",
"to": "fetch",
"label": "허용 시 통과",
"variant": "security"
},
{
"id": "fetch-providers",
"from": "fetch",
"to": "providers",
"label": "HTTPS",
"variant": "security"
},
{
"id": "store-ollama",
"from": "store",
"to": "ollama",
"label": "판독 요청",
"variant": "dashed"
}
],
"cards": [
{
"dot": "rose",
"title": "경계",
"items": [
"설치·런타임에 인터넷 없음을 전제한다 — CDN · 원격 fetch · 자동업데이트 금지",
"외부로 나가는 경로는 store_remote_fetch 하나뿐이고 SSRF 가드가 앞에 있다",
"게이트는 쿼터와 비상 차단을 본다 — 시드 · 재분석 · 얼굴 크롭 경로 모두 통과한다"
]
},
{
"dot": "emerald",
"title": "서버",
"items": [
"http_app 이 라우팅 · Bearer/세션 인증 · CSP · CSRF · 10MB 바디 상한을 맡는다",
"CopyrighterStore 는 믹스인 8개로 조립되고 SQLite payload JSON 에 쓴다",
"BatchAnalyzer 를 스토어가 구동하고, 증거·점수·밴드가 스토어로 돌아온다"
]
},
{
"dot": "cyan",
"title": "콘솔 · 의존성",
"items": [
"콘솔은 빌드 스텝이 없는 클래식 스크립트 15개로 전역 스코프를 공유한다 — 로드 순서가 계약",
"얼굴 검출은 YuNet(ONNX)이고 가중치는 gitignore 된 models/ 에 있다",
"런타임 서드파티는 numpy · opencv-python-headless · pillow 3종",
"LLM 은 로컬 ollama 선택 사항이며 없으면 연결이 빠르게 실패한다"
]
}
]
}

BIN
docs/diagrams/system-architecture.html generated Normal file

Binary file not shown.

View file

@ -0,0 +1,159 @@
# 캘리브레이션 코퍼스 — 실이미지로 밴드 임계를 검증하는 절차
이 프로젝트의 가장 큰 미검증 항목은 **위험도 밴드 임계(70/30)가 실제 판정과 한 번도
대조된 적이 없다**는 것이다. 판정이 0건이라 `decision_stats` 도 빈 표를 낸다.
이 문서는 그 공백을 메우는 절차다.
두 가지를 구분한다.
| 대상 | 도구 | 사람 필요? |
|---|---|---|
| 스코어러의 **구조적 거동** (어떤 신호 조합이 어느 밴드로 가나) | `scripts/score_distribution.py` | 아니오 |
| **임계가 맞는지** (밴드 vs 운영자 판단) | `scripts/sweep_thresholds.py` | **예 — 실이미지 + 실판정** |
전자는 지금 당장 돌릴 수 있고 스코어러를 바꿀 때마다 돌려야 한다. 후자는 아래 절차로
코퍼스를 만든 뒤에만 의미가 있다.
## 1. 이미지 구성이 성패를 가른다
기술이 아니라 여기서 갈린다. 현재 밴드 분포에 **low 가 0건**이고, 저위험으로 떨어지는
이미지가 섞이지 않으면 미탐 지표(`lowBandRejectionRate`)의 분모가 0 이 된다.
그런데 `_ratio` 가 `0/0 → 0.0` 을 돌려주므로 화면에는 **"미탐 0%"로 표시된다** —
측정된 0 과 측정 불가가 구분되지 않는다(`store_workflow.py:398-399`).
40장 기준 권장 구성:
| 군 | 장수 | 기대 밴드 | 넣는 이유 |
|---|---|---|---|
| 자사 제작·오리지널 (웹에 없음) | 10 | low | **미탐 지표의 유일한 분모** |
| 라이선스 구매·계약처 제공분 | 8 | low~medium | 신뢰 출처 기능도 함께 검증 |
| 명백한 무단 사용 후보 (뉴스 사진·연예인·캐릭터) | 12 | high | 과탐 지표의 분모 |
| 애매한 것 (웹에 있으나 출처 불명, SNS 유통) | 8 | 경계 | **임계가 실제로 가르는 지점** |
| 위 중 2~3장을 리사이즈·재저장한 재제출 | 4 | — | 선례 승계·중복 탐지 검증 |
## 2. 준비 — 안 하면 코퍼스가 오염된다
**네이버 일일 한도.** `COPYRIGHTER_NAVER_DAILY_LIMIT=100` 인데 자동 검색이 장당 최대
5콜(이미지 3 + 블로그 1 + 웹 1)이라 20장째에 고갈된다. 고갈분은 v6 부터 검토 하한
30/medium 으로 올라가고 사유에 `naver 미수행 — search API usage limit reached` 가 남는다
(v5 까지는 0점·무사유로 조용히 지나갔다). 보이게는 됐지만 앞쪽과 뒤쪽 이미지의 증거
밀도가 다른 것은 그대로라 코퍼스 런에서는 한도를 올린다.
```
.env: COPYRIGHTER_NAVER_DAILY_LIMIT=400
COPYRIGHTER_GOOGLE_DAILY_LIMIT=200
```
**Google Custom Search 는 현재 꺼져 있다.** `GOOGLE_CUSTOM_SEARCH_CX` 는 있는데
`GOOGLE_CUSTOM_SEARCH_API_KEY` 가 없어 어댑터가 `None` 이다(`env_clients.py:243-246`).
이 상태로 돌리면 `google_search` 증거가 0행이다. 키를 넣거나, **증거원 2종
(Vision+Naver)으로 간다고 코퍼스 메타에 명시**한다. 키를 넣으면
`COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT=100` 도 같이 넣는다 — 없으면 로컬
게이트가 무제한이라 구글 무료 한도를 막아주지 못한다.
**DB 를 분리한다.** 기존 제출물은 스탬프 도입 이전 행이라 판정해도 스냅샷에
`scorerVersion='legacy'` 로 찍혀 스윕에서 제외된다.
```powershell
python run_copyrighter_server.py --db data/corpus.sqlite3 --images C:/corpus
```
## 3. 투입
**파일명을 새로 매긴다.** 파일명 stem 이 곧 제출 id 이고, 같은 stem 이면
**조용히 1건으로 병합된다**(`image_store.py:94-101` 이 알려진 한계로 명시). 매니페스트
(`submissions.json`)는 필요 없다 — 폴더를 재귀 스캔한다. HEIC/TIFF 는 미지원이다.
```powershell
# 중복 검사 (결과가 비어야 한다)
Get-ChildItem C:/corpus -Recurse -File | Group-Object BaseName | Where-Object { $_.Count -gt 1 }
# 10~15장씩 나눠서 — seed 가 write lock 을 잡는다
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:9500/api/submissions/reload
```
reload 는 멱등이다(분석된 건은 건너뛴다). 다만 **LLM 서술이 seed 1회당 20건 캡**
(`store_constants.py` `MAX_LLM_SUMMARIES_PER_SEED`)이라 40장이면 reload 를 2~3번 더
호출해야 전부 채워진다.
투입 시 자동으로 도는 것: 내부 분석(지문·얼굴·출처) → Vision 웹탐지 장당 1콜 →
검색결과 유사도 비교 → 얼굴 크롭 생성 → 네이버 자동 검색 → 재스코어링 → LLM 서술.
**얼굴 크롭의 외부 전송은 시드에 없다** — 케이스별 재분석을 눌러야 발생한다
(`COPYRIGHTER_GOOGLE_FACE_CROP_SEARCH=true` 면 장당 Vision 최대 4콜을 더 쓴다).
## 4. 판정
- **근거로 쓴 증거를 '사용'으로 표시한다.** 안 하면 내보내기의 `evidenceUsed` 가
빈칸이라 "무엇을 보고 판정했나"가 사라진다.
- **판정 중에 새 이미지를 넣지 않는다.** 폴더 리로드마다 전체 리스코어가 돌아
이미 판정한 케이스의 밴드가 움직이고 교차표가 소급 변동한다.
- 키보드: `A` 승인 · `H` 보류 · `R`→숫자 반려 · `N` 다음 미심사.
## 5. 측정
```powershell
# 분모부터 — 콘솔 좌측 'S 통계' 에서 highBandDecided / lowBandDecided
# 각 5건 미만이면 비율을 해석하지 않는다 (콘솔은 색만 죽이고 숫자는 그대로 보여준다)
python scripts/sweep_thresholds.py data/corpus.sqlite3
```
스윕은 `decisionSnapshot.riskScore`(판정 시점 각인)를 쓴다. `decision_stats` 의 교차표는
**현재** riskBand 기준이라 리스코어에 흔들리므로 확정 판단에 쓰지 않는다.
읽는 법: `over` = high 로 분류했는데 승인한 건수(과탐, 많으면 high 임계가 낮다).
`under` = low 로 분류했는데 반려한 건수(미탐, 0 이 아니면 medium 임계가 높다).
## 참고 — 구조적 거동 실측 (판정 없이 측정한 것)
`scripts/score_distribution.py` 432조합 전수 + `data/corpus.sqlite3` 실이미지 20장.
스코어러를 바꿀 때마다 이 두 표를 다시 채운다.
**이 격자는 신호 축을 균등 샘플링하므로 실제 큐 분포가 아니다.** 92% 가 "실제 큐의
92% 가 high"라는 뜻이 아니며, 임계가 맞는지도 말해주지 않는다. 구조적 거동의
기준선일 뿐이고, 스코어러를 바꿀 때 전후 비교에 쓴다.
### 격자 432조합 — v4 → v5 (2026-09-22)
| | v4 미등록 | v4 등록 | **v5 미등록** | **v5 등록** |
|---|---|---|---|---|
| high | 92% | 63% | **75%** | **40%** |
| medium | 6% | 29% | **22%** | **40%** |
| low | 0% | 7% | **2%** | **18%** |
| 100점 포화 | 80% | 38% | **44%** | **22%** |
| 웹 신호만으로 high | 13/24 | 0/24 | **0/24** | **0/24** |
(등록 = 신뢰 출처 등록.) v5 의 포화 44% 는 전부 격자의 1/3 을 차지하는 "등록 권리 대상
강한 일치(80)" 조합이다 — 등록 일치는 단독 high 로 설계했으므로 그 위의 순서는 의도적으로
포기한 것이고, **등록 일치가 없는 조합의 포화는 0** 이다.
### 실이미지 20장 (`sample/` — 전부 웹에 있는 인물 사진, 저위험 기대 이미지 0장)
| | v4 | v5 |
|---|---|---|
| 밴드 | high 20 | high 20 |
| 100점 | 20/20 | **0/20** |
| 구별되는 점수 | 1 (100) | **4 (75 · 80 · 92 · 95)** |
v5 에서 20장의 순서를 만든 것은 인물 등급(배경 1장 → 75)과 이름 있는 검색 승격
유무(없음 3장 → 80, 약한 승격 4장 → 92)다. 웹 존재(40)와 라벨(10)은 20장 전부 상한에
있어 이 코퍼스 안에서는 순서를 만들지 못한다 — 웹 사본이 있는 인물 사진만 모은
코퍼스의 한계지 스코어러의 한계가 아니다. **medium/low 임계 검증은 여전히 불가**:
1절의 구성대로 자사 오리지널·라이선스 구매분이 들어와야 분모가 생긴다.
### v4 에서 드러났고 v5 가 처리한 것
1. **웹 신호만으로 high 에 도달하는 조합이 있었다.** 2026-08-09 v2 의 설계 원칙은
"웹 존재만으로는 high 가 되지 않는다. 두 번째 **종류**의 신호가 필요하다"였는데,
presence 풀(최대 60)과 entity 풀(최대 20)의 합이 80 이라 `full+partial` + 엔티티 1개
만으로 high 가 됐다. 두 풀이 각각 임계 아래로 캡됐지만 합은 캡되지 않았다.
2. **v3 의 제공자별 풀링이 그 경향을 키웠다.** 독립 제공자가 하나 더 있으면 웹 전용
조합의 high 가 4/12 → 9/12 로 올라갔다. 교차 확증이 high 에 도달해야 한다는 v3 의
의도대로 동작한 결과지만, 총량 통제가 없었다.
3. **실이미지가 보인 세 번째 원인.** 검색결과 사본 유사도가 행마다 +80, 무상한이었다
(코퍼스 평균 216점/장). 캡 없는 두 축만 캡하는 시뮬레이션(80/50, 60/40)으로도 20/20 이
그대로 100 — 축 하나가 아니라 예산 **합**(245 vs 100)의 문제였다.
v5 는 축 예산을 총합 기준으로 재배분했다(등록 일치 80 · 웹 존재 40 **한 풀** · 인물 30/10 ·
검색 승격 15 · 라벨 10 → 등록 제외 최대 95, 외부 검색 실패는 가산 대신 medium 하한).
정본은 `risk_scoring.py` 상단 예산표다.

View file

@ -13,11 +13,37 @@
- Do not automate Naver web search UI or scrape Naver result pages. - Do not automate Naver web search UI or scrape Naver result pages.
- Do not send internal originals to external APIs. - 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 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 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 expose automated scores, evidence, or provider details to applicants.
- Do not let the filter change review status automatically. - 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 ## Disable External Calls
Set the external API policy to disabled. Batches should continue internal-only and record external-skipped evidence reasons for affected submissions. Set the external API policy to disabled. Batches should continue internal-only and record external-skipped evidence reasons for affected submissions.

View file

@ -13,19 +13,68 @@ carried across the air gap as pre-downloaded wheels. The build/staging machine
## 1. On the build machine (has internet) ## 1. On the build machine (has internet)
```bash ```bash
# Runtime only: # Runtime only (--only-binary keeps sdists out so nothing has to build on the
pip download -r requirements.txt -d wheelhouse/ # offline target; also carry pip/setuptools/wheel themselves):
pip download --only-binary=:all: -r requirements.txt -d wheelhouse/
pip download --only-binary=:all: pip setuptools wheel -d wheelhouse/
# Or runtime + test tooling: # Or runtime + test tooling:
pip download -r requirements-dev.txt -d wheelhouse/ pip download --only-binary=:all: -r requirements-dev.txt -d wheelhouse/
``` ```
This fills `wheelhouse/` with every wheel (including transitive deps). This fills `wheelhouse/` with every wheel (including transitive deps).
### Hash-verified lock (recommended)
Pin the full transitive tree with hashes so a tampered/substituted wheel is
rejected at install time:
```bash
# On the build machine (pip-tools):
pip-compile --generate-hashes -o requirements.lock requirements.txt
```
Then install on the target with `--require-hashes` (step 3).
### Model weights (`models/`)
Some analysis paths use pre-trained weights that are **not** pip packages and are
**not** committed to the repository (`models/` is gitignored, same treatment as
`wheelhouse/`). Fetch them on the build machine:
```bash
mkdir -p models
# YuNet face detector (OpenCV Zoo, MIT). Used by face/person presence detection;
# cv2.FaceDetectorYN itself ships inside opencv-python-headless, only the weights
# are external.
curl -sSL -o models/face_detection_yunet_2023mar.onnx \
https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx
```
Verify before transferring — a truncated or substituted model must not cross the
air gap unnoticed:
| file | bytes | sha256 |
|---|---:|---|
| `face_detection_yunet_2023mar.onnx` | 232589 | `8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4` |
```bash
python - <<'PY'
import hashlib, pathlib
p = pathlib.Path("models/face_detection_yunet_2023mar.onnx")
print(len(p.read_bytes()), hashlib.sha256(p.read_bytes()).hexdigest())
PY
```
If `models/` is absent on the target the server still starts: face detection
falls back to the bundled Haar cascades and logs the downgrade once. It does not
fail closed, because a missing optional weight must not stop an operator from
reviewing.
## 2. Transfer ## 2. Transfer
Copy both the repository and the `wheelhouse/` directory to the target via the Copy the repository, the `wheelhouse/` directory, and the `models/` directory to
approved air-gap transfer process (e.g. removable media). the target via the approved air-gap transfer process (e.g. removable media).
## 3. On the target machine (no internet) ## 3. On the target machine (no internet)
@ -46,14 +95,75 @@ python -c "import numpy, cv2, PIL; print('deps ok')"
python -m pytest -q # if test deps were installed python -m pytest -q # if test deps were installed
``` ```
## Optional: Ollama LLM model (evidence narration)
The LLM assistant is optional. If you want it on the air-gapped target, the
model weights must be carried across too — they are **not** part of the Python
wheelhouse:
Carry the **`ollama` binary itself** as well as the weights — neither is in the
Python wheelhouse.
Use `qwen2.5:7b` (about 4.7 GB), the code default. Do **not** substitute
`qwen2.5:0.5b-instruct`: it was measured against this prompt at 55.7 s and it
broke the output contract, reprinting the prompt's format example as if it were
a finding. A 3B-class model is the floor for reading evidence and citing it.
```bash
# Build machine (has internet):
ollama pull qwen2.5:7b # or your pinned OLLAMA_MODEL
# Copy the model store across the air gap:
# default: ~/.ollama/models (or set OLLAMA_MODELS to a directory you transfer)
# Target machine (no internet):
export OLLAMA_MODELS=/path/to/transferred/models
ollama serve &
# Smoke test:
ollama run qwen2.5:7b "hello"
```
Set `OLLAMA_BASE_URL` / `OLLAMA_MODEL` in `.env`. Leaving them unset does **not**
turn the LLM off — the client falls back to `http://127.0.0.1:11434` and the code
default model, so a target without Ollama spends about 2 s per case before
writing a readable failure row (scores are unaffected either way). To stop the
attempts, disable the `llm` provider in the operator console.
Hardware: the 7B default was timed on a GPU host (about 2 s warm, 7 s cold). On a
CPU-only target, raise `OLLAMA_TIMEOUT` (default 120 s) and expect narration to
dominate seed time.
## Optional: Playwright Chromium (E2E tests)
The operator-GUI E2E suite needs a Chromium binary, which Playwright normally
downloads. For the air gap, fetch it on the build machine and carry it:
```bash
# Build machine:
PLAYWRIGHT_BROWSERS_PATH=./pw-browsers playwright install chromium
# Transfer the pw-browsers/ directory to the target, then:
# Target machine:
export PLAYWRIGHT_BROWSERS_PATH=/path/to/pw-browsers
python -m pytest tests/operator_gui -q
```
## Notes ## Notes
- **Which network mode?** Read `operating-modes.md` before deploying: what
scores mean with full egress, API hosts only, or no egress at all (and why a
fully closed site must remove the API keys rather than just block the network).
- The operator GUI (`web/operator-gui/`) is fully self-contained: all - The operator GUI (`web/operator-gui/`) is fully self-contained: all
scripts/styles are local and the Pretendard font is bundled scripts/styles are local and the Pretendard font is bundled
(`assets/fonts/PretendardVariable.woff2`). No CDN fetch at runtime. (`assets/fonts/PretendardVariable.woff2`). No CDN fetch at runtime.
- External **search** APIs (Google / Naver / Cloud Vision) are an allowed - External **search** APIs (Google / Naver / Cloud Vision) are an allowed
runtime exception and require outbound access to those providers ONLY. They runtime exception and require outbound access to those providers. They
are optional; missing credentials disable the provider (see the startup are optional; missing credentials disable the provider (see the startup
provider-readiness log). provider-readiness log).
- **Broader egress:** beyond the API hosts, the image-comparison feature also
fetches arbitrary public result-page / image / stylesheet URLs returned by
those searches (SSRF-guarded: private/loopback/CGNAT/link-local ranges are
blocked). Restricting egress to the API hosts only disables image comparison
of external results; it does not break core review, but it weakens the web-copy
signal silently (no failure row) - see `operating-modes.md` mode B.
- `COPYRIGHTER_AUTH_TOKEN` should be set in `.env` for any multi-operator or - `COPYRIGHTER_AUTH_TOKEN` should be set in `.env` for any multi-operator or
networked deployment so the data routes require a shared token. networked deployment so the data routes require a shared token.

View file

@ -0,0 +1,40 @@
# 운영 모드 — 네트워크를 얼마나 여느냐에 따라 무엇이 남는가
"폐쇄망 납품"은 **설치** 쪽 이야기다. 런타임 의존성(numpy · opencv · Pillow)은 오프라인
휠로 들어가고(`offline-install.md`), 콘솔·폰트도 전부 동봉이다. 그러나 **탐지의 핵심 신호는
외부 검색 API 에서 온다.** 그래서 배포 전에 아래 셋 중 어느 모드인지 정하고, 운영자에게
그 모드에서 점수가 무엇을 뜻하는지 알려야 한다.
| | A. 전체 egress | B. API 호스트만 | C. 완전 폐쇄 |
|---|---|---|---|
| 허용 대상 | API 호스트 + 검색 결과 페이지·이미지 | `vision.googleapis.com` · `openapi.naver.com` (+ CSE 사용 시 `www.googleapis.com`) | 없음 |
| Vision 웹 탐지 (동일·부분·페이지·유사) | O | O | X |
| 네이버 이름 검색 | O | O | X |
| **검색결과 사본 비교** (결과 페이지를 받아 지문 대조) | O | **X — 조용히 빠진다** | X |
| 지문 · 얼굴 · 출처 메타데이터 · 기준 DB 일치 | O | O | O |
| 실이미지 20장 기준 한 줄 요약 | `웹 완전일치 사본 N곳 · …` | Vision full 매치가 있으면 같은 `웹 완전일치`, 없으면 `부분·페이지` 로 내려감 | `식별 가능 인물` 또는 `외부 검색 꺼짐` |
| 점수가 뜻하는 것 | 설계대로 | 웹 존재 축이 약해져 일부가 한 단계 낮게 나온다 | 인물·기준 DB 이력만 — **신규 이미지는 대부분 low** |
## 모드별 주의
**B. API 호스트만.** 결과 페이지 받기가 막히면 오류 없이 빈 결과로 처리된다
(`store_search_candidates.py` 의 페이지 fetch 는 예외를 삼킨다). 그래서 실패 하한(30)이 걸리지
않고 사유에도 남지 않는다. Vision 이 같은 이미지를 `full` 로 잡으면 점수는 같다. 사본 비교에만
기대던 케이스는 조용히 약해진다 — 이 모드를 택했다면 운영자에게 알린다.
**C. 완전 폐쇄 — API 키를 빼거나 비상 차단을 켜고 운영한다.** 키를 둔 채 망만 막으면 매
호출이 **실패**(`External API failed`)가 되고, v5 부터 실패는 검토 하한 30 이라 **큐 전체가
medium 으로 깔린다.** 키가 없거나 비상 차단이 켜진 상태면 `disabled` 로 처리돼 0점이고, 한 줄
요약에 `외부 검색 꺼짐` 이 붙는다(v7 이후). 이 모드에서 low 는 "확인했고 깨끗함"이 아니라
"외부 확인을 하지 않음"이다.
**공통.** 일일 한도 도달(`usage limit reached`)은 모드와 무관하게 검토 하한 30 과
`<provider> 미수행` 사유로 드러난다. 네이버 기본 한도 100 = 약 20장/일이므로 하루 처리량에
맞춰 `COPYRIGHTER_NAVER_DAILY_LIMIT` 을 정한다.
## 확인 방법
- 기동 로그 첫 줄: `Provider readiness: Naver search: ready, Google Vision web detection: ready, …`
- `/health` → `version` · `scorerVersion`
- 콘솔 상단 `외부 검색 tool N/5 활성 · 실패 N`
- 모드 C 는 한 줄 요약에 `외부 검색 꺼짐` 이 떠야 정상이다. 대신 `외부 검색 실패` 가 뜨면 키가 남아 있는 것이다.

View file

@ -0,0 +1,111 @@
# 심사 가이드 — 무엇을 보고, 어떻게 판정하는가
운영자용. 이 도구는 **판정하지 않는다.** 이미지가 어디에 있고 무엇이 찍혔는지 모아서
큐를 위험한 순서로 세워 줄 뿐이고, 승인·보류·반려는 사람이 한다. 이 문서는 그 판정을
빠르고 일관되게 하는 순서다.
## 0. 시작 전에 — 이름
왼쪽 아래 **운영자** 칸에 이름을 넣는다. 판정·증거 표시·기준 DB 조작이 감사 로그에
`operator:<이름>` 으로 남는다. 이름이 없으면 판정 버튼이 막힌다. 브라우저가 기억하므로 한
번만 넣으면 된다. 이 이름은 기록용이며, 접근 권한은 공유 토큰이 정한다.
## 1. 한 건 심사의 순서 (보통 1~2분)
| 단계 | 어디서 | 무엇을 |
|---|---|---|
| ① 한 줄 읽기 | 큐 '상위 근거' 칸 / 케이스 요약 첫 줄 | `웹 완전일치 사본 4곳 · 식별 가능 인물 · …` — 무엇이 얼마나 나왔는지. 여기서 대부분 방향이 정해진다 |
| ② 점수 구성 확인 | 케이스 화면 '점수 구성' | 몇 점이 어디서 왔는지. "증거 77개인데 왜 95점"의 답. 상한에 걸린 항목은 `원래 230 · 상한 40` 으로 표시 |
| ③ 증거 열기 | '동일 이미지' 그룹부터 | **동일 → 부분 → 페이지** 순. 유사·라벨·네이버는 앞이 비었을 때만 |
| ④ 근거 표시 | 각 증거의 `사용` / `무관` | 판정 근거로 삼은 행은 `사용`(다시 누르면 해제, 점수는 그대로). 관계없거나 잘못 잡힌 행은 `무관` — **무관만 점수에서 빠지고** 리스코어된다. 표시하지 않은 행은 점수에 그대로 남는다 |
| ⑤ 판정 | `A` 승인 · `H` 보류 · `R` 반려 | 반려는 사유 필수(아래 4절) |
| ⑥ 다음 | `N` | 다음 미심사 건으로. 승인·보류 후에는 자동으로 넘어간다 |
**증거를 다 읽을 필요는 없다.** 동일 이미지 그룹에 한 건이라도 있으면 그 출처 하나를 열어
보는 것이 나머지 70행보다 중요하다. 라벨("K-pop", "Photograph")은 무엇이 찍혔는지를 말할 뿐
권리와 무관하다 — 라벨만으로 반려하지 않는다.
## 2. 무엇을 보는가 — 한 줄 요약의 신호와 그 뜻
| 한 줄 요약에 나오는 말 | 뜻 | 판정에서의 무게 |
|---|---|---|
| **등록 권리 대상 일치 0.95** | 기준 DB 에서 운영자가 **확정**한 이미지(주의 후보 승격, 수동 등록)와 같은 이미지 | 단독으로 높음. 이전 판정과 같은 결론이 원칙 — 다르면 이유를 메모 |
| **주의 후보 일치 0.95** | 보류·반려로 자동 생성된, 아직 확정 안 된 항목과 같은 이미지 | 단독으로 중간 — "누가 다시 보려 했다"는 뜻. 기준 DB 에서 그 항목의 메모를 열어 이전 판정을 확인하고, 반복되면 `승격` 으로 확정한다 |
| **웹 완전일치 사본 N곳** (언론·공식 a · 무단 게시처 b) | 검색 결과·구글 이미지 감지에서 **같은 이미지**가 N개 호스트에서 발견. 괄호는 기준 DB 에 성격을 등록한 호스트의 수 | 가장 중요한 사실. 언론·공식 쪽이면 원저작자일 가능성이 높다. 자주 보는 호스트는 기준 DB 에 `언론·공식 출처`/`무단 게시처` 로 등록해 두면 다음부터 링크를 열지 않아도 된다(점수는 바뀌지 않음) |
| 웹 부분·페이지 일치 / 웹 유사 이미지 | 잘렸거나 편집된 사본 / 비슷한 구도 | 참고. 부분 일치는 열어 보고, 유사는 앞의 둘이 없을 때만 |
| **식별 가능 인물** / 배경 인물 | 얼굴이 알아볼 수 있게 찍힘 / 군중 속 인물 | 초상권 축. 유명인·특정 개인이면 사본이 없어도 보류 검토 |
| 이름 있는 검색 승격 N건 | 이름(인물·작품)으로 검색해 결과가 나옴 | 약한 참고. 단독으로는 밴드를 만들지 못한다 |
| 라벨 N개 | 구글이 붙인 설명 라벨 | 무엇이 찍혔는지만. 권리 판단에 쓰지 않는다 |
| 클리어 이력 일치 | 이전에 운영자가 **클리어 기준으로 등록**한 승인 이미지와 같다 | 재심사 없이 승인 쪽. 등록은 승인만으로 되지 않고 `클리어 기준 등록` 을 눌러야 한다 |
| 외부 검색 실패 / 일일 한도로 일부 미수행 | 그 검색이 **안 돌았다** | 증거가 적은 게 안전하다는 뜻이 아니다. 점수는 중간 하한(30)까지만 오른다 — 보류 후 다음 날 `선택 재분석` |
| 신뢰 출처 제외 N행 | 자사·계약처 도메인에서 발견된 행 | 점수에서 빠진다. 스크래퍼 게시가 같이 있으면 그것만 센다 |
**점수는 검토 순서이지 침해 판정이 아니다.** 축은 웹 노출·인물 식별성·이름 검색·등록 이력이고, 라이선스·초상 동의 같은 **권리 증빙은 점수에 없다.** 그래서 라이선스가 있는 인물 사진이 80(높음), 무단 사본인 그림이 50(중간)이 될 수 있다 — 높음은 "먼저 보라"이고, 권리 판단은 운영자가 한다.
점수 밴드: **높음 70+ · 중간 30+ · 낮음.** 웹에 있다는 것만으로는 최대 65 라 높음이 되지
않는다 — 높음은 "웹 사본 + 인물" 또는 "등록 일치"다. 밴드는 우선순위이지 결론이 아니다.
## 3. 판정 기준
| 판정 | 언제 | 뒤에 일어나는 일 |
|---|---|---|
| **승인** `A` | 자사 제작·라이선스 확인·자유 이용 확인. 웹 사본이 있어도 출처가 우리이거나 계약처면 승인 | 지원자 상태 '승인 대기'. 기준 DB 에는 **아무것도 안 들어간다** — 재사용될 이미지면 `클리어 기준 등록` 을 따로 누른다. 보류·반려를 **승인으로 바꾸면** 그 판정이 만든 주의 후보는 자동 철회되고(이미 `승격` 한 항목은 남고 감사에 기록), 그 항목에 걸려 있던 다른 케이스는 리스코어된다. 내보내기 메모는 항상 **마지막 판정**의 메모다 |
| **보류** `H` | 출처 불명·권리자 확인 필요·검색이 안 돈 경우 | 기준 DB 에 **주의 후보(watchlist)** 로 들어간다. 같은 이미지가 다시 오면 `주의 후보 이미지 유사도` 로 잡힌다. 단, **검색이 안 돌아서(미수행·실패) 보류한 경우는 주의 후보를 만들지 않는다** — "확인 못 함"은 "의심됨"이 아니다. 감사 로그에 `검색 대기` 로 남는다 |
| **반려** `R` | 타인 저작물 사본·초상권 침해·이전 반려와 동일 | 사유 필수. 기준 DB 주의 후보 갱신. 지원자 상태 '반려 예정' |
**보류를 아끼지 말 것.** 판정을 미루는 것이 아니라 "다음에 같은 이미지가 오면 잡아라"를
DB 에 남기는 행위다. 반대로 승인은 DB 에 남지 않으므로, 반복 제출되는 정상 이미지는 승인 후
`클리어 기준 등록` 까지 해야 다음에 자동으로 클리어 이력이 붙는다.
## 4. 반려 사유
`R` 을 누르면 사유 목록이 뜬다. 숫자 하나로 고르면 두 키로 끝난다.
자유 서술이 필요하면 `M` 으로 메모 칸에 가서 쓰고 `Ctrl+Enter`.
사유 프리셋은 판정 창에서 저장·수정할 수 있다(라벨 40자, 본문 500자). 팀이 쓰는 사유를
프리셋으로 고정해 두면 반려 메모의 문장이 통일되고 나중에 통계로 집계된다.
메모에는 **무엇을 보고 판단했는지**를 쓴다: `kenh14cdn 완전일치, 언론 사진` 처럼 출처 하나면
충분하다. 근거 행을 `사용` 으로 표시해 두면 내보내기에 그 행이 함께 실린다.
## 5. 키보드
| 키 | 동작 |
|---|---|
| `J` / `K` (↓ / ↑) | 큐에서 다음 / 이전 건 |
| `N` | 다음 **미심사** 건 |
| `A` / `H` | 승인 / 보류 → 자동으로 다음 미심사로 |
| `R` → `1`~`9` | 반려 사유 선택 |
| `R` → `M` | 반려 메모 직접 쓰기 → `Ctrl+Enter` 제출 |
| `?` | 단축키 도움말 / `Esc` 닫기 |
입력칸에 커서가 있으면 단축키는 먹지 않는다 — `Esc` 로 빠져나온 뒤 누른다.
## 6. 여러 건 한꺼번에
- 큐에서 체크 → `선택 승인 / 선택 보류 / 선택 반려`. 반려는 여기서도 사유 필수.
- 증거 그룹 머리의 `전부 무관` — 한 그룹(예: 유사 이미지 20행)을 한 번에 점수에서 뺀다.
- **판정 중에 새 이미지를 넣지 않는다.** 폴더 리로드마다 전체 리스코어가 돌아 이미 판정한
케이스의 밴드가 움직일 수 있다(판정 시점 점수는 스냅샷으로 보존되지만 화면 밴드는 바뀐다).
## 7. 자주 헷갈리는 것
- **점수가 낮은데 증거가 많다** → 점수 구성을 본다. 상한에 걸린 것(라벨 59 → 10)이거나
신뢰 출처로 빠진 것이다. 둘 다 정상.
- **어제 높음이었는데 오늘 중간** → 리스코어. 감사 로그에 `Risk rescored` 가 남는다.
판정 시점 점수는 판정 기록에 그대로 있다.
- **모든 케이스가 중간 30 에 깔려 있다** → 외부 검색이 안 돌고 있다(한도·장애).
'외부 검색 tool 활용' 화면에서 상태를 보고, 돌아온 뒤 `선택 재분석`.
- **같은 사진을 잘라서 다시 냈다** → 기준 DB 주의 후보에 있으면 `주의 후보 이미지 유사도`
로 잡힌다. 지문이 리사이즈·재저장에는 버티지만 큰 크롭·워터마크에는 약하다 — 눈으로 본다.
- **자사 도메인 사본이 자꾸 점수를 올린다** → 기준 DB 에 `신뢰 출처` 로 도메인을 등록한다.
하위 도메인(cdn.…)은 자동으로 같은 편이다.
## 8. 심사가 끝나면
- `판정 내보내기` — 판정·메모·`사용` 표시한 근거가 함께 나간다. 근거 표시를 안 했으면
"무엇을 보고 판정했나"가 빈칸이다.
- `S` 통계 — 밴드별 판정 교차표. 높음인데 승인이 많으면 임계가 낮은 것, 낮음인데 반려가
있으면 미탐. 분모 5건 미만이면 읽지 않는다.
- 판정이 30~50건 쌓이면 `scripts/sweep_thresholds.py` 로 임계를 재검토한다
(`docs/operations/calibration-corpus.md`).

View file

@ -104,9 +104,10 @@ The LLM output is stored as a source-linked summary evidence item. It helps oper
Important boundary: Important boundary:
- it does not identify a person; - 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 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 ### 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 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 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 expose internal risk scores or evidence details to applicants.
- Do not let automated analysis change final review status. - Do not let automated analysis change final review status.
- Do not treat LLM output as standalone evidence unless it links back to source evidence. - Do not treat LLM output as standalone evidence unless it links back to source evidence.

View file

@ -0,0 +1,112 @@
# Copyrighter 프로젝트 코드 리뷰 — 2026-07-11
> 다중 에이전트 리뷰(리뷰 유닛 9개 + 백로그 전수 대조 1개, 총 33 에이전트) · 신규 CRITICAL/HIGH 발견은 독립 검증자 2명의 적대적 검증(반박 시도)을 통과한 것만 채택.
> 기준 커밋: `89c1fe4` (Phase 1 HIGH remediation) · 백엔드 테스트: **361 passed** (pytest, 3m56s).
> 지난 리뷰에서 레이트리밋으로 실패했던 arch-concurrency 유닛 재실행 완료 — 이번 신규 HIGH 9건 중 4건이 이 유닛에서 나옴.
> 신규 발견: **HIGH 9 · MEDIUM ~24 · LOW ~12 · CRITICAL 0** (기존 백로그 재발견분 제외).
## 1. 총평 (Executive Summary)
**프로젝트 건강도: 주의 (Phase 1 수습은 성공, 그러나 더 깊은 계층에서 새 HIGH 9건 발견)**
2026-06-22 리뷰(66건: CRITICAL 0 / HIGH 6 / MEDIUM 43 / LOW 17) 이후 commit 89c1fe4로 Phase 1 HIGH 5건이 실제로 수정되었음을 코드 레벨에서 확인했다. 그러나 이번 심층 리뷰(동시성·직렬화·시드 경로 집중)에서 **신규 HIGH 9건(전건 검증 CONFIRMED), MEDIUM 약 24건, LOW 약 12건**이 나왔다. CRITICAL은 이번에도 0건.
지금 가장 큰 리스크 3가지:
1. **프로덕션 스코어링이 조용히 무력화되어 있음 (H6, H7)** — `_evidence_payload`/`_domain_evidence_from_ui`(src/rights_filter/server/store_serialization.py:260, 302)가 `promoted`와 `entity` 필드를 직렬화에서 누락한다. 모든 프로덕션 점수는 `_rescore_submission`의 라운드트립을 거치므로, **Naver 프로모션 근거(+40)와 Google 웹 엔티티 근거(+40)가 프로덕션에서 항상 0점**이다. GUI에는 80% 신뢰도로 표시되면서 위험도는 'low'에 머무는, 이 도구의 핵심 기능(권리 리스크 필터링)의 침묵 결함이다. 89c1fe4의 entity-bucket dedup 수정도 프로덕션 경로에서는 사실상 죽은 코드다.
2. **긴급 제공자 차단(emergency disable)이 내구성이 없음 (H2, H3)** — `_sync_provider_payloads`(store_enrichment.py:49)는 reload/수집 때마다 저장된 `enabled` 플래그를 자격증명-연결성 기본값으로 덮어쓰고, `_apply_provider_usage_delta`(store_persistence.py:238)는 검색 시작 시점에 캡처한 stale payload 전체를 되써서 진행 중이던 검색이 끝나면 차단을 되돌린다. 컴플라이언스 목적의 긴급 정지가 감사 기록 없이 무효화된다.
3. **`_write_lock` 규율이 절반만 적용됨 (H1, H4)** — 89c1fe4는 `mark_evidence_status`와 promote 경로에만 락을 넣었고, `record_decision`(sqlite_store.py:569)과 `manual_search`(store_operations.py:321)는 여전히 무락 RMW다. 결과: **운영자의 승인/반려 결정이 동시 rescore/검색에 의해 조용히 'unreviewed'로 되돌아갈 수 있으며**, 감사 로그·워치리스트와 실제 상태가 어긋난다.
전 회차 대비 평가: 지난 리뷰가 표면(엔드포인트·GUI·개별 함수) 중심이었다면, 이번엔 "전 제출건이 단일 JSON blob + ThreadingHTTPServer" 구조에서 파생되는 **lost-update 계열**과 **직렬화 라운드트립 손실 계열**이라는 두 개의 구조적 결함군이 드러났다. 개별 수정보다 패턴 단위 일괄 적용이 필요하다.
## 2. Phase-1 수정 검증 판정
**판정: 5건 모두 커밋 내용대로 존재하고 회귀 테스트 4건 통과. 단, "좁게 맞은 수정" — 리뷰 항목 기준 4 FIXED / 1 PARTIAL.**
| # | 항목 | 판정 | 근거 |
|---|---|---|---|
| 1 | `mark_evidence_status` 원자성 | **FIXED** | sqlite_store.py:601-623 — `_write_lock` + 단일 `_transaction`, conn 스레딩, 롤백 테스트 존재 |
| 2 | `promote_collection_candidates` 락+멱등성 | **FIXED** | store_operations.py:699-771 — 락, 충돌-안전 `_stable_id`, 락 하 already-promoted 가드 |
| 3 | `deactivateCorrectionEntry` / provider-retry 정직화 | **FIXED** (단서 有) | app.js:3247-3256 실제 API 호출, :3676-3684 정직한 미구현 안내. 단, 연결된 서버 엔드포인트가 비멱등이라 파생 항목 1건이 이미 비활성이면 버튼이 영구 고장 (신규 M16) |
| 4 | entity-bucket 점수 dedup | **PARTIAL** | risk_scoring.py:102-104 핵심 수정 + 테스트 2건 존재. 번들이던 LOW(카테고리 우선순위 미러링) 미적용. **더 중요하게는 H7로 인해 프로덕션 경로에서는 이 수정 자체가 작동 불능** |
| 5 | `runManualSearch` in-flight 가드 | **FIXED** (단서 有) | app.js:2384/2390/2436/2460, rerun은 :2488 void 처리. 단, 가드 발동 시 무피드백 + 폼 덮어쓰기(신규 M17), 검색 완료 시 입력창 초기화(LOW) |
핵심 교훈: 락 수정의 주석(sqlite_store.py:598-600 "다른 writer가 끼어들지 않는다")은 **락을 잡는 writer에 대해서만** 참이다. `record_decision`, `manual_search`, `set_provider_enabled`, `emergency_disable`, `collect_keyword_candidates`, `_sync_submission_provider_state`가 락 밖에 남아 수정의 보증을 우회한다.
## 3. 신규 발견 (유닛 간 dedup 완료, HIGH는 전건 2-표 검증)
### HIGH — 9건 전건 CONFIRMED
| # | 위치 | 요약 | 발견 유닛 |
|---|---|---|---|
| H1 | sqlite_store.py:569 | `record_decision` 무락 RMW — `_rescore_submission`(store_search_candidates.py:721-731)·bulk sweep과 경합 시 운영자 결정이 'unreviewed'로 조용히 복원, 감사로그/워치리스트와 상태 불일치 | fix-verify(M), **arch-concurrency(H)**, store-integrity(M) — 3개 유닛 교차 발견 |
| H2 | store_persistence.py:238 | `_apply_provider_usage_delta`가 검색 전 캡처한 provider payload 전체를 되써서 in-flight 검색 종료 시 enable/disable(긴급 차단 포함) 되돌림 | arch-concurrency |
| H3 | store_enrichment.py:49 | `_sync_provider_payloads`가 저장된 `enabled`를 연결성 기본값으로 덮어씀 — 모든 reload/import/upload/수집이 차단을 무감사 해제, collect의 disabled 체크(store_operations.py:575-578)는 항상 무효 | arch-concurrency |
| H4 | store_operations.py:321 | `manual_search`가 수 초~수십 초 외부 검색 전에 읽은 stale payload를 :337-347에서 무락 재기록 — 검색 중 내려진 결정/근거상태 덮어씀 | arch-concurrency |
| H5 | sqlite_store.py:285 | 0바이트/손상 이미지 1장이 `analysis_runs_for_submission(...)[-1]` IndexError로 **모든 import/reload/upload를 지속 마비**(부분 커밋 잔류, 파일 수동 삭제 전까지 반복) | store-integrity |
| H6 | store_serialization.py:260 | 직렬화가 `promoted` 누락 → 프로덕션에서 Naver 검색 근거 상시 0점 (GUI엔 80% 표시, contributed=true 거짓 표기) | analysis-scoring |
| H7 | store_serialization.py:302 | 직렬화가 `entity` 누락 → Google 웹 엔티티 상시 0점, rerun 시 엔티티 기반 쿼리 생성도 불가, 89c1fe4 dedup 수정 프로덕션 무력화 | analysis-scoring |
| H8 | index.html:227-229 | 이미지 패널 버튼(맞춤/1:1/회전) 핸들러 전무 — 죽은 버튼 (합성 단계 재검증: JS 전체에 해당 id 참조 0건) | frontend |
| H9 | app.js:2943-2955 | 후보 '무시'가 로컬 splice뿐 — 서버 엔드포인트 부재(src/ 내 dismiss 0건), 모든 bootstrap 갱신 시 무시한 후보 부활 (합성 단계 재검증 완료) | frontend |
### 검증 결과 강등 (HIGH → MEDIUM)
| # | 위치 | 요약 | 판정 |
|---|---|---|---|
| D1 | store_serialization.py:349 | skip-사유 키워드 휴리스틱이 컴플라이언스 skip을 FAILURE(+30)로 오복원 — **단, 현 wiring에서는 트리거 문자열이 도달 불가능**(compliance 플래그가 어댑터 존재와 동치, env_clients.py:99-118). 잠재 지뢰로서 MEDIUM. 수정은 H6/H7과 같은 방법(도메인 source enum 영속화)으로 함께 처리 | DOWNGRADE (1표 REFUTED, 1표 DOWNGRADE) |
### MEDIUM — 주요 항목 (유닛 판정 그대로)
**동시성/저장소**
- M1 store_operations.py:584 — `collect_keyword_candidates` 무락 전체 삭제+재적재: promote와 경합 시 404 또는 stale 'promoted' 행 부활, 어댑터 예외 시 이전 후보 전멸 *(fix-verify·arch-concurrency·store-integrity 3중 발견)*
- M2 sqlite_store.py:630/647 — `set_provider_enabled`/`emergency_disable` 무락 RMW(usage 증분 유실, 비원자 sweep) *(arch-concurrency·store-integrity)*
- M3 store_enrichment.py:56-71 — `_sync_submission_provider_state` 전 테이블 무락 sweep이 무관한 제출건의 동시 갱신까지 되돌림
- M4 sqlite_store.py:233 — seed가 `_write_lock`을 쥔 채 레코드당 외부 HTTP 수행 → 대량 import 동안 락 필요 엔드포인트 전부 무한 대기
- M5 http_app.py:387-396 — 업로드 경로 exists()/write TOCTOU: 동명 파일 동시 업로드 시 한쪽 바이트 소실
- M6 store_queue.py:92-119 — 큐 활성 전환/레거시 이관에 감사 이벤트 전무
- M7 store_schema.py:249 — CHECK 위반 레거시 행 1건이 서버 기동을 영구 차단(수복 경로 없음)
- M8 image_store.py:93 — 동일 stem 파일(a.jpg/a.png)이 한 제출건으로 병합, 한 이미지는 미심사 통과
- M9 store_queue.py:119 — 레거시 무큐 제출건이 새 큐에 입양된 뒤 prune에서 근거까지 연쇄 삭제 (심사 이력 소실 시나리오)
**HTTP 보안 (신규만)**
- M10 http_app.py:277 — 음수 Content-Length가 10MB 캡 우회 → `rfile.read(-1)` EOF까지 읽어 메모리 고갈
- M11 http_app.py:65 — 핸들러 timeout 부재 + 무제한 스레드 → slowloris로 스레드 고갈
**스코어링/프로모터**
- M12 search_result_promoter.py:17 — blog/web "no results" 근거가 가드를 빠져나가 쿼리 텍스트 매칭으로 0.8 승격 (0건 검색이 80% 신뢰도로 표시)
- M13 search_result_promoter.py:36 — 마커가 영어 11개뿐: 한국어 Naver 결과 체계적 미탐 + 'iu'/'game'/'cover' 오탐
- M14 risk_scoring.py:38-42 — promoted Naver 결과 아이템당 +40 무상한 적층 (쿼리 1건 결과 2개면 high 밴드) — H6 수정 시 즉시 프로덕션 노출되므로 동시 수정 필수
- M15 evidence_enrichment.py:58 — (인메모리 경로 한정) KB 전 항목이 쿼리 예산 잠식, 'rejected:{id}' 항목이 문자 그대로 검색어화
**프론트엔드**
- M16 app.js:3249 — deactivate 루프 비멱등·부분 실패 시 refresh 없음 → 이미 비활성 항목 1건이 버튼 영구 고장 *(fix-verify·frontend)*
- M17 app.js:2484 — rerun이 폼을 덮어쓴 뒤 무피드백 no-op
- M18~M23 — addKnowledgeEntry의 수집탭 초기화(:2879), executeSuggestedQueries 미처리 rejection(:1052), runCandidateCollection 선-클리어(:3005), 영구 잔존 apiError 배너(:234), 8개 클릭 mutation의 in-flight 가드 부재(:3185 등), `renderCorrections`의 `derivedEntries` 무가드 참조(:2030, renderAll 전체 중단 가능)
**LOW (~12건, 대표)**: 검색 완료 시 입력 wipe(app.js:2448), batch re-promote 무음 폐기(store_operations.py:713), keep-alive 커넥션 Set-Cookie 잔류(http_app.py:264), Content-Length 파싱 오류 내부 메시지 노출(:277), FK check 미수행(store_schema.py:218), face-crop 보존기한 mtime 기준(store_enrichment.py:202), 죽은 category 분기(risk_scoring.py:87), exact: 지문 비교 불능(fingerprints.py:28), 프론트 'undefined' 렌더/스테일 패널/CSS 누락 클래스 등.
*(knownBacklog 재발견분 — ensure_queue UPDATE 키 버그(store_queue.py:114), rerun 경로 무락(backlog #12), CSP/쿠키/rate-limit 등 sec-http 6건, 프론트 5건 — 은 위 표에서 제외, 4절에 반영)*
## 4. 백로그 현황 (2026-06-22 로드맵 26개 항목)
**집계: FIXED 4 / PARTIAL 3 / OPEN 20 / REGRESSED 0** (backlog-status 유닛 전수 코드 대조 결과)
- **Phase 1 (1-5)**: 1,2,3,5 FIXED / 4 PARTIAL (LOW 서브픽스 누락 + H7로 프로덕션 무력)
- **Phase 2 보안 (6-11)**: **전 6건 OPEN** — 앱셸 CSP/XFO 부재(http_app.py:314-345), 원시 토큰 쿠키·Secure 부재(:291), 토큰 엔트로피/rate-limit 부재, `UrllibJsonTransport` 무제한 read(http_json.py:39), Origin CSRF 폴백 부재, Google API 키 URL 노출(cloud_vision_web_detection.py:57)
- **Phase 3 (12-20)**: 16 PARTIAL(LLM 사유만 필터, skip 사유는 여전히 잔류 — risk_scoring.py:57-63) / 나머지 8건 OPEN (12 rerun 무락, 13 ensure_queue, 14 무음 예외, 15 lastFailure 오귀속, 17 GUI 가드, 18 dedup 핫패스 전체 스캔, 19 EXIF 무경고 폴백, 20 reasons 가변 list)
- **Phase 4 (21-24)**: 전건 OPEN — 21은 89c1fe4에서 의도적 유예된 HIGH #6(_auto_* 메가함수, store_enrichment.py:391/:557)
- **Phase 5 (25-26)**: 25 PARTIAL(롤백·dedup 테스트 2건 추가, 미디어 401/untrusted 헤더/safeUrl/SSRF/E2E 등 다수 미작성) / 26 OPEN(offline-install.md에 Ollama·Playwright·lock 해시 부재)
- **Phase 6 LOW 배치**: 전건 OPEN
## 5. 차기 스프린트 권고 (risk-per-effort 순)
1. **[최우선] 근거 직렬화 라운드트립 복구** — store_serialization.py `_evidence_payload`/`_domain_evidence_from_ui`에 `promoted`·`promotionReason`·`entity`(·`category`) 필드 추가 + 도메인 `EvidenceSource` enum 직접 영속화(키워드 휴리스틱은 레거시 폴백으로 강등). H6·H7·D1을 한 번에 해소. **store 레벨 회귀 테스트**(auto-search → `_rescore_submission` → riskScore > 0 단언) 필수. 작은 diff, 핵심 기능 복구.
2. **[동시 필수] 프로모터 정합성** — 1번을 배포하면 M12(0건 검색 0.8 승격)·M13(영어 마커)·M14(+40 무상한 적층)가 곧바로 프로덕션 점수에 노출된다. search_result_promoter.py의 no-results 구조적 매칭 + 쿼리 텍스트 스캔 제외 + NAVER_SEARCH 기여 상한/쿼리별 dedup(risk_scoring.py)을 **같은 PR**로.
3. **[한 줄 수정] seed IndexError 가드** — sqlite_store.py:285에서 빈 run 리스트 시 'failed' 밴드 제출건 생성+감사 이벤트 후 continue. 0바이트 파일 1장으로 전 import 마비되는 H5 제거.
4. **provider enabled 내구성** — `_sync_provider_payloads`가 저장된 `enabled` 보존(연결 안 될 때만 강제 off), `_apply_provider_usage_delta`는 최신 행에 델타 병합(stale payload 전체 되쓰기 금지), `_auto_*` 드라이버가 DB 플래그 확인, `set_provider_enabled`/`emergency_disable`에 `_write_lock`+단일 트랜잭션. H2·H3·M2 해소 — 긴급 정지의 신뢰성 회복.
5. **`_write_lock` 일괄 적용** — `record_decision`(H1), `manual_search`(H4, 외부 I/O는 락 밖·RMW는 락 안), `rerun_enrichment`/`run_auto_search`(backlog #12), `collect_keyword_candidates`(M1), `_sync_submission_provider_state` 필드 단위 UPDATE 전환(M3). 패턴은 `mark_evidence_status`에 이미 있어 기계적 적용. 동시에 M4(seed 락 협소화) 검토.
6. **GUI 조작 신뢰성 배치** — H8(버튼 구현 또는 제거), H9(dismiss 서버 영속화 또는 최소 클라 필터), M16(엔드포인트 멱등화 + finally refresh), M22(공용 `withInFlightGuard` 헬퍼로 8개 mutation + setDecision(backlog #17) 일괄), M23(`derivedEntries || []`). 단일 프론트 PR.
7. **HTTP DoS 즉효 수정 + Phase 2 착수** — 음수/비정수 Content-Length 거부(M10, 2줄), 핸들러 `timeout` 설정(M11, 1줄)을 즉시. 이어서 백로그 Phase 2(items 6-11)를 보안 PR 하나로 — CSP/nosniff 헤더, 쿠키 분리, 토큰 엔트로피, `UrllibJsonTransport` 응답 캡.
8. **마이그레이션/보존 안전망** — M7(제약 재구축 시 coerce/격리 후 진행), M9(레거시 이관 시 파일 존재 검증 또는 prune 면제), M8(stem 충돌 시 확장자 포함 id), M6(ensure_queue 감사 이벤트).
**핵심 메시지**: Phase 1은 제대로 수행됐지만, 이번 리뷰의 결론은 "함수 단위 수정에서 패턴 단위 수정으로 전환하라"이다. (a) 직렬화 필드 손실 1개 패턴이 HIGH 2건+MEDIUM 1건을, (b) 무락 RMW 1개 패턴이 HIGH 3건+MEDIUM 5건을 만들었다. 위 1·4·5번을 패턴 단위로 닫으면 신규 HIGH 9건 중 7건이 사라진다.

View file

@ -0,0 +1,118 @@
# 2026-07-11 리뷰 리메디에이션 체크리스트
> 기준 리뷰: `docs/reviews/2026-07-11-project-review.md` (HEAD 89c1fe4)
> 신규 HIGH 9 + 유닛 추가 HIGH 3(통합/프론트) + MEDIUM ~28 + LOW ~14.
> 배치는 risk-per-effort 순. 각 배치 후 `pytest tests/rights_filter -q` 통과 확인.
>
> **2026-08-08 재확인**: 미체크로 남아 있던 항목을 코드와 대조했다. 6건은 이미 수정돼 있었고
> (체크리스트가 낡았던 것 — 각 항목에 확인 근거 기재), 보안 2건(#8 rate limit, SSRF
> DNS-rebinding TOCTOU)은 실제로 열려 있어 이번에 수정했다. 남은 미체크는 전부 **명시적 연기**
> 이며 각 줄에 근거가 있다.
## Batch 1 — 스코어링 파이프라인 복구 (최우선) ✅ 완료
직렬화 라운드트립 필드 손실 1개 패턴이 HIGH 3건을 만듦. 프로모터 3건은 배포 즉시 노출되므로 동일 배치.
- [x] H6 `store_serialization.py` — `_evidence_payload`에 `promoted`/`promotionReason` 추가, `_domain_evidence_from_ui`에서 복원
- [x] H7 `store_serialization.py` — `entity`/`category` 추가+복원
- [x] D1 `store_serialization.py` — 실제 `EvidenceSource` 영속화(`domainSource`), 키워드 휴리스틱은 레거시 폴백
- [x] M12 `search_result_promoter.py` — no-results 구조적 매칭, 마커 스캔에서 query 텍스트 제외
- [x] M13 `search_result_promoter.py` — 한국어 마커 추가, 단문 오탐 워드바운더리
- [x] M14 `risk_scoring.py` — promoted Naver 결과 query_signature 기준 dedup
- [x] 회귀 테스트 7건 (라운드트립 score>0, 2 distinct entities, skip 무점수, dedup, 프로모터)
## Batch 2 — 동시성 / _write_lock 규율 ✅ (M4/#12 제외)
무락 RMW 1개 패턴이 HIGH 3 + MEDIUM 5.
- [x] H1 `sqlite_store.py` — `record_decision` _write_lock
- [x] H2 `store_persistence.py` — `_apply_provider_usage_delta` 델타 병합(stale payload 되쓰기 금지)
- [x] H3 `store_enrichment.py` — `_sync_provider_payloads` 저장된 enabled 보존(`operatorDisabled` 플래그로 연결성/운영자 구분)
- [x] H4 `store_operations.py` — `manual_search` 네트워크 락 밖, RMW 재읽기 후 락 안
- [x] M1 `store_operations.py` — `collect_keyword_candidates` 후보 메모리 누적→락 안 clear+insert
- [x] M2 `sqlite_store.py` — `set_provider_enabled`/`emergency_disable` 락+트랜잭션
- [x] M3 `store_enrichment.py` — `_sync_submission_provider_state` 락
- [x] 회귀 테스트 2건 (operator-disable 재시드 생존, usage delta가 in-flight disable 안 되돌림)
- [ ] #12 `rerun_enrichment`/`run_auto_search` 락 — **연기**(메가함수 내 네트워크+RMW 교차, 락 유지 시 가용성 회귀; 연기된 HIGH#6 리팩터와 함께)
- [ ] M4 seed 락 협소화 — **연기**(동일 이유, 가용성 최적화)
## Batch 3 — 시드/저장소 견고성 ✅
- [x] H5 `sqlite_store.py` — 빈 analysis run 시 'failed' 밴드+감사+continue + 테스트
- [x] #13 `store_queue.py` — ensure_queue WHERE 키를 기존 행 id로 + 테스트
- [x] M6 `store_queue.py` — 큐 활성/레거시 이관 시 감사 이벤트(스팸 방지 조건부)
- [x] M5 `http_app.py` — 업로드 TOCTOU: 배타 생성(`open xb`) 재시도 루프
- [~] M8 `image_store.py` — **되돌림**: stem-충돌 접미어가 id를 형제-의존으로 만들어 판정된 submission 중복 부활 회귀(diff 리뷰 HIGH) → `id=path.stem` 복귀. 올바른 수정은 시드 단계 incumbent-id 보존 + 자산경로 마이그레이션 필요(연기)
- [ ] M7 `store_schema.py` — 제약 재구축 위반 행 coerce/격리 (미착수 — 마이그레이션 위험)
- [ ] M9 `store_queue.py` — 레거시 이관 파일 존재 검증 (연기 — 오이관 위험, 신중 설계)
## Batch 4 — 통합/아웃바운드 ✅
- [x] HIGH `cloud_vision_web_detection.py` — per-image error → HttpJsonError + 테스트 2건
- [x] HIGH `search_policy.py` — daily_limit 날짜 윈도우 리셋 + Lock
- [x] M `google_custom_search.py` — map 응답을 try 안으로
- [x] #9 `http_json.py` — 응답 크기 캡 + decode/read 예외 계약
- [x] #10 API 키 URL→헤더 — **Vision만** `X-goog-api-key`(검증됨+테스트). customsearch는 헤더 미검증(미지원 시 검색 조용히 실패)이라 `key=` 쿼리 유지(diff 리뷰 MEDIUM)
- [x] #15 `store_enrichment.py` — lastFailure 첫 실패 아이템 (3곳)
- [x] LOW `store_remote_fetch.py` — SSRF `not is_global`(CGNAT 등 특수대역 차단)
- [x] LOW `env_clients.py` — NAVER display 3종 클램프(1~100)
- [x] M `env_clients.py` — daily-limit malformed → 보수적 100(fail-open 제거) + 테스트 갱신
- [x] M `naver_search.py` — 멀티페이지 쿼터 실제 요청 수 (2026-08-08 확인: `_requested_calls` + `try_reserve(requested_calls=…)`/`release` 로 이미 구현돼 있었음. 항목이 낡았던 것)
- [x] M `store_remote_fetch.py` — SSRF DNS-rebinding TOCTOU (2026-08-08 수정: `_guarded_create_connection` 이 getaddrinfo 를 **1회만** 수행하고 그 답변의 모든 주소를 검증한 뒤 그중 하나로만 연결. `_Guarded*Connection`/`*Handler` 로 opener 에 배선 — 사전검사와 실제 다이얼의 해석이 갈라지지 않는다. 리바인딩 회귀 테스트 포함 14건)
- [ ] LOW: EUC-KR 스니핑, dead code 삭제(레이아웃 테스트가 고정 → 유지)
## Batch 5 — HTTP 보안 ✅ (M10/M11/#6)
- [x] M10 음수/비정수 Content-Length 거부 + 테스트
- [x] M11 핸들러 timeout=30
- [x] #6 앱셸 CSP/X-Frame-Options/nosniff + 테스트
- [x] #8(부분) 토큰 길이<32 경고 + static 부재 경고(`__main__`)
- [ ] #7 쿠키 분리+Secure (연기 — Secure는 localhost HTTP서 쿠키 미전송→인증 붕괴; HTTPS 전제 필요)
- [x] #8 rate limit (2026-08-08 수정: `server/auth_rate_limit.py` — **틀린 자격증명을 제시한** 시도만 클라이언트 주소별 고정윈도우 10회/60초로 제한, 초과 시 `429` + `Retry-After`. 성공하면 카운터 초기화. 토큰 미입력 요청(콘솔 최초 로드가 여러 건 발생시킴)은 세지 않으므로 정상 운영자가 잠기지 않는다. 테이블은 1024 클라이언트로 상한. 테스트 12건)
- [x] #10 Origin/Referer CSRF 폴백 (2026-08-08 확인: `_origin_ok()` 로 이미 구현돼 있었음 — 7/24 리뷰가 지적한 낡은 항목)
## Batch 6 — 프론트엔드 ✅
- [x] H8 이미지 뷰어 버튼 구현 + 케이스별 리셋
- [x] H9 dismiss 서버 영속화(신규 엔드포인트) + 테스트
- [x] HIGH '자동 항목 비활성' 선택 케이스 스코프
- [x] M16 deactivate 루프 멱등화(활성만, per-entry 실패 무시, finally refresh)
- [x] #17 setDecision in-flight 가드 + rerunSelected/applyCoverage null 가드
- [x] M18 addKnowledgeEntry 수집탭 초기화 부작용 제거
- [x] M19 executeSuggestedQueries refreshFromApi try/catch
- [x] M20 runCandidateCollection 선-클리어→성공 후로
- [x] M22 apiError 배너 applyBootstrap에서 clear
- [x] M23 renderCorrections derivedEntries 가드
- [x] #11(부분) riskClass 밴드 allowlist, case-image src safeUrl
- [ ] #11 잔여 sinks(similar-strip/face-crop safeUrl, innerHTML label) (미착수)
- [ ] #24 window.prompt → 인라인 (미착수 — 규모 큼)
- [ ] M21 8개 delegated mutation 공용 in-flight 가드 (미착수)
## Batch 7 — env/config/docs ✅
- [x] `env_file.py` UTF-8-sig BOM
- [x] `env_clients.py` daily-limit fail-open 제거
- [x] `__main__`/launcher 경로 ROOT chdir + python 3.13 가드 + static/token 경고
- [x] offline-install.md — Ollama/Playwright/lock 해시/egress 정정/`--only-binary`
- [x] .gitignore — .claude/settings.local.json, .impeccable/
- [ ] `env_clients.py` LLM assistant OLLAMA 게이팅 (연기 — env={} llm 테스트 다수 의존)
- [ ] package.json 정리 (미착수)
## Batch 8 — 에러핸들링/기타 ✅ (부분)
- [x] #14 `_sync_face_crops` 무음 예외 로깅 + preprocessing EXIF 폴백 경고(#19)
- [x] #16 skip 사유 reasons 오염 제거 + 테스트
- [x] #18 dedup 핫패스 타깃 쿼리(2곳)
- [ ] #14 잔여 무음 swallow(page scrape 등) (미착수)
- [ ] #20 ScoreResult.reasons tuple (연기 — 테스트 다수 `== []` 단언 충돌)
## Batch 7 — env/config/docs
- [x] `env_file.py:12` — UTF-8-sig BOM (2026-08-08 확인: `read_text(encoding="utf-8-sig")` 로 이미 적용됨)
- [x] daily-limit fail-open → 보수적 기본 (2026-08-08 확인: `_daily_limit()` 이 malformed 값에 warning + 100 적용. Batch 4 의 동일 항목과 중복 기재였음)
- [ ] `env_clients.py:239` — LLM assistant OLLAMA_* 게이팅 (연기 — env={} 로 LLM 을 기대하는 테스트 다수가 의존)
- [x] `__main__.py` — 경로 ROOT 앵커링 + static 부재 경고 (2026-08-08 확인: `6ab6da3` 에서 적용됨)
- [ ] offline-install.md — Ollama/Playwright/lock 해시
- [ ] python 3.13 가드, .gitignore, package.json 정리
## Batch 8 — 에러핸들링/기타 백로그
> **2026-08-08: 이 배치는 Batch 6 과 중복된 착수 전 계획 목록이었다.** #14·#16·#18·#19 는
> Batch 6 에서 이미 처리돼 [x] 로 기재돼 있고, #20 은 같은 곳에 연기 근거와 함께 남아 있다.
> 여기서 중복 추적하지 않는다 — 상태는 Batch 6 이 기준.
- [x] #14 무음 예외 로깅 → Batch 6 참조 (잔여 swallow 는 Batch 6 에 별도 미체크로 남음)
- [x] #16 skip 사유 reasons 오염 → Batch 6 참조
- [x] #19 EXIF/GPS PIL 폴백 경고 → Batch 6 참조 (테스트 실행 로그에서 실제 warning 발생 확인:
`preprocessing.py:52 external derivative fell back to original bytes…`)
- [x] #18 dedup 핫패스 타깃 쿼리 → Batch 6 참조
- [ ] #20 ScoreResult.reasons tuple → Batch 6 의 연기 항목과 동일 건

View file

@ -0,0 +1,85 @@
# 2026-07-24 구현 체크리스트 (전체 개선 + 신규 기능)
> 기준: `docs/reviews/2026-07-24-project-review.md`.
> 최종 검증: `pytest tests/rights_filter tests/operator_gui` → **457 passed** (착수 전 377 passed / 2 failed).
> 실서버 스모크(포트 9561): 인증·시드·중복인덱스·일괄판정·프리셋·내보내기·백업·복구 CLI 실동작 확인.
## 배치 0 — 저장소 위생 ✅
- [x] 루트 `Microsoft/` 삭제 + `.gitignore` 에 `Microsoft/` 추가
- [x] 루트 `tmp_*` 4건 삭제 (git 미추적 확인 후)
- [x] 저장소 전체 stale `__pycache__` 삭제 (옛 경로 `Desktop\complete\` 내장분)
- [x] `data/logs/edge-profile-static/` 삭제 (612엔트리·27MB)
## 배치 1 — HIGH ✅
- [x] H1 `face_person_detection.py` cv2 호환 가드(경고 1회) + `batch_analyzer` 제출물단위 예외 격리 + **환경 정리**(opencv-python 5.0.0 제거 → 핀 4.13.0.92 복구)
- [x] H2 `risk_scoring.py` `match=="search_image"` 명시적 0점 — 왕복 시 0→45 인플레 차단
- [x] H3 `store_serialization.py` SEARCH_SKIPPED/ENRICHMENT_FAILURE 를 dedup 시그니처에서 제외
- [x] H4 `store_operations.py` rerun 예외 격리 + 'queued' 마커 finally 정리 + `lastRerunDiff.error` 노출
- [x] H5 `external_policy.py` 날짜 윈도우 리셋 + Lock (SearchApiPolicy 패턴 미러)
- [x] H6 `image_store.py` 매니페스트 행 단위 예외 격리 + width/height 안전 변환
- [x] H7 `app.js`/`index.html`/`styles.css` 인증 게이트(401→토큰 입력→헤더 재시도→세션 쿠키)
## 배치 2 — 큐/스토어 정합성 ✅
- [x] `sqlite_store.py` 큐 전역 id 충돌 시 스킵 + 1회성 감사 이벤트(판정 소실·증거 오염 차단)
- [x] Vision **실패**만 provider 귀속(`provider: google`) + 레거시 매처 확장. **스킵은 의도적으로 미귀속** — 정책 거부는 '실패'가 아니라 '미실행'
- [x] `_search_coverage` 실패-만 제출물을 커버리지에서 제외
## 배치 3 — enrichment/operations ✅
- [x] Vision 정책거부 시 사용량 오계상 + **허위 생체정보 외부전송 감사기록** 제거
- [x] 재분석 FACE_PERSON +35 중첩 → 스코어러 단일 버킷
- [x] `lastFailure` 를 실제 실패 항목에서 선택 (manual_search / collect 2곳)
- [x] 전부 스킵 시 기존 수집 후보 보존(무조건 삭제 제거) + provider 상태는 영속
- [x] 수집 후보 카운트 인플레 → id 기준 dedup
## 배치 4 — HTTP/보안/부팅 ✅
- [x] 비-객체/비-UTF8 JSON 바디 → 400 (핸들러 AttributeError 크래시 제거)
- [x] 비ASCII Authorization/쿠키 → bytes 비교로 401 (무응답 종료 제거)
- [x] `_file()` OSError → 404
- [x] `env_file.py` 비인용 값의 인라인 주석 절단(인용 값은 원문 보존)
- [x] `__main__.py` stdout 라인 버퍼링 — 리다이렉트 기동 시 보안 경고가 즉시 로그에 남음
## 배치 5 — analysis ✅
- [x] EXIF Orientation 정규화 — 외부 파생물·얼굴크롭·pHash·PIL 폴백 디코드 (파생물 크기를 정규화 이미지 기준으로 재계산). cv2 기본 디코드는 이미 적용됨을 실측 확인
- [x] aHash 저엔트로피 근접매칭 차단 (동일 해시는 1.0 유지 — 동일 이미지 매칭 보존)
- [x] 프로모터 마커 스캔에서 URL 제외 (title/description 한정)
## 배치 6 — integrations ✅
- [x] Vision 응답 매핑을 예외 경계 안으로 + bestGuessLabels 비-dict 항목 무시
- [x] `http_json.py` HTTPError 본문 4KiB 캡
- [x] `_probe_connect` 프록시 적용 대상이면 프로브 생략
- [x] `search_policy.py` `try_reserve`/`release` 예약형 회계 (쿼터 의미 보존: fetch 실패=환불, 매핑 실패=소비)
## 배치 7 — domain/admin/governance ✅
- [x] LLM_SUMMARY 거버넌스 게이트를 실제 저장 경로에 적용(미근거 요약 폐기)
- [x] admin 4개 모듈에 '운영 서버 미연결' docstring 경고
- [x] 정상 스킵을 `skipped` 그룹으로 분리 (failures 오분류 제거)
- [x] `register_manual_entry` 미지원 필드 조용한 폐기 → ValueError
- [x] 반복 반려 시 rejected_image 엔트리 upsert (무한 누적 제거)
## 배치 8 — 프론트엔드 ✅
- [x] 케이스 전환 시 결정 메모/오류/수동쿼리 초기화 (`resetCaseScopedInputs`)
- [x] 추천 쿼리 요약 — 실근거만 집계, 스킵/무결과 구분 표기
- [x] '주의 후보 보기' → 등록 탭 + watchlist 필터
- [x] 케이스 없음 시 `evidence-next-actions` 초기화
- [x] hashchange `#corrections` 진입 시 지식 탭 실제 전환
- [x] 수동검색 완료 메시지에 대상 케이스 id 명시
## 배치 9 — 문서 ✅
- [x] README `python -m rights_filter.server` PYTHONPATH 안내로 정정
- [x] GUI 설계 스펙에 일괄 판정 제약 해제 **개정 이력** 명시
## 배치 10 — 신규 기능 ✅
- [x] **F1 키보드 연속 심사** — J/K 이동, A/H 판정 후 다음 미심사 자동 이동, R 메모 포커스, N 다음 미심사, ? 도움말, 진행 표시. 입력 중에는 비활성
- [x] **F2 일괄 판정 + 메모 프리셋** — `POST /api/submissions/decision-batch`(케이스별 트랜잭션·감사·부분실패 보고, 최대 200건, 고위험 확인, 반려 메모 필수) / `GET·POST /api/decision-memo-presets`
- [x] **F3 제출물 지문 중복·선례 인덱스** — 시드 시 지문 백필(멱등), exact+pHash≥0.97 클러스터, 큐 배지·그룹 일괄선택, 선례 판정·**판정 불일치** 표시
- [x] **F4 판정 내보내기** — `GET /api/decisions/export`(internal / applicant 화이트리스트 분리, CSV+BOM)
- [x] **F5 온라인 백업·복구** — `POST /api/admin/backup`(sqlite backup API, WAL 안전) + 보존 로테이션 + `--restore` 오프라인 CLI(복구 전 원본 보존)
## 의도적 비구현 (사유)
- #7 쿠키 Secure — localhost HTTP 에서 인증 붕괴, HTTPS 전제 필요
- #8 rate limit — 로컬 단일 운영자 콘솔, 인증으로 대체 커버
- #20 `ScoreResult.reasons` tuple — 다수 테스트 `== []` 단언과 충돌
- 운영자 역할분리 / 통계 대시보드 — 차점 기능, 다음 사이클
- google_search 수동검색 허용 — 2026-06-11 승인된 설계 결정(유지)
- aHash → DCT pHash 교체 — 저장된 지문 무효화 + 임계 재보정 필요. 근접매칭 가드로 완화, 별도 마이그레이션 과제
- `#12`/M4 락 협소화, M7 스키마 coerce, M9 이관 검증, M8 id 마이그레이션 — 7/11 연기 사유 유효

View file

@ -0,0 +1,252 @@
# 2026-07-24 프로젝트 점검 리포트 (멀티에이전트)
> 세션: 감사자 8 + 런타임 스모크 1 + 연기항목 현황 1 → 발견별 적대적 검증 2~3인 → 기능 제안 4렌즈 16건 → 심사 3인.
> 총 에이전트 109. 기준 HEAD: 2c607e0 (fix/2026-07-11-review-remediation).
## 0. 전체 요약
- **pytest**: `tests/rights_filter` **377 passed, 2 failed** (실패 2건 모두 cv2 5.0 환경 드리프트 — H-4 참조).
- **런타임 스모크**: 실서버 기동(포트 9557) 후 9개 항목 **전부 통과** — 앱셸/보안헤더 3종/정적자산/무토큰 401/인증 GET/케이스 상세/미디어 서빙/404/path traversal 5변형 거부.
- **신규 발견**: CONFIRMED 43건 (HIGH 7 · MEDIUM 15 · LOW 21), REFUTED 2건. 모든 발견은 독립 검증자 2인(상충 시 3인)의 반증 시도를 통과함.
- **7/11 체크리스트 현황**: 미해결로 기재된 항목 중 3건은 실제로는 이미 수정됨(naver 멀티페이지 쿼터, #10 Origin/Referer CSRF 폴백, #11 잔여 XSS sinks) — 체크리스트 갱신 필요. 완료 표본 검증 5건 전부 실수정 확인.
## 1. HIGH 발견 (7건) — 상세
### H-1. cv2 5.0 환경에서 CascadeClassifier AttributeError가 무방비로 전파되어 시드/리로드/임포트/업로드/재분석 전부가 응답 없이 죽음
- 위치: `src/rights_filter/analysis/face_person_detection.py:56` (감사: analysis)
- 내용: face_person_detection.py:56의 `cv2.CascadeClassifier(f"{cv2.data.haarcascades}...")`는 어떤 try/except에도 보호되지 않는다(33–37행 try는 import만 커버, 디코드 실패는 그 전에 조기 반환하므로 디코드 가능한 모든 이미지에서 도달). OpenCV 5.0.0(사용자 site-packages가 핀 버전 4.13.0.92를 가림)에는 CascadeClassifier가 없어 AttributeError가 발생하고, 이 예외는 서버 최상위까지 아무도 잡지 않는다. 전파 경로: (1) detect → internal_analyzer.py:70 analyze(무보호) → jobs/batch_analyzer.py:70 run(82행이 PreprocessingError만 catch — AttributeError 통과) → sqlite_store.py:282 batch.run → seed_from_image_store → http_app.py:170(/api/submissions/reload)·181(/import-folder)·188(/upload-image). (2) 이미 시드된 DB에서는 그보다 먼저 sqlite_store.py:245 → store_enrichment.py:149 _refresh_existing_local_face_evidence에서 face 증거 없는 첫 제출물에 대해 폭발. (3) sqlite_store.py:248/327·store_enrichment.py:299 → store_enrichment.py:184 _sync_face_crops(176–182행 try는 image_payload만 보호). (4) http_app.py:168 rerun-enrichment → store_operations.py:232 → store_enrichment.py:288 _rerun_internal_analysis. http_app.py do_POST의 except 체인(236–243행)은 _PayloadTooLarge/sqlite3.OperationalError/KeyError/ValueError만 잡으므로 AttributeError는 핸들러를 탈출하고, http.server 표준 동작상 traceback이 서버 콘솔에 찍히며 HTTP 응답 없이 커넥션이 닫힌다. 운영자 증상: 500 JSON도, 분석 실패 밴드도 아닌 브라우저 수준 'Failed to fetch' — 조회/헬스 라우트는 정상이라 앱이 살아있는 것처럼 보이지만 모든 신규 유입·재분석이 무음으로 전멸한다. 부수 피해: rerun은 크래시 전에 lastAnalysis 갱신 + '재분석 요청이 접수됨'(status=queued) 마커 증거를 이미 영속화(store_operations.py:213–229, 타임스탬프 id)하므로 실패한 재시도마다 queued 마커 행이 GUI 증거 목록에 누적되고, 시드는 batch 이전 단계 쓰기(_sync_provider_payloads 등)가 부분 커밋된 채 중단된다. 신규 제출물은 첫 레코드 분석에서 죽으므로 1건도 저장되지 않는다.
- 제안 수정: (a) 환경 정리: C:/Users/USER/AppData/Roaming/Python/Python313/site-packages의 OpenCV 5.0.0 제거(pip uninstall opencv-python) 후 핀 버전 opencv-python-headless==4.13.0.92가 보이게 하거나, 서버 실행을 venv + PYTHONNOUSERSITE=1(또는 python -s)로 격리해 user-site 가림을 차단. (b) 코드 방어: _detect_with_opencv에서 `if not hasattr(cv2, "CascadeClassifier") or not hasattr(cv2, "data"): logger.warning(한 번); return FacePersonSignal(0, 0)` 가드 또는 cascade 루프 전체를 try/except Exception으로 감싸 import 실패 폴백과 동일하게 빈 신호로 강등(무음 미탐 방지 위해 warning 로그 필수). 추가로 batch_analyzer/시드 루프에 제출물 단위 예외 격리(기존 'failed' submission 영속화 패턴 재사용)를 두면 한 이미지의 예기치 못한 예외가 시드 전체를 중단시키지 않는다.
### H-2. 직렬화 라운드트립이 result_url을 url로 별칭해 Google 커스텀 이미지 검색 결과가 리스코어 시 0점→+45점으로 변하고, 텍스트 검색 결과가 무게이트 +35/+45로 밴드를 인플레이션
- 위치: `src/rights_filter/analysis/risk_scoring.py:94` (감사: analysis)
- 내용: RiskScorer._web_detection_points는 `item.data.get("url")`이 있으면 무조건 45점을 준다(risk_scoring.py:94–95). Google 커스텀 이미지 검색 결과(match="search_image", google_custom_search.py:235)는 신선한 Evidence에는 "url" 키가 없어 0점으로 설계돼 있으나, 영속화 시 _evidence_payload가 result_url을 "url" 필드로 별칭하고(store_serialization.py:251) _domain_evidence_from_ui가 그것을 data["url"]로 복원하므로(store_serialization.py:326–327), 시드 말미의 _rescore_submission(sqlite_store.py:332) 이후에는 동일 증거가 +45점이 된다 — store_serialization.py:264–268 주석이 명시한 '라운드트립은 원 점수를 재현해야 한다' 불변식 위반. 또한 커스텀 웹 검색 결과는 어댑터가 match="page"를 박아(google_custom_search.py:289) Vision의 '이미지 일치 페이지'와 동일한 +35를 받는다. Naver 쪽은 프로모터가 무게이트 텍스트 검색 결과를 0점(context-only)으로 강등하는 것과 정반대로, Google 텍스트 쿼리 결과는 관련성 검증 없이 결과 존재만으로 +45(url 버킷)+35(page 버킷)=+80 → 자동 쿼리가 아무 결과나 반환하는 모든 제출물이 고위험 밴드(≥70)로 부양된다. 오탐 밴드가 운영자 트리아지 가치를 훼손.
- 제안 수정: 채점 쪽에서 search_type/provider를 보고 커스텀 검색 결과(match="search_image"·provider=google_custom_search의 match="page")를 명시적으로 0점 처리(유사도 FINGERPRINT 증거가 점수를 담당)하거나, 최소한 _domain_evidence_from_ui가 result_url을 "url"로 복원하지 않도록 분리(payload에 원 data의 url 존재 여부를 별도 보존). 수정 후 기존 DB는 전체 리스코어 1회 필요.
### H-3. 쿼터로 스킵된(미실행) 쿼리가 dedup 시그니처에 영구 등재되어 auto search가 해당 쿼리를 영원히 재시도하지 않음
- 위치: `src/rights_filter/server/store_enrichment.py:636` (감사: enrichment-ops)
- 내용: 네이버/구글 어댑터는 쿼터 소진 시 HTTP 요청 없이 SEARCH_SKIPPED evidence를 반환하는데, 이 evidence에도 query_signature가 포함되고(naver_search.py:176, google_custom_search.py:117) _auto_naver_search/_auto_google_custom_search는 이를 그대로 evidence 테이블에 저장한다(store_enrichment.py:661-664, 485-487). SEARCH_SKIPPED는 UI source 'failure'로 직렬화되고(store_serialization.py:422-423), dedup 집합 함수 _existing_naver_query_signatures(store_serialization.py:481-486)와 _existing_google_custom_query_signatures(489-497)는 source 'failure' 행을 포함한다. 결과적으로 다음 auto search/재분석 실행 시 같은 쿼리는 'if signature in existing_signatures: continue'(store_enrichment.py:636-637, 460-461, 블로그/웹 분기 698-701·751-754 동일)로 영구 스킵된다 — 쿼리가 한 번도 실제 실행된 적이 없는데도. ENRICHMENT_FAILURE(일시적 네트워크 오류) 행도 같은 경로로 영구 재시도 차단된다.
- 제안 수정: _existing_naver_query_signatures/_existing_google_custom_query_signatures에서 domainSource가 SEARCH_SKIPPED(및 필요 시 ENRICHMENT_FAILURE)인 행을 제외하거나, 스킵 evidence를 저장할 때 querySignature를 비워 기록한다.
### H-4. 이미지 결손 submission에서 rerun_enrichment가 부분 상태 오염(lastAnalysis 갱신 + 영구 'queued' 마커) 후 미처리 예외로 실패
- 위치: `src/rights_filter/server/store_operations.py:232` (감사: enrichment-ops)
- 내용: rerun_enrichment는 lastAnalysis를 먼저 갱신하고(213-214) '재분석 요청이 접수됨' status='queued' 마커 evidence를 저장한(216-229) 뒤에야 _rerun_internal_analysis(232)를 호출하는데, 이 함수는 image_store.image_payload(submission_id)를 보호 없이 호출한다(store_enrichment.py:288-291, _rerun_google_image_search:310도 동일). image_payload는 manifest 레코드가 없으면 KeyError(image_store.py:65), 파일이 삭제되었으면 FileNotFoundError를 던진다. KeyError는 http_app.py:240-241에서 404 'not found'로 응답되어(submission은 존재하는데) 오해를 유발하고, FileNotFoundError는 do_POST에서 포착되지 않아 응답 없이 커넥션이 끊긴다. 어느 쪽이든 마커 evidence는 영구 'queued' 상태로 잔존하고(타임스탬프 id라 재시도마다 1개씩 누적), lastRerunDiff는 기록되지 않는다. 같은 코드베이스의 _sync_face_crops(store_enrichment.py:176-181)는 동일 상황을 'expected for some rows'라며 명시적으로 처리하고 있어 이미지 결손 행의 존재는 실제로 예상되는 조건이다.
- 제안 수정: rerun_enrichment에서 image_payload 로드를 사전 검증(try/except 후 마커를 실패 상태로 갱신하고 ValueError로 변환)하거나, 마커 저장 전에 이미지 가용성을 확인한다.
### H-5. 인증 토큰 설정 시 운영자 콘솔 전체 동작 불능 — 프론트에 인증 수단 부재
- 위치: `web/operator-gui/app.js:118` (감사: frontend)
- 내용: apiJson()은 Content-Type 헤더만 설정하고 Authorization 헤더를 절대 보내지 않으며, index.html/app.js 어디에도 토큰 입력 UI가 없다(web/operator-gui 전체에서 'Authorization|Bearer|token' grep 0건). 백엔드 http_app.py:280-310은 COPYRIGHTER_AUTH_TOKEN이 설정되면 /api/* 와 /media/* 등 모든 데이터 라우트에 Bearer 헤더 또는 세션 쿠키를 요구하는데, 세션 쿠키는 '헤더 인증에 성공한 요청'의 응답에서만 발급된다(http_app.py:291-296). 즉 브라우저가 쿠키를 얻을 경로가 제품 안에 존재하지 않는다. README는 단일 호스트 개발 외에는 반드시 토큰을 설정하라고 지시하므로, 문서대로 배포하면 /api/bootstrap가 401을 반환해 큐·썸네일·케이스 화면 전부가 'API 연결 실패: unauthorized' 상태로 죽는다. http_app.py:287의 주석('so the operator can enter the token')이 전제하는 토큰 입력 흐름이 프론트에 구현된 적이 없다.
- 제안 수정: 최소 구현: GUI 최초 로드 시 401이면 토큰 입력 프롬프트/화면을 띄우고, 입력 토큰으로 Authorization 헤더를 붙여 /api/bootstrap 1회 호출해 세션 쿠키를 발급받은 뒤(이후 요청은 쿠키로 동작) sessionStorage 등에 보관하지 않는 방식으로 처리.
### H-6. ExternalApiPolicy 일일 한도 윈도우 리셋 부재 — 한도 도달 시 Vision 영구 비활성화
- 위치: `src/rights_filter/integrations/external_policy.py:24` (감사: integrations)
- 내용: ExternalApiPolicy.calls_made 는 단조 증가만 하고 날짜 경계 리셋이 없다. COPYRIGHTER_GOOGLE_DAILY_LIMIT 에 도달하면 다음날에도 can_call() 이 계속 'external API usage limit reached' 를 반환해 Google Vision Web Detection 이 서버 재기동 전까지 영구 비활성화된다. 이 정책 객체는 서버 기동 시 1회 생성되는 싱글턴이다(env_clients.py:110, server/__main__.py:42, sqlite_store.py:83)이고 store_enrichment.py:315,402 의 모든 Vision 호출이 이 인스턴스를 공유한다. SearchApiPolicy 는 정확히 같은 버그를 _roll_window_locked() 로 이미 수정했고(search_policy.py:21-25 주석: 'once the limit is hit, the provider stays disabled until the process restarts'), ExternalApiPolicy 만 누락됐다. 부수적으로 SearchApiPolicy 와 달리 락도 없어 record_call() 의 calls_made += 1 이 멀티스레드 서버에서 증분 유실될 수 있다.
- 제안 수정: SearchApiPolicy 패턴 미러링: window_day 필드 + date.today() 비교 리셋을 can_call/record_call 양쪽에 추가하고 threading.Lock 으로 감싼다.
### H-7. 큐 전환/폴더 임포트 시 동일 id(파일명 stem) 제출물이 다른 큐의 판정·이력을 덮어쓰고 증거가 교차 오염됨
- 위치: `src/rights_filter/server/sqlite_store.py:253` (감사: store-core)
- 내용: _seed_from_image_store_locked 는 신규 여부를 '현재 큐의 제출물 id 집합'(sqlite_store.py:253-260, _all(queue_id=queue_id))으로만 판정하지만, submissions 테이블의 PK 는 큐와 무관한 전역 id 이고 _put 은 on conflict(id) do update 업서트(store_persistence.py:67-92)다. 운영자가 /api/submissions/import-folder 로 다른 폴더(큐 B)를 임포트할 때 큐 A 에 이미 존재하는 id(파일명 stem 또는 manifest id)와 같은 제출물이 있으면 '신규'로 분류되어 새 분석 payload 로 기존 행을 통째로 덮어쓴다. 이때 (1) 큐 A 에서 기록한 decisionStatus(승인/반려/보류)·memo·queryHistory 가 unreviewed 초기값으로 소실되고, (2) 행이 삭제가 아니라 갱신이므로 큐 A 이미지에서 나온 기존 evidence 행들이 그대로 남아 같은 id 의 큐 B 제출물(다른 이미지)에 붙어 표시·재채점(_rescore_submission)에 섞여 들어가 잘못된 위험 점수를 만든다. 큐를 다시 A 로 전환하면 같은 메커니즘으로 재분석·재덮어쓰기가 반복된다. 테스트(test_sqlite_store_switches_active_queue_when_importing_from_different_folders)는 서로 다른 id 만 사용해 이 케이스를 커버하지 않는다. 알려진 M8(id=path.stem 유지, image_store stem-충돌 접미어 되돌림)과 인접하지만, M8 은 한 폴더 내 stem 충돌 항목이고 이 건은 seed 의 큐-단위 중복 판정 vs 전역 PK 업서트 불일치라는 별도 경로다.
- 제안 수정: seed 에서 records 필터 시 큐 무관 전역 id 존재 여부를 함께 조회해, 다른 큐에 같은 id 가 살아 있으면 (a) 신규 id 로 접미어를 부여해 삽입하거나 (b) 해당 레코드를 건너뛰고 감사 이벤트로 충돌을 기록한다. 최소한 덮어쓰기 전 기존 행의 queue_id 가 다르면 기존 evidence 를 삭제/이관하고 판정 상태를 보존해야 한다.
## 2. MEDIUM 발견 (15건)
- **EXIF Orientation을 어디서도 적용하지 않음 — 회전 저장된 스마트폰 사진의 얼굴 미탐 + 외부 파생물이 옆으로 누운 채 태그까지 제거되어 전송** — `src/rights_filter/analysis/preprocessing.py:139`
- 파이프라인 전체가 EXIF Orientation을 무시한다: (1) face_person_detection.py:40 cv2.imdecode는 EXIF를 적용하지 않으므로(imread와 달리) Orientation 6/8로 저장된 세로 폰 사진의 얼굴이 90° 누운 픽셀로 Haar frontal/profile 캐스케이드에 들어가 대부분 미탐 → FACE_PERSON 증거(+35점)·얼굴 크롭·face-crop 웹 검색이 전부 누락. (2) _reencode_without_metadata(preprocessing.py:139–153)는 ImageOps.exif_transpose 없이 재인코딩하면서 Orientation 태그를 포함한 EXIF를 제거하므로, 외부 API(Google Vision)에는 회전 보정 불가능한 옆으로 누운 이미지가 전송돼 웹 매칭 품질이 저하된다. (3) pHash도 원시 방향 픽셀로 계산되므로 웹에서 가져온(대개 방향이 구워진) 검색 결과 이미지와의 유사도 비교(_sync_search_result_image_similarity)가 회전 불일치로 미탐된다. 스마트폰 원본 사진은 Orientation 태그 저장이 일반적이라 발생 빈도가 높
- 수정: 디코드 직후 한 곳에서 방향 정규화: _decode 경로(face_person_detection)·_reencode_without_metadata·_perceptual_hash에서 PIL 열기 시 ImageOps.exif_transpose(image) 적용(재인코딩은 이미 태그를 제거하므로 transpose 후 저장이면 충분). cv2 경로는 PIL 폴백을 우선하거나 EXIF Orientation을 읽어 cv2.rotate로 보정.
- **'phash'가 실제로는 8x8 평균 해시(aHash)라 균일/저디테일 이미지끼리 유사도 1.0으로 충돌 — 지식베이스·검색결과 유사도 +80 고위험 오탐** — `src/rights_filter/analysis/fingerprints.py:47`
- _perceptual_hash(fingerprints.py:34–51)는 DCT 기반 pHash가 아니라 8x8 평균 해시다. 픽셀이 평균과 같으면 비트 1이 되므로(50행 `pixel >= average`) 완전 균일 이미지는 색과 무관하게 전부 0xffffffffffffffff로 해시된다 — 검은 단색과 흰 단색이 유사도 1.0. 저디테일 이미지(흰 배경 제품컷, 플레이스홀더/빈 썸네일, 단순 로고)끼리도 상위 비트 대부분이 일치해 0.9 임계(internal_analyzer.py:19 threshold, risk_scoring.py:20 `similarity >= 0.9` → +80)를 쉽게 넘는다. 결과: (1) 지식베이스에 배경이 밋밋한 샘플이 있으면 무관한 플랫 이미지 제출물이 'Knowledge base image similarity 1.00' +80 → 즉시 고위험 밴드 오탐. (2) 검색 결과 이미지 유사도(store_search_candidates.py:386, provider_runtime 임계 기반)도 빈/플레이스홀더 썸네일과 플랫 제출물 간 허위 매치 생성.
- 수정: 최소 방어로 해시 엔트로피 가드 추가(비트 popcount가 0/64에 가까우면 — 예: ≤4 또는 ≥60 — 유사도 비교에서 제외하거나 0 반환). 근본적으로는 DCT pHash(32x32→8x8 DCT 저주파) 또는 dHash로 교체하고 명칭 'phash' 접두어를 실제 알고리즘과 일치시켜 오해를 제거. 임계 0.9는 알고리즘 교체 후 재보정.
- **재분석 시 검출 결과가 달라지면 구 FACE_PERSON/유사도 증거가 남은 채 신규 행이 추가되고 스코어러에 FACE_PERSON 중복 제거가 없어 +35가 중첩** — `src/rights_filter/server/store_enrichment.py:292`
- _rerun_internal_analysis(store_enrichment.py:288–295)는 analyze() 결과를 _put만 하고 이전 internal 증거를 무효화하지 않는다. 증거 id는 (source, reason, data) 해시(store_serialization.py:410–412)이므로 face_count가 바뀌면(데이터 dict 변경) 새 행이 생기고 구 행은 active로 남는다. RiskScorer의 FACE_PERSON 분기(risk_scoring.py:26–28)는 WEB_DETECTION/NAVER처럼 버킷 dedup이 없어 행마다 +35를 더한다 — 얼굴만 있는 무해한 사진이 재분석 1회로 35(중간)→70(고위험 경계)이 된다. reason은 동일 문자열이라 _unique로 합쳐져 운영자에게는 사유 1줄에 점수만 뛴 것으로 보인다. 지식베이스 유사도 증거도 reason에 유사도 2자리가 박혀('similarity 0.93' vs '0.91') 같은 방식으로 중복 +80이 쌓인다(상한 100이라 밴드 영향은 제한적). 트리거: 운영자가 동일 파일명으로 이미지를 교체 후 재분석(id=path.stem 유지 — M8), 또는 O
- 수정: _rerun_internal_analysis에서 신규 증거 _put 전에 해당 제출물의 source in {fingerprint(knowledge/face 관련), face} 기존 internal 증거를 superseded 상태로 전환하거나 삭제(LLM 요약의 _delete 패턴 재사용). 또는 RiskScorer FACE_PERSON에 단일 버킷을 도입해 행 수와 무관하게 1회만 +35.
- **LLM_SUMMARY 저장 게이트가 프로덕션에서 호출되지 않고, 실제 검증은 항상 참이 되도록 구현되어 정책이 무력화됨** — `src/rights_filter/governance/policies.py:110`
- assert_operator_evidence_payload_allowed()의 LLM_SUMMARY 분기(source_urls/source_evidence_ids 없는 요약 저장을 ValueError로 금지, test_policies.py:47이 이를 고정)는 src 전역에서 DataClass.LLM_SUMMARY로 호출되는 곳이 한 곳도 없다(호출은 전부 SEARCH_EVIDENCE: naver_search.py:330,386,439 / google_custom_search.py:238,292). 실제 저장 경로는 두 갈래 모두 게이트를 우회한다: (a) analysis/llm_assistance.py:81-93은 근거 없는 요약을 verified=False Evidence로 만들어 반환하고, server/store_operations.py:406-411(_ensure_llm_summary)은 게이트 호출 없이 그대로 evidence 테이블에 저장한다. (b) 프로덕션에서는 _domain_evidence_from_ui(store_serialization.py:319)가 모든 source evidence에 evidence_id(sqlite 저장 id, _ev
- 수정: _ensure_llm_summary 저장 직전에 assert_operator_evidence_payload_allowed(DataClass.LLM_SUMMARY, llm_evidence.data) 호출을 추가하고, verified 판정은 evidence_id 존재가 아니라 URL 근거(source_urls) 유무 기준으로 좁히거나 정책 함수 쪽 주석/테스트를 실제 완화된 규칙(verified 플래그 저장 허용)으로 갱신해 정의-런타임 불일치를 해소.
- **admin 결정·정정·프레젠터 계층 전체가 운영 서버에 미연결된 병렬 구현으로 남아 실서버 동작과 의미가 갈라짐** — `src/rights_filter/admin/review_handlers.py:42`
- record_operator_decision, correct_rejected_decision, detailed_review_for, register_manual_entry, operator_summary_for의 호출자는 tests/ 뿐이며(프로젝트 전역 grep 확인) 운영 서버는 server/store_operations.py에 별도 구현을 갖는다. 이미 의미가 갈라져 있다: (a) admin 계층은 REJECTED에서만 지식 엔트리를 만들고 HELD는 아무것도 만들지 않지만, 실서버는 held/rejected 모두 watchlist 엔트리를 upsert한다(store_operations.py:170-176). (b) admin 계층은 결정·정정 시 감사 이벤트를 전혀 남기지 않지만 실서버는 add_audit_event를 기록한다. (c) admin 계층은 반려마다 새 id의 엔트리를 무한 생성하지만(아래 별도 발견) 실서버는 _stable_id로 1개를 upsert한다(store_operations.py:151). 트리거: 2026-05-25 plan 문서가 이 계층을 '타깃 admin 앱이 그대로 렌더/이식할 계약(contract)'으로 규정하고 있어
- 수정: admin 계층을 (1) 삭제하고 테스트를 실서버 경로로 이관하거나, (2) 유지한다면 각 모듈 docstring에 '운영 서버 미사용 — store_operations.py가 사실 소스' 경고를 명시하고 HELD/감사 의미를 실서버와 일치시킬 것.
- **Google Vision 호출이 정책 거부(EXTERNAL_SKIPPED)로 실행되지 않아도 사용량 +1 계상되고, 생체 이미지가 '외부 전송됨'이라는 허위 감사 이벤트가 기록됨** — `src/rights_filter/server/store_enrichment.py:398`
- CloudVisionWebDetectionAdapter.detect는 policy.can_call()이 거부하면 HTTP 요청 없이 EXTERNAL_SKIPPED evidence만 반환한다(cloud_vision_web_detection.py:108-117). 그런데 _rerun_google_image_search는 무조건 call_count = 1로 시작하고(317), _google_face_crop_web_detection은 detect 결과를 보지 않고 크롭마다 call_count += 1 한다(398). 두 카운트 모두 _apply_provider_usage_delta('google', ...)로 usage에 가산된다(351). 반면 _auto_naver_search/_auto_google_custom_search는 'any(item.source != SEARCH_SKIPPED)'로 실제 요청 여부를 확인한 뒤에만 계상한다(469, 645). 더 나쁜 것은 face crop 경로에서 call_count>0이면 'Personal image sent to external provider' 감사 이벤트가 기록되는데(415-425), 정책이 거부해 크롭이 실
- 수정: naver/google_custom 경로와 동일하게 `any(item.source != EvidenceSource.EXTERNAL_SKIPPED for item in ...)`일 때만 call_count를 증가시키고, 감사 이벤트도 실제 전송된 크롭 수 기준으로 기록한다.
- **manual_search·collect_keyword_candidates의 lastFailure에 실패 항목이 아닌 domain_evidence[0](대개 성공 결과)의 reason이 기록됨** — `src/rights_filter/server/store_operations.py:359`
- manual_search(359)와 collect_keyword_candidates(660)는 evidence 중 하나라도 SEARCH_SKIPPED/ENRICHMENT_FAILURE면 `provider_payload['lastFailure'] = domain_evidence[0].reason`을 기록한다. 그러나 domain_evidence[0]은 캐스케이드의 첫 항목(주 검색의 첫 성공 결과)이지 실패 항목이 아니다. _auto_naver_search(823-830)와 _auto_google_custom_search(581-588)는 `next(item.reason for item in ... if item.source in 실패집합)`으로 올바르게 실패 항목의 reason을 찾는 것과 대비된다.
- 수정: auto 경로와 동일하게 next()로 실패 소스 항목의 reason을 선택한다.
- **collect_keyword_candidates: 검색이 전부 쿼터 스킵/실패해 수집 결과가 0건이어도 기존 collection_candidates 전량 무조건 삭제** — `src/rights_filter/server/store_operations.py:667`
- 수집 트랜잭션은 `delete from collection_candidates`를 무조건 실행한 뒤 pending을 재삽입한다(666-669). 검색 캐스케이드가 전부 SEARCH_SKIPPED(쿼터 소진)나 실패로 끝나면 pending이 비어 있으므로, 운영자가 이전에 수집해 검토 중이던 후보 목록(promoted 이력 행 포함)이 대체 결과 없이 전량 삭제된다. '이전 쿼리의 후보를 새 수집으로 교체'하는 설계 의도(596-599 주석)는 새 결과가 있을 때만 성립하는데, 실패 경로에서도 동일하게 청소가 실행된다. 특히 쿼터 소진 시점에는 재수집으로 복구할 수도 없다.
- 수정: pending이 비어 있고 provider_call_count가 0(또는 전부 스킵)이면 삭제 없이 오류/빈 결과만 반환하거나, 최소한 스킵으로 인한 0건일 때는 기존 행을 보존한다.
- **케이스 전환 시 결정 메모·메모 오류 미초기화 — 타 케이스 메모가 반려/보정 사유로 기록됨** — `web/operator-gui/app.js:2343`
- selectCase()는 suggestedQueryRunSummary만 리셋하고 #decision-memo textarea와 #memo-error를 초기화하지 않는다. setDecision()은 전송 성공 후에만 memo를 비우므로(app.js:2408), 운영자가 케이스 A의 반려 사유를 적다가 제출 전에 큐에서 케이스 B를 클릭하면 A용 메모가 그대로 남아 있고, B에서 '반려'를 누르면 requiresMemo 검증을 A의 메모가 통과시켜 케이스 B의 결정 레코드·감사 로그에 A의 사유가 영구 기록된다.
- 수정: selectCase()에서 document.getElementById('decision-memo').value = '' 와 memo-error 초기화를 추가.
- **추천 쿼리 실행 요약이 스킵/실패/결과없음 행을 '근거 N건 완료'로 오보고 (skipped 분기 dead code)** — `web/operator-gui/app.js:1048`
- executeSuggestedQueries()는 응답 queryHistory[0].count를 근거 수로 보고하는데, 백엔드 count는 len(domain_evidence)로 SEARCH_SKIPPED(쿼터 차단)·ENRICHMENT_FAILURE·'returned no results' 마커 Evidence 행을 모두 포함한다(store_operations.py:344-353). naver 어댑터는 어떤 경우에도 최소 1개의 마커 행을 반환하므로(naver_search.py:166-179 스킵, 292-306 결과없음) resultCount는 항상 ≥1이고, resultCount===0일 때만 실행되는 skipped 감지 분기(app.js:1049-1052)는 사실상 도달 불가다. 결과: 네이버 쿼터가 소진된 상태에서 '모두 실행'을 누르면 이미지→블로그→웹문서 3연쇄 스킵으로 count=3이 되어 요약에 '"쿼리" 완료(근거 3건)'이 표시되고, 결과가 실제로 없어도 '완료(근거 1건)'으로 표시된다. 판정 보조 도구가 근거 확보 성공을 허위로 보고하는 셈이다. 쿼리 이력 행의 'N건' 표시(app.js:1857)도 같은 의미 왜곡을 공유한다.
- 수정: review.evidence에서 해당 query와 일치하는 항목 중 source!=='failure'이고 'returned no results'가 아닌 실제 근거만 세어 보고하거나, 백엔드 count를 실근거 수로 바꾸고 skip/failure 여부를 별도 필드로 반환.
- **'주의 후보 보기' 버튼이 수동 등록 탭으로 이동 — 라벨과 동작 불일치** — `web/operator-gui/app.js:3279`
- 워크벤치 보조 작업의 #add-from-case 버튼 라벨은 '주의 후보 보기'(index.html:217)지만 핸들러 addKnowledgeEntryFromCase()는 knowledge 뷰의 'manual'(수동 기준 등록) 탭으로 이동한다. watchlist(주의 후보) 항목은 '등록된 기준' 탭에서 상태 필터로 보는 구조이므로, 운영자는 주의 후보 목록 대신 빈 수동 등록 폼을 보게 된다. 함수명이 원래 용도(케이스에서 기준 등록)를 드러내는 것으로 보아 라벨 개편 시 핸들러 목적지가 갱신되지 않은 것이다.
- 수정: switchKnowledgeTab('registered') + knowledgeFilters.status='watchlist' 설정 후 renderKnowledgeBase() 호출(및 #knowledge-status-filter 값 동기화)로 변경하거나, 버튼 라벨을 실제 동작에 맞게 되돌린다.
- **JSON 바디가 객체가 아니면(리스트/문자열/null) 핸들러가 AttributeError로 크래시 — 응답 없이 연결 종료** — `src/rights_filter/server/http_app.py:341`
- _body()는 json.loads() 결과를 dict 검증 없이 그대로 반환한다(-> dict[str, object] 타입 표기만 있고 실제 검사 없음). 유효한 JSON이지만 객체가 아닌 바디(`[]`, `"x"`, `123`, `null`)를 보내면 do_POST/do_PATCH의 body.get(...) 또는 store.register_manual_knowledge_entry(body) 등에서 AttributeError가 발생하는데, 핸들러는 _PayloadTooLarge/OperationalError/KeyError/ValueError만 잡으므로 예외가 socketserver까지 전파되어 클라이언트는 HTTP 응답 없이 연결이 끊기고 서버 콘솔에는 트레이스백이 찍힌다. 잘못된 JSON 문법은 400으로 처리되는 것(test_malformed_json_body_returns_400)과 비대칭인 경계 검증 구멍.
- 수정: _body()에서 `parsed = json.loads(...)` 후 `if not isinstance(parsed, dict): raise ValueError("JSON object body required")` 추가 — 기존 400 경로로 수렴.
- **submissions.json 매니페스트의 잘못된 행 1개(루트 밖 경로)가 큐 전체의 불러오기/재분석을 ValueError로 마비시킴** — `src/rights_filter/server/image_store.py:38`
- submission_records()의 리스트 컴프리헨션은 각 매니페스트 레코드에 대해 _safe_path(record["file"])를 호출하는데, 파일이 없는 레코드는 .exists() 필터로 조용히 건너뛰면서 루트 밖을 가리키는 레코드는 _safe_path가 ValueError("manifest path points outside image store")를 던져 전체 호출이 실패한다. submissions.json은 운영자가 손으로 편집하는 문서화된 워크플로(docs/operations/copyrighter-operation-worklist.md:129 'submissions.json에 ID, 제목… 직접 등록')이므로, 절대 경로(예: 원본 위치 C:\Users\...\a.jpg)를 붙여넣는 실수 하나로 '새 제출 불러오기'(/api/submissions/reload·upload-image·import-folder 경유 seed_from_image_store)와 rerun-enrichment(image_payload)가 모두 400으로 실패한다. 에러 메시지에 문제 레코드가 명시되지 않아 어느 행이 원인인지도 알 수 없다 — 결함 행만 스킵하는 기존
- 수정: 컴프리헨션 대신 루프에서 레코드별 try/except ValueError로 결함 행을 스킵(로그로 해당 record["file"] 명시)하고, width/height도 안전 변환(int 실패 시 1) 적용.
- **README 대체 실행 커맨드 `python -m rights_filter.server` 가 그대로는 실행 불가 (ModuleNotFoundError)** — `C:/Users/USER/Desktop/munsang/copyrighter/README.md:38`
- README 실행 섹션이 `python run_copyrighter_server.py` 의 대안으로 `python -m rights_filter.server --host 127.0.0.1 --port 9500` 을 안내하지만, 프로젝트는 src/ 레이아웃이고 pyproject.toml/setup.py/setup.cfg 가 없으며 rights_filter 는 어떤 site-packages 에도 설치되어 있지 않다. 따라서 repo 루트에서 이 커맨드를 그대로 실행하면 즉시 ModuleNotFoundError 로 실패한다. PYTHONPATH=src 가 필요하다는 언급이 문서 어디에도 없어, 문서만 보고 따라 하는 운영자/납품 검수자가 실행 실패를 겪는다. 나머지 문서 항목은 정확함: CLI 옵션 6종(--host/--port/--db/--images/--static/--env)은 src/rights_filter/server/__main__.py:28-33 과 일치, .env.example 43개 변수는 src 코드 참조와 양방향 완전 일치(코드에만 있거나 예시에만 있는 변수 0건), Pretendard 폰트 번들(web/operator-gui/assets/fo
- 수정: README.md:38 을 `PYTHONPATH=src python -m rights_filter.server ...` (Windows: `set PYTHONPATH=src`) 로 수정하거나, 해당 대안 커맨드 줄을 제거하고 `python run_copyrighter_server.py` 에 동일 옵션을 전달하는 예시로 통일.
- **Vision 응답 매핑이 어댑터 try 경계 밖 — 비정형 bestGuessLabels 시 쿼터 차감 후 요청 전체 중단** — `src/rights_filter/integrations/cloud_vision_web_detection.py:134`
- CloudVisionWebDetectionAdapter.detect 는 client.detect_web 만 try 로 감싸고, policy.record_call() 후의 _map_response() 는 try 밖에서 실행한다. detect_web 이 정규화하는 필드들과 달리 best_guess_labels 는 API 응답의 bestGuessLabels 를 원본 그대로 통과시키므로(97행), 리스트에 dict 가 아닌 항목이 섞이면 _map_response 211행 `label.get("label", "")` 에서 AttributeError 가 발생해 어댑터 밖으로 전파된다. 호출부 _rerun_google_image_search(store_enrichment.py:312)에도 국소 try 가 없어 enrichment 요청 전체가 중단되며, 쿼터는 이미 record_call 로 차감된 상태다. google_custom_search.py:125-126 은 정확히 이 실패 모드를 막으려고 매핑을 try 안으로 옮겼다는 주석까지 있는데(‘Map inside the try so a malformed payload becomes ENRICHMENT_FAILURE ins
- 수정: google_custom_search 와 동일하게 record_call 후 매핑을 try 안으로 이동하거나, detect_web 에서 bestGuessLabels 도 dict 항목만 필터링해 정규화한다.
- **Google Vision 실패/스킵 증거가 provider 에 귀속되지 않아 providerState 가 '미실행'으로 오표시됨** — `src/rights_filter/server/store_serialization.py:281`
- CloudVisionWebDetectionAdapter 는 FAILURE/EXTERNAL_SKIPPED 증거의 data 에 submission_id 만 넣는다(cloud_vision_web_detection.py:110-129). _evidence_payload 의 domain 은 data.get("provider", data.get("domain", "internal")) 폴백이라 이 증거들의 UI payload 가 source="failure", domain="internal" 로 저장된다. _evidence_matches_provider(store_serialization.py:183-194)의 google 분기는 source == "google" 을 요구하므로 Vision 호출 실패 증거는 어느 provider 에도 매칭되지 않는다(네이버는 domain, google_search 는 domain=google_custom_search 로 매칭돼 실패도 귀속되는 것과 비대칭). 결과: (1) _external_provider_state_for_submission 이 매칭 증거 0건으로 'not_run' 을 반환해 Vision API 가 실제로 실패한 제출
- 수정: cloud_vision_web_detection.py 의 FAILURE/EXTERNAL_SKIPPED 증거 data 에 "provider": "google" 을 포함시키거나, _evidence_matches_provider 의 google 분기에서 source == "failure" and domain == "google" 도 매칭하도록 확장(기존 저장 행 호환을 위해 후자 병행 권장).
## 3. LOW 발견 (21건)
- **프로모터가 result_url을 마커 스캔에 포함해 URL 경로/호스트 토큰(game, cover, album 등)만으로 무관한 결과를 0.8 승격 → +40점** — `src/rights_filter/analysis/search_result_promoter.py:32`: _promote_item의 마커 스캔 텍스트에 result_url이 포함된다(search_result_promoter.py:32–35). ASCII 마커는 \b 경계 매칭인데 URL에서는 '.'과 '/'가 단어 경계라 'game.naver.com', '/cover/', '/album/', '/character/' 같은 흔한 경로·호스트 토큰이 마커 'game'/'cover'/'album'/'character'에 걸린다. 본문·제목에 권리 신호가 전혀 없는 결과도 URL 구조만으로 promoted=true, confidence 0.8이 되어 _naver_search_points에서 round(50*0.8)=+40점과 'n
- **record_operator_decision 반복 반려 시 동일 제출물의 rejected_image 지식 엔트리가 무한 누적** — `src/rights_filter/admin/review_handlers.py:56`: 동일 submission_id에 REJECTED 결정을 재기록할 때마다(재검토, 정정 후 재반려 등) create_rejected_image_entry(domain/records.py:242-253)가 매번 새 id('knowledge-N')의 엔트리를 무조건 생성한다. 기존 활성 rejected_image 엔트리 존재 여부를 확인하지 않아 동일 지문의 활성 엔트리가 중복 축적되고, internal_analyzer(analysis/internal_analyzer.py:42-68)는 엔트리별로 별도 FINGERPRINT evidence를 만들어 동일 매치가 N중으로 표시된다(RiskScorer의 FINGERPRINT 경로
- **detailed_review_presenter가 정책상 정상인 스킵(EXTERNAL_SKIPPED/SEARCH_SKIPPED)을 'failures' 증거 그룹으로 오분류** — `src/rights_filter/admin/detailed_review_presenter.py:64`: _group_name()이 명시 열거되지 않은 모든 소스를 'failures'로 귀속시켜, 쿼터 도달·제공자 비활성 같은 의도된 운영 스킵(EXTERNAL_SKIPPED, SEARCH_SKIPPED)이 실패 증거 그룹에 표시된다. 같은 항목이 _provider_status()에도 다시 나타나 이중 표기된다. 실서버 스코어러는 동일 소스를 '위험 신호 아님·운영 상태'로 명시 구분하는 것(analysis/risk_scoring.py:70-72 주석 및 0점 처리)과 상반된 표시 의미론이다. 트리거: 이 프레젠터 출력을 렌더링하는 소비자(계약상 타깃 admin UI)는 '외부 API 비활성'을 분석 실패로 오인해 불필요한
- **register_manual_entry가 **payload 추가 인자를 검증 후 조용히 폐기** — `src/rights_filter/admin/knowledge_base_handlers.py:23`: **payload로 받은 추가 키는 assert_no_biometric_template 검사에만 쓰이고 엔트리 생성(create_manual_knowledge_entry)에 전달되지 않아 무예외로 소실된다. 트리거: 호출자가 명명 인자 오타(예: policy_note=... 또는 alias=...)를 내면 TypeError 대신 payload로 흡수돼 해당 데이터가 등록 엔트리에서 조용히 누락 — 운영자는 메모/별칭이 저장됐다고 오인한다. 현재 테스트 전용 경로라 LOW.
- **수집 후보 카운트 인플레이션: 동일 candidate_id가 pending에 중복 적재되어 collected 수가 실제 저장 행 수보다 크게 보고됨** — `src/rights_filter/server/store_operations.py:615`: candidate_id는 _stable_id('cand', provider, type, query, image_url, thumbnail_url, result_url)로 생성되며 rank/title이 포함되지 않는다(store_search_candidates.py:615). 같은 이미지 URL이 검색 결과 여러 순위에 등장하면(검색 결과에서 흔함) 동일 id의 후보가 pending에 2회 이상 적재되어 collected가 그만큼 증가하지만, 트랜잭션의 _put은 upsert라 실제로는 1행만 저장된다. GUI에 반환되는 payload['collected']와 감사 이벤트의 'N candidates'가 실제 후보 목록 행
- **케이스 없음 렌더 시 '근거 보강 추천' 패널 잔존 (evidence-next-actions 미초기화)** — `web/operator-gui/app.js:1309`: renderNoSelectedCase()는 file-facts·similar-strip·evidence-groups 등은 비우지만 #evidence-next-actions는 초기화하지 않는다. 제출 목록이 비게 되면(빈 폴더로 import-folder 전환, 또는 초기 API 실패 후 clearRuntimeData) '표시할 케이스가 없습니다' 옆에 이전 케이스의 추천 쿼리 패널('바로 실행'/'모두 실행' 버튼 포함)이 그대로 남는다. 버튼을 눌러도 getSelectedCase()가 undefined라 아무 일도 일어나지 않아 죽은 컨트롤이 노출된다.
- **hashchange 경유 #corrections 진입 시 지식 탭 패널이 전환되지 않음** — `web/operator-gui/app.js:4013`: hashchange 리스너는 switchView()만 호출한다. switchView('corrections')는 state.knowledgeTab='corrections'로 상태만 바꾸고 switchKnowledgeTab()을 호출하지 않아(app.js:2229-2235), 페이지 리로드 없이 URL 해시를 #corrections로 바꾸면 knowledge 뷰는 열리지만 탭 패널은 기존 탭(기본 '후보 수집')에 머문다. 이후 상태 변수(knowledgeTab)와 실제 표시 패널이 어긋난다. 최초 로드 시에는 init()이 마지막에 switchKnowledgeTab을 호출해 정상이다.
- **수동 검색 완료 메시지가 케이스 전환 후 도착하면 다른 케이스 문맥으로 표시** — `web/operator-gui/app.js:2492`: runManualSearch()는 시작 시점의 케이스에 결과를 귀속시키지만(정상), 응답 도착 시 '쿼리 결과가 현재 케이스 증거에 추가되었습니다'를 #manual-query-status(케이스별로 리렌더되지 않는 고정 엘리먼트)에 쓴다. 검색은 외부 API+LLM 요약으로 수 초 걸리므로 그 사이 운영자가 케이스 B로 전환하면, B의 검색 이력 탭을 보는 중에 A에 추가된 결과를 '현재 케이스'에 추가했다는 메시지가 표시되고 B용으로 입력하던 쿼리 입력값도 지워진다(queryInput.value=''). selectCase()가 manual-query/manual-query-status를 리셋하지 않는 것도 같은 잔존
- **비ASCII Authorization 헤더가 hmac.compare_digest TypeError를 유발 — 401 대신 무응답 연결 종료 (인증 전 원격 트리거)** — `src/rights_filter/server/http_app.py:291`: COPYRIGHTER_AUTH_TOKEN이 설정된 상태에서 보호 라우트로 비ASCII 바이트가 포함된 Authorization 헤더를 보내면(http.server는 헤더를 latin-1로 디코드하므로 0x80-0xFF 바이트가 비ASCII str이 됨) hmac.compare_digest(str, str)가 TypeError('comparing strings with non-ASCII characters is not supported')를 던진다. 이 예외는 _require_auth 어디에서도 잡히지 않아 클라이언트는 401 대신 빈 응답(연결 리셋)을 받고 서버 콘솔에 트레이스백이 반복 출력된다. 인증 없이 원격에서
- **미디어/정적 파일 서빙 중 OSError(파일 잠금·삭제 레이스·권한) 미처리 — 404/503 대신 연결 종료** — `src/rights_filter/server/http_app.py:377`: _file()은 path.exists() 확인 후 path.read_bytes()를 호출하는데, 그 사이 파일이 삭제되거나(face-crop 보존기간 purge, 운영자 폴더 정리) Windows에서 다른 프로세스가 파일을 배타 잠금한 경우(대용량 이미지를 data/submissions로 복사하는 중 GUI가 /media 폴링, 백신 스캔 등) PermissionError/OSError가 발생한다. do_GET은 ValueError/KeyError/OperationalError만 잡으므로 예외가 전파되어 응답 없이 연결이 끊기고 트레이스백이 출력된다. 운영 콘솔에서는 이미지 타일이 깨진 채 원인 없는 네트워크 오류로 보
- **.env 값 뒤 인라인 주석(# ...)이 값에 그대로 포함됨 — 토큰/키가 조용히 오염** — `src/rights_filter/server/env_file.py:23`: load_env_file은 `KEY=value # comment` 형식에서 `# comment`를 값의 일부로 저장한다(일반 dotenv 구현은 비인용 값 뒤 인라인 주석을 제거). 운영자가 `COPYRIGHTER_AUTH_TOKEN=abc123 # 운영용` 처럼 주석을 붙이면 토큰이 'abc123 # 운영용'이 되어 GUI 토큰 입력이 이유 없이 401로 실패하고, API 키면 외부 검색이 인증 오류로 실패한다 — 경고·에러 없이 조용히 어긋나므로 원인 추적이 어렵다. 인용값+주석(`"abc" # x`)이면 첫/끝 문자가 달라 따옴표 제거도 무산되어 따옴표까지 값에 남는다. .env.example에는 인라인 주석이 없
- **루트 미추적 Microsoft/Windows/PowerShell/ModuleAnalysisCache — PowerShell 캐시 잔재, ignore 미커버로 커밋 오염 위험** — `C:/Users/USER/Desktop/munsang/copyrighter/Microsoft/Windows/PowerShell/ModuleAnalysisCache:?`: 정체: PowerShell 모듈 분석 캐시(6,072바이트 바이너리, 2026-07-11 08:19 생성, 디렉터리 내 유일 파일). 정상 위치는 %LOCALAPPDATA%\Microsoft\Windows\PowerShell\ModuleAnalysisCache 인데, PSModuleAnalysisCachePath 가 상대경로로 잡혔거나 LOCALAPPDATA 가 비어 있는 환경에서 powershell 이 당시 cwd(repo 루트) 기준으로 캐시를 쓴 잔재다(생성일이 7/11 커밋 작업일과 일치 — 자동화 도구가 환경변수 없이 powershell 을 띄운 흔적). 문제: git status 에 ?? Microsoft/
- **__pycache__ stale: 테스트 pyc 19개가 옛 경로 Desktop\complete\copyrighter 를 내장, mtime 보존으로 pytest 가 계속 재사용** — `C:/Users/USER/Desktop/munsang/copyrighter/tests/rights_filter/admin/__pycache__/test_correction_handlers.cpython-313-pytest-8.4.2.pyc:?`: tests/ 하위 19개 pytest assertion-rewrite 캐시(.cpython-313-pytest-8.4.2.pyc)가 co_filename 으로 이전 프로젝트 경로 `C:\Users\USER\Desktop\complete\copyrighter\...` 를 내장하고 있다(src/ 쪽 pyc 는 0건 — 이후 재생성됨). 소스 .py 와 pyc 의 mtime 이 동일(예: 둘 다 5/25 09:43)해 프로젝트 이동 시 mtime 이 보존되었고, pytest 캐시 유효성 검사(소스 mtime+size)가 통과하므로 캐시가 계속 재사용된다. 트리거 시나리오: 해당 19개 파일(test_naver_search,
- **루트 tmp_* 잔재 4건 — git 미추적(ignore 정상), 로컬 정리만 필요** — `C:/Users/USER/Desktop/munsang/copyrighter/tmp_fail3.sqlite3:?`: tmp_dbg_img/(images/ + submissions.json, 6/10), tmp_fail3.sqlite3(106KB, 5/27), tmp_server_err.log·tmp_server_out.log(둘 다 0바이트, 5/28) 네 건 모두 git 추적되지 않고 .gitignore 가 정확히 커버한다(tmp_* 및 tmp_dbg_img/ 패턴, *.sqlite3·*.log 로도 이중 커버). 저장소 오염 없음 — 순수 로컬 디스크 클러터로 삭제해도 무방하다. 유일한 주의점: tmp_fail3.sqlite3 는 5/27 실패 재현용 DB 스냅샷으로 보이므로 아직 참조하는 디버깅 절차가 없는지 확인 후 삭제.
- **data/logs/edge-profile-static/ 612엔트리·27MB 브라우저 프로필 — git 미추적 확인(data/ 전체 ignore), 디스크 정리 대상** — `C:/Users/USER/Desktop/munsang/copyrighter/data/logs/edge-profile-static:?`: data/logs/edge-profile-static/ 는 Edge 브라우저 프로필 전체(Default/, extensions_crx_cache/, Local State, Variations 등 총 612 엔트리, 27MB)로 2026-05-27 00:33 브라우저 자동화(E2E/스크린샷) 실행이 남긴 잔재다. 우려했던 git 추적 오염은 없음: .gitignore:23 의 `data/` 가 디렉터리 전체를 커버하고 `git ls-files data/` 는 0건이다. 따라서 저장소 위생 문제가 아니라 27MB 로컬 디스크 점유 문제이며 삭제해도 무방하다(다음 브라우저 실행 시 재생성). 참고: 브라우저 프로필에는 세션
- **HTTPError 응답 본문 읽기에 크기 캡 미적용 — 에러 경로에서 10MiB 캡 우회** — `src/rights_filter/integrations/http_json.py:55`: 성공 경로는 response.read(_MAX_RESPONSE_BYTES + 1) 로 10MiB 캡을 강제하지만, HTTPError 처리(53-58행)의 `exc.read()` 는 무제한으로 전체 본문을 메모리에 적재한다. 이 캡은 커밋 이력상 '적대적/오동작 API 의 메모리 고갈 방지' 목적으로 도입됐는데(17행 주석) 4xx/5xx 응답에는 적용되지 않아 위협 모델이 절반만 커버된다. 또한 읽은 detail 전문이 HttpJsonError 메시지 → Evidence.reason 으로 그대로 흘러가 초대형 에러 본문이 DB/GUI 에 저장될 수 있다.
- **SearchApiPolicy 쿼터 check-then-act 비원자 — 동시 요청 버스트 시 daily_limit 초과 가능** — `src/rights_filter/integrations/search_policy.py:30`: can_call() 과 record_call() 은 각각 락을 잡지만 예약(reserve) 개념이 없어, can_call 통과 → 외부 HTTP 요청(수 초) → record_call 사이 창에서 다른 스레드가 같은 잔여 쿼터로 can_call 을 통과할 수 있다. 잔여 1건 상태에서 N개의 동시 enrichment/수동 검색이 모두 통과하면 daily_limit 를 N-1 만큼 초과해 실제 과금/제공자 쿼터를 소모한다. 연기 항목 #12(store_operations rerun_enrichment/run_auto_search 락 부재)와 결합하면 실제로 도달 가능한 경로이나, 정책 객체 자체의 결함이므로 별도 기재한다
- **_probe_connect 가 시스템 프록시를 우회 — 프록시 필수망에서 원격 Ollama 오탐 실패** — `src/rights_filter/integrations/http_json.py:78`: connect_timeout 설정 시 수행되는 사전 TCP 프로브가 socket.create_connection 으로 대상 호스트에 직결을 시도한다. 실제 요청을 수행하는 urlopen 은 HTTP(S)_PROXY 환경변수를 따르므로, 아웃바운드가 프록시로만 허용된 망에서 원격 호스트를 가리키면 urlopen 으로는 성공할 요청이 프로브 단계에서 'connection to host:port failed' 로 전부 실패한다. 현재 connect_timeout 을 쓰는 곳은 llm_assistance.py:35 의 Ollama 클라이언트(기본 127.0.0.1:11434)뿐이라 기본 구성에서는 무해하지만, OLLAMA_BA
- **stdout 리다이렉트 기동 시 시작 진단 로그가 전혀 기록되지 않음 (블록 버퍼링, flush 부재)** — `src/rights_filter/server/__main__.py:41`: __main__.py 의 시작 print 6종(41행 .env 경로, 43행 provider readiness, 51/53행 COPYRIGHTER_AUTH_TOKEN 경고, 60행 static 경고, 73-75행 listening/DB/이미지 경로)이 flush 없이 출력된다. stdout 이 tty 가 아닌 파일로 리다이렉트되면(서비스형 백그라운드 기동, Task Scheduler, `> log` 등) Python 이 블록 버퍼링(8KB)을 적용해 이 ~500바이트가 버퍼에 갇히고, 서버가 정상 가동 중에도 로그 파일이 0바이트로 남는다. 프로세스가 강제 종료되면(비정상 크래시, taskkill) 버퍼가 폐기되어 영구
- **_search_coverage 가 검색 실패 증거만 있는 제출물도 '커버됨'으로 집계** — `src/rights_filter/server/sqlite_store.py:462`: _search_coverage 는 naver/google/google_search 커버리지를 _evidence_matches_provider 매칭 존재 여부만으로 판정한다(sqlite_store.py:462-467). 네이버·구글 맞춤 검색의 실패 증거(source="failure", domain="naver"/"google_custom_search")도 매칭되므로, 해당 provider 검색이 전부 실패한 제출물이 evidenceSubmissions 와 coverageSubmissions(커버리지율)에 '검색 근거 확보'로 포함된다. 같은 함수의 llm 버킷은 _provider_item_has_result 로 실제 결
## 4. 반증되어 기각된 발견 (기록용)
- **검색 결과 title/description이 이스케이프 없이 LLM 프롬프트에 삽입되는 프롬프트 인젝션 표면 — 운영자 요약문 조작 가능** (`src/rights_filter/analysis/llm_assistance.py`) — REFUTED: REFUTED: 인젝션 "유입"은 부분적으로 실재하지만, 발견이 주장하는 핵심 증상 — 조작된 LLM 요약문이 GUI에 운영자 판단 보조로 표시되어 오해를 유발 — 은 현재 코드에서 도달 불가능하다.
1) 조작된 요약문은 운영자에게 절대 표시되지 않음 (핵심 반증):
- LLM 출력은 llm_assistance.py:99-105에서 Evidence.data["summary"]에 담기지만, 영속화 시 store_operations.py:407-411이 _evidence_payload(store_serialization.py:249-309)로 직렬화하는데 이 payload에는 "summary" 키가 존재하지 않는다. 즉 LLM이 생성한 텍스트는 DB에 저장되는 순간 폐기된다. grep 결과 "summary"
- **correct_rejected_decision이 존재하지 않거나 무관한 decision_id에 대해 조용히 성공 형태(빈 리스트)로 반환** (`src/rights_filter/admin/correction_handlers.py`) — REFUTED: REFUTED: 코드 동작 자체는 발견 기술과 일치함을 확인했다: correction_handlers.py:9-14는 repository.operator_decision() 존재 확인도 REJECTED 상태 확인도 없는 단일 위임이고, domain/records.py:227-234는 매칭 엔트리 0건이면 예외 없이 빈 리스트를 반환하며 231-233에서 이미 비활성인 엔트리의 deactivation_reason도 무조건 덮어쓴다. OperatorDecision(records.py:152-168)에 정정 표식 필드도 없다. 그러나 발견이 제시한 트리거 시나리오는 도달 불가능하다. 전 저장소 grep 결과 correct_rejected_decision의 호출자는 tests/rights_filter/admin/t
## 5. 7/11 리메디에이션 체크리스트 현황 재확인
| 항목 | 상태 | 비고 |
|---|---|---|
| #12 rerun_enrichment/run_auto_search 락 | still-open | store_operations.py:202-312 — 두 메가함수 모두 _write_lock 없이 submission RMW(_get→수정→_put) 수행, 락은 manual_search(334)부터만 사용 |
| M4 seed 락 협소화 | still-open | sqlite_store.py:229-234 — seed_from_image_store 전체가 여전히 _write_lock 하나로 감싸져 있음(협소화 미착수, 의도된 연기 상태 유지) |
| M7 store_schema.py 제약 재구축 위반 행 coerce/격리 | still-open | store_schema.py:246-251 — insert..select 단일 복사만 있고 위반 행 coerce/격리 없음, 레거시 위반 행 존재 시 마이그레이션 전체가 raise로 중단(원본은 보존) |
| M9 store_queue.py 레거시 이관 파일 존재 검증 | still-open | store_queue.py:53-61 — _migrate_legacy_submissions_to_queue가 queue_id='' 행을 파일 존재 확인 없이 무조건 새 큐로 update |
| M8 image_store.py stem-충돌 되돌림(incumbent-id 보존 마이그레이션) | still-open | image_store.py:71,81-85 — id=path.stem 복귀 상태 유지, 동일-stem 형제 충돌 및 시드 incumbent-id 보존 마이그레이션 미착수(주석으로 문서화됨) |
| M naver_search.py 멀티페이지 쿼터 실제 요청 수 | fixed | naver_search.py:164-183,206-226,250-270 — _requested_calls(client, '*_pages')를 policy.can_call/record_call에 전달해 멀티페이지가 쿼터에 반영됨(빈 페이지 조기중단 시 계획 페이지 수만큼 보수적 과금) |
| M store_remote_fetch.py SSRF DNS-rebinding TOCTOU | still-open | store_remote_fetch.py:76-111 — getaddrinfo로 검증 후 urlopen이 별도로 재해석(IP 미고정, TOCTOU 창 존재); 리다이렉트 재검증(88-93)은 추가됐으나 rebinding은 미해결 |
| LOW EUC-KR 스니핑/dead code | still-open | store_page_scrape.py:842,850 — decode('utf-8', errors='replace')만 사용, EUC-KR/cp949 스니핑 코드 없음(레포 전체 grep 무매치); dead code는 레이아웃 테스트 고정으로 유지 결정대로 |
| #7 쿠키 분리+Secure | still-open | http_app.py:349-350 — 단일 세션 쿠키에 HttpOnly; SameSite=Strict만 있고 Secure 속성 없음(HTTPS 전제 연기 사유 그대로) |
| #8 rate limit | still-open | http_app.py — rate/throttle/429 관련 코드 전무, 엔드포인트 속도 제한 미구현 |
| #10 Origin/Referer CSRF 폴백 | fixed | http_app.py:303-324 — 쿠키 인증 상태변경 메서드에 _origin_ok() 검사 구현(Origin 우선, Referer 폴백, 둘 다 없으면 허용하는 명시적 설계) |
| #11 잔여 XSS sinks(similar-strip/face-crop safeUrl, innerHTML label) | fixed | app.js:1374(similar-strip img src=escapeHtml(safeUrl)), 1389-1390(face-crop safeUrl), 684·1360 등 label 보간도 escapeHtml 적용 — 잔여 sink grep에서 미이스케이프 동적 label 무발견 |
| #24 window.prompt → 인라인 | still-open | app.js:3433,3449 — 비활성/재활성 사유 입력에 여전히 window.prompt 사용 |
| M21 8개 delegated mutation 공용 in-flight 가드 | still-open | app.js:3772 이하 — toggleProvider/deactivate*/setEvidenceStatus/promote* 등 delegated 핸들러에 공용 가드 없음, in-flight 가드는 setDecision(2358-2418)에만 존재(#17 범위) |
| env_clients.py LLM assistant OLLAMA_* 게이팅 | still-open | env_clients.py:244-250 — _llm_assistant가 OLLAMA_* 미설정이어도 기본값(127.0.0.1:11434)으로 무조건 생성(연기 사유였던 env={} llm 테스트 의존 그대로) |
| package.json 정리 | still-open | package.json은 initial commit 이후 무변경({private,type:module}), node_modules/(@oai, lucide) 잔존 — 정리 미착수 |
| #14 잔여 무음 예외 swallow(page scrape 등) | still-open | store_page_scrape.py:308,386,431 — except Exception: return [] 무로깅 swallow 3곳 잔존(873 fingerprint 검사 포함 시 4곳) |
| #20 ScoreResult.reasons tuple | still-open | src/rights_filter/domain/records.py:75 — reasons: list[str] = field(default_factory=list) 그대로(연기 사유였던 == [] 테스트 단언 의존 유지) |
| checklist 하단 중복 Batch 7/8 섹션(94-107행) | fixed | 체크리스트 94-107행의 [ ] 항목들은 상단 [x] 항목의 낡은 중복 — 예: env_file.py:12-14 utf-8-sig 실적용 확인. 문서 정리만 필요(코드 이슈 아님) |
| spot:H6 store_serialization promoted/promotionReason | fixed | store_serialization.py:269-271 영속화, 340-341 복원 + domainSource(269, 364) 및 레거시 폴백(371) 확인 |
| spot:H2 store_persistence usage delta 병합 | fixed | store_persistence.py:231-246 — _write_lock 안에서 fresh row 재읽기 후 델타 필드만 병합, stale payload 되쓰기 제거 확인 |
| spot:M5 업로드 TOCTOU 배타 생성 | fixed | http_app.py:460-474 — open(candidate, 'xb') 배타 생성 + FileExistsError 시 -N 접미어 재시도 루프 확인 |
| spot:#6 앱셸 CSP/X-Frame-Options/nosniff | fixed | http_app.py:393-411 — 정적/앱셸 응답에 nosniff, X-Frame-Options: DENY, Content-Security-Policy 송출 확인 |
| spot:H9 dismiss 서버 영속화 엔드포인트 | fixed | http_app.py:217-219 — /api/collections/candidates/{id}/dismiss 라우트가 store.dismiss_collection_candidate 호출로 서버 영속화 확인 |
## 6. 런타임 스모크 테스트
서버 런타임 스모크 테스트 완료 (포트 9557, PID 23864, 테스트 후 자체 종료). 9개 검증 항목 전부 통과: 기동 성공, 앱셸 200 + 보안 헤더 3종(CSP/X-Frame-Options/nosniff), 정적 자산 정상 서빙, 무토큰 데이터 라우트 401 거부, 토큰 인증 시 bootstrap(245KB)·review-queue(188KB)·케이스 상세(36KB) 정상 JSON, /media 이미지 서빙 정상(untrusted 미디어 전용 sandbox CSP + no-store + no-referrer 헤더 확인), 미존재 라우트 404, path traversal 전 변형(원문 /../, %2F 인코딩, %2e%2e 인코딩, media/static 양쪽) 거부. 신규 발견 1건(LOW): stdout 을 파일로 리다이렉트해 백그라운드 기동하면 Python 블록 버퍼링 때문에 시작 진단 출력(COPYRIGHTER_AUTH_TOKEN 미설정 경고 포함)이 로그 파일에 전혀 기록되지 않음 — 서버 가동 1분 이상 경과 후에도 로그 0바이트를 실측으로 확인.
| 체크 | 결과 |
|---|---|
| 서버 기동 성공 | PASS — python run_copyrighter_server.py --port 9557 (COPYRIGHTER_AUTH_TOKEN 54자 설정) → PID 23864 이 127.0.0.1:9557 LISTENING, /he |
| GET / 앱셸 200 + 보안 헤더 | PASS — 200 text/html (25695바이트, index.html). X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy(de |
| 정적 자산 로드 | PASS — /app.js 200 text/javascript; charset=utf-8 (103404바이트, 파일 크기와 일치) + 앱셸 CSP/XFO/nosniff 헤더 동일 적용. /styles.css 200 text/cs |
| 토큰 없이 데이터 라우트 거부 | PASS — 무토큰 GET /api/bootstrap → 401 {"error":"unauthorized"}. 무토큰 GET /media/x.png → 401. 보호 프리픽스(/api/, /media/)가 실동작으로 확인됨. |
| 토큰 포함 부트스트랩/큐 GET | PASS — Authorization: Bearer 헤더로 /api/bootstrap 200 (245825바이트, 유효 JSON — submissions 6건, providers 5건, 키: auditEvents/collecti |
| 케이스 상세 GET | PASS — /api/submissions/3/review → 200 유효 JSON (36594바이트, id=3, evidence/queryHistory/providerState/fileFacts 등 12개 키). |
| 이미지 서빙 엔드포인트 | PASS — /media/images/3.jpg → 200 image/jpeg 5056바이트. untrusted 미디어 전용 헤더 확인: nosniff + sandbox CSP(default-src 'none') + Cache- |
| 미존재 라우트 404 | PASS — /api/no-such-route → 404 {"error":"not found"}. |
| path traversal 거부 | PASS — 5개 변형 모두 거부, 파일 유출 없음(size=0): /media/../copyrighter.sqlite3(--path-as-is) → 400, /media/..%2Fcopyrighter.sqlite3 → 400, |
| 기동 프로세스 정리 | PASS — 저장한 PID 23864 를 python 프로세스임을 확인 후 해당 PID 만 Stop-Process. 종료 후 9557 LISTENER 없음(TIME_WAIT 잔여만). 파괴적 POST 미실행 — GET + 앱셸만 |
## 7. 신규 기능 제안 — 심사 통과 상위 5
제안 16건을 심사자 3인(운영자/기술 리드/제품 책임자 렌즈)이 채점. 지문(pHash) 계열 3건은 3인 만장일치로 단일 로드맵 병합 권고.
### ① 키보드 연속 심사 모드 (M)
**키보드 연속 심사 모드** [operator-efficiency]
- 무엇: 케이스 심사 화면에 키보드 단축키 레이어를 추가한다. J/K(또는 방향키)로 현재 큐 필터·정렬 순서 그대로 이전/다음 케이스 이동, A/H/R로 승인·보류·반려(반려는 메모 필드로 포커스 이동), 판정 저장 성공 시 다음 미심사 케이스로 자동 이동. 상단에 '현재 n/전체 · 남은 미심사 m건' 진행 표시와 ? 키 도움말 오버레이를 넣고, 입력 필드 포커스 중에는 단축키를 비활성화한다.
- 가치: 지금은 케이스 하나 판정할 때마다 큐 화면 복귀 → 행 클릭 → 판정 버튼 클릭의 마우스 왕복이 고정 비용이다. 이를 키 2번(판정 키 → 자동 다음 이동)으로 줄여, 하루 수십~수백 건 연속 처리에서 케이스 간 이동 시간을 거의 제거한다.
- 인사이트: web/operator-gui 전체를 grep해도 keydown 핸들러가 단 하나도 없고, 판정 후 화면이 해당 케이스에 그대로 머문다는 사실을 확인했다. filteredSubmissions()·selectCase()·setDecision()이 이미 완결된 형태로 존재하므로 서버 변경 없이 순수 프론트 작업만으로 연속 심사 플로우를 완성할 수 있다 — 효율 대비 구현 리스크가 가장 낮은 항목.
- 기반: web/operator-gui/app.js의 filteredSubmissions()/selectCase()(line 2345)/setDecision() 및 index.html decision-pane의 data-decision 버튼. 서버 변경 없음 — 기존 POST /api/submissions/{id}/decision 재사용.
### ② 선택 케이스 일괄 판정 + 판정 메모 프리셋 (M+S)
**선택 케이스 일괄 판정 (가드레일 포함)** [operator-efficiency]
- 무엇: 큐에 이미 있는 선택 체크박스(data-bulk-id)를 판정에도 연결한다. POST /api/submissions/decision-batch 엔드포인트를 추가해 선택 케이스들에 공통 메모와 함께 승인/보류/반려를 일괄 적용하고, 케이스별 성공/실패 목록을 반환한다. 가드레일: 보류/반려는 기존 record_decision의 케이스별 watchlist 생성 로직을 그대로 통과시키고, 선택에 high 위험 케이스가 섞여 있으면 개수를 명시한 확인 단계를 거친다.
- 가치: '내부 근거만 있고 외부 매칭 0건' 같은 명백한 저위험 무리를 한 번에 승인할 수 있다. 수십 건에 대해 케이스 진입 → 판정 클릭을 반복하던 것이 체크 → 일괄 판정 1회로 줄고, 부분 실패 시 어느 건이 남았는지 즉시 보인다.
- 인사이트: 체크박스 선택 인프라(selectedBulkSubmissionIds, app.js:2578)와 일괄 '재분석'은 이미 있는데 일괄 '판정'만 빠져 있다 — UI 절반이 이미 깔려 있는 상태. 서버 쪽도 record_decision(sqlite_store.py:580)이 케이스 단위 락·감사이벤트·watchlist 생성을 완결적으로 캡슐화하고 있어, 배치 엔드포인트는 검증 + 루프 + 결과 집계만 추가하면 된다.
- 기반: src/rights_filter/server/sqlite_store.py record_decision(케이스별 트랜잭션·감사·watchlist 로직 재사용), src/rights_filter/server/http_app.py do_POST 라우팅, web/operator-gui/app.js selectedBulkSubmissionIds()와 큐 체크박스, index.html decision-actions 패턴.
**판정 메모 프리셋 (자주 쓰는 사유 원클릭)** [operator-efficiency]
- 무엇: 결정 메모 textarea 위에 프리셋 칩 줄을 추가한다. 운영팀 공용 프리셋(제목+본문)을 DB에 저장하는 GET/POST /api/decision-memo-presets 소형 라우트를 만들고, 칩 클릭 시 메모에 삽입, '현재 메모를 프리셋으로 저장' 버튼을 제공한다. 반려 시 메모 필수 검증(memo-error)과 자연 연동되어 프리셋 선택만으로 반려 요건을 충족한다.
- 가치: 반려는 메모가 서버에서 강제되는데 실제 사유는 '연예인 초상 포함', '방송 캡처', '기준DB 반려 이미지와 동일' 등 소수 패턴의 반복이다. 케이스당 타이핑을 클릭 1회로 줄이고, 팀 내 사유 문구가 표준화되어 감사 로그와 보정 기록의 가독성도 함께 올라간다.
- 인사이트: record_decision(sqlite_store.py:589)이 rejected에 memo.strip() 비어있음 거부를 강제하는데 GUI는 빈 textarea만 제공한다 — 반복 사유 타이핑이 반려 처리의 실질 병목임을 코드로 확인했다. audit_events에 'decision · memo' 형태로 과거 메모가 이미 쌓여 있어, 초기 프리셋 후보를 과거 반려 메모 빈도에서 자동 추출해 시딩하는 확장도 가능하다.
- 기반: web/operator-gui/index.html decision-pane(#decision-memo, #memo-error), app.js setDecision 및 메모 검증 로직, src/rights_filter/server/http_app.py 신규 소형 라우트 2개, sqlite_store의 기존 JSON payload 테이블 패턴(providers/app_state와 동일한 키-값 저장 재사용).
### ③ 제출물 지문 중복·선례 인덱스 (L, 3제안 병합 로드맵)
**동일·유사 제출 자동 묶음 (중복 그룹)** [operator-efficiency]
- 무엇: seed/bootstrap 시 미심사 제출물끼리 SHA-256 완전일치와 pHash 고유사(기본 임계 0.97 이상)를 버킷팅해 submission payload에 duplicateGroup 필드로 직렬화한다. 큐 행에 '동일 이미지 N건' 배지를 표시하고, 배지 클릭 시 그룹 전체의 체크박스를 자동 선택해 기존 선택 재분석·일괄 판정(기능 2)으로 바로 이어지게 한다. 케이스 심사 화면에도 '같은 이미지 제출 N건' 링크를 노출한다.
- 가치: 같은 스톡/연예인 이미지를 여러 신청자가 반복 제출하는 패턴에서 첫 건만 제대로 심사하고 나머지는 그룹 단위로 처리한다. 중복 건마다 외부 근거를 처음부터 다시 읽는 시간이 사라지고, 같은 이미지에 운영자별로 상반된 판정이 나가는 일관성 사고도 구조적으로 방지된다.
- 인사이트: pHash 비교가 기준DB 대조와 검색결과 이미지 대조에는 쓰이면서 정작 큐 안의 제출물끼리는 한 번도 비교되지 않는다는 비대칭을 확인했다. store_search_candidates.py의 _submission_perceptual_fingerprint(line 193)가 제출 이미지 지문 계산 경로를 이미 제공하므로, 실제 신규 작업은 큐 스코프 버킷팅(수백 건 O(n²) 해밍 비교로 충분)과 직렬화 필드 하나다.
- 기반: src/rights_filter/analysis/fingerprints.py FingerprintService, src/rights_filter/server/store_search_candidates.py _submission_perceptual_fingerprint 패턴, sqlite_store.seed_from_image_store/bootstrap 및 store_serialization.py _submission_payload 직렬화, web/operator-gui/app.js 큐 행 렌더링(app.js:1220대)과 data-bulk-id 체크박스.
**재제출·중복 제출 선례 자동 연결 (prior-case dedupe link)** [automation-integration]
- 무엇: seed 시 신규 제출의 SHA-256/pHash 지문을 기존 제출 건들의 저장된 fingerprint 증거와 대조해, 일치(exact 동일 또는 pHash≥임계치) 시 '이전 케이스 연결' 증거를 자동 생성한다. 증거에는 선행 케이스 ID·당시 판정·메모 링크를 담고, 케이스 심사 화면과 큐 행에 '선례: SUB-xxxx 승인/반려' 칩을 표시한다. exact 동일 재제출은 외부 검색(Google Vision/Naver) 재실행을 건너뛰고 선행 케이스의 증거를 참조하도록 해 쿼터를 절약한다. 최종 판정 자동 변경은 하지 않는다(운영 경계 유지).
- 가치: 현재 지식DB 매칭은 보류/반려로 만들어진 주의 후보만 커버한다. 승인됐던 이미지나 아직 미결인 케이스와 동일한 이미지가 다시 들어오면 전체 분석이 다시 돌고(외부 API 쿼터 소모) 운영자는 선례를 모른 채 처음부터 재심사한다. 선례 칩 하나로 '지난주에 이미 승인한 그 이미지'를 즉시 식별하고 판정 일관성도 확보된다.
- 인사이트: internal_analyzer 가 exact(SHA-256)·perceptual 지문을 이미 모든 제출의 evidence 로 저장하고 있어(internal_analyzer.py:36-37, evidence 테이블 idx_evidence_source 인덱스 존재) 새 분석 없이 저장된 데이터 간 조인만으로 구현된다. 즉 이것은 탐지 모델 고도화가 아니라 이미 계산된 신호를 케이스 간에 연결하는 순수 워크플로 기능이며, exact-dupe 외부 검색 스킵은 문서에 명시된 '페이지 수만큼 API 호출량 증가' 비용 문제를 직접 줄인다.
- 기반: src/rights_filter/server/sqlite_store.py seed 경로(_seed_from_image_store_locked)와 evidence 테이블, src/rights_filter/analysis/internal_analyzer.py + fingerprints.py 의 저장 지문, src/rights_filter/analysis/risk_scoring.py (선례 증거 가중), web/operator-gui/app.js 큐/케이스 렌더링
**재제출·중복 클러스터 감지 (지문 유사도 매트릭스)** [insight-analytics]
- 무엇: 전 제출의 pHash를 상호 비교(64bit 해밍 거리, 수천 건 규모에서 전수 비교 가능)해 유사도 임계 이상 클러스터를 만들고, (a) 보류·반려된 이미지가 파일명·인코딩만 바꿔 재제출된 케이스, (b) 동일/유사 이미지에 상반된 판정(하나는 승인, 하나는 반려)이 난 케이스를 신규 API와 큐 화면 배지 + 클러스터 목록 뷰로 하이라이트한다. pHash는 현재 evidence 직렬화에서 exact/perceptual 키가 탈락해 DB에 없으므로, seed_from_image_store 시 submission payload에 지문을 백필·저장하는 단계가 선행돼야 한다.
- 가치: 같은 이미지를 두 번 심사하는 낭비를 제거하고, 반려 후 우회 재제출을 자동으로 잡아내며, 운영자 간 판정 불일치를 관리자가 볼 수 있게 된다.
- 인사이트: 현재 내부 분석기는 지식DB sampleFingerprints와만 비교하므로, 반려 시 watchlist 엔트리가 생성된 경우에만 재제출이 잡힌다. 승인된 이미지의 재제출, 판정 전 중복 제출, 유사 이미지 간 판정 불일치는 지금 아무도 보지 못하는 사각지대다.
- 기반: src/rights_filter/analysis/fingerprints.py (FingerprintService.similarity), src/rights_filter/server/store_search_candidates.py (_submission_perceptual_fingerprint 재계산 경로), src/rights_filter/server/sqlite_store.py (seed_from_image_store에 지문 백필 추가, submissions payload), src/rights_filter/server/image_store.py, src/rights_filter/server/http_app.py (신규 라우트), web/operator-gui/app.js (queue 행 배지 + 신규 패널)
### ④ 판정 결과 일괄 내보내기 + 통보 초안 (S)
**판정 결과 일괄 내보내기 + 통보 초안 (decision export)** [automation-integration]
- 무엇: GET /api/decisions/export?since=&status= 라우트를 추가해 판정 완료 케이스를 CSV/JSON 으로 내려받게 한다. 두 가지 뷰를 분리한다: (1) 내부 아카이브용 — 케이스 ID·제목·판정·메모·판단에 사용된 증거 제목과 출처 URL·판정 시각(감사 이벤트 기준), (2) 신청 채널 전달용 — 판정·applicantStatus·운영자 메모만 포함하고 위험 점수·증거 상세는 절대 포함하지 않는다(운영 경계 준수). GUI 심사 큐 상단에 '판정 결과 내보내기' 버튼과 기간/상태 선택을 추가한다.
- 가치: 지금은 record_decision 이 applicantStatus('반려 예정' 등)까지 세팅하지만 그 값이 시스템 밖으로 나가는 경로가 없어, 운영자가 판정 결과를 접수 채널·상급자에게 전달하려면 큐 화면을 보며 수기로 옮겨 적어야 한다. 하루치 판정을 버튼 한 번으로 정리된 파일로 뽑으면 판정 후 후속 업무(결과 통보, 일일 보고)가 케이스당 수 분에서 수 초로 준다.
- 인사이트: 필요한 데이터가 이미 전부 저장돼 있다 — submissions.decisionStatus/applicantStatus, 증거의 operatorStatus(판단에 사용 여부), audit_events 의 'Operator decision created' 이벤트(판정 시각·메모). 새 스키마 없이 읽기 전용 GET 라우트 하나로 완결되므로 기존 CSRF/락 구조에 영향이 없다. 핵심 설계 판단은 '신청자용 뷰에서 내부 증거·점수를 구조적으로 차단'하는 필드 화이트리스트이며, 이것이 문서화된 운영 경계(Do not expose internal risk scores to applicants)를 코드로 보증하게 된다.
- 기반: src/rights_filter/server/http_app.py (GET 라우트 추가), src/rights_filter/server/sqlite_store.py 의 record_decision/audit_events/bootstrap 데이터, web/operator-gui/index.html + app.js 큐 액션 영역
### ⑤ 온라인 스냅샷 백업·복구 (M)
**온라인 스냅샷 백업·복구 (DB+미디어 번들)** [essential-foundation]
- 무엇: 서버 실행 중에도 안전한 백업을 만드는 관리 기능. sqlite3의 온라인 backup API로 WAL 모드 DB(data/copyrighter.sqlite3)의 일관된 스냅샷을 뜨고, 미디어 4종 디렉터리(data/submissions/images, data/knowledge/images, data/collections/images, data/face-crops)를 묶어 타임스탬프 zip으로 data/backups/에 생성하는 POST /api/admin/backup 엔드포인트 + GUI 버튼(마지막 백업 시각 표시). 복구는 오프라인 CLI(python -m rights_filter.server --restore <zip>)로 수행하고 _ensure_schema_version으로 스키마 버전을 검증한 뒤 기동. 백업/복구 실행은 add_audit_event로 감사 기록. 보존 개수 로테이션 포함.
- 가치: 판정 이력·기준 DB·감사 로그 전체가 단일 SQLite 파일에 있는데 현재 백업 코드가 0줄이다. 디스크 손상·실수 삭제 한 번이면 운영 자산 전체가 유실되고, 폐쇄망 납품 환경에는 클라우드 백업이 없으므로 로컬 스냅샷+반출이 유일한 복구 수단이다. 운영자가 버튼 한 번으로 복구 지점을 만들 수 있게 된다.
- 인사이트: WAL 모드(sqlite_store.py initialize의 pragma journal_mode=wal)에서는 서버 실행 중 파일 복사만으로는 -wal 사이드카가 빠진 손상 스냅샷이 나온다. '파일 하나 복사하면 되는 백업'이라는 가정이 이 구조에서는 성립하지 않으며, 반드시 sqlite3 backup API 경유가 필요하다. 운영 문서(copyrighter-operation-worklist.md '아직 안 된 것' 절)도 백업·복구를 명시적 미완 항목으로 남겨두고 있다.
- 기반: src/rights_filter/server/store_persistence.py(_connect — sqlite3.Connection.backup 사용 지점), src/rights_filter/server/sqlite_store.py(db_path·knowledge_image_dir·collection_image_dir·face_crop_image_dir 경로 필드, add_audit_event), src/rights_filter/server/store_schema.py(_ensure_schema_version 복구 검증), src/rights_filter/server/http_app.py(do_POST 라우팅), src/rights_filter/server/__main__.py(--restore CLI 진입점)
### 차점 후보 (다음 사이클 권장)
- **판정 통계·스코어 캘리브레이션 대시보드**: 운영자는 백로그가 어디서 쌓이는지(오래된 미심사 건, 처리량 저하 요일)를 즉시 보고, 관리자는 룰 기반 리스크 스코어의 임계값 조정과 인력 배치를 감이 아닌 수치로 결정할 수 있다.
- **운영자 식별·역할 분리 (이름 붙은 토큰 + admin 게이트 + 감사 실명화)**: 복수 운영자가 같은 콘솔을 쓰는 순간 현재 감사 로그는 책임 추적 기능을 상실한다 — 반려·기준 DB 편입·provider 중지 같은 판단 행위가 전부 'rights.ops'로 기록되기 때문. 이름 붙은 토큰만으로 SSO 없이(폐쇄망 제약) '누가 언제 무엇을'이 남고, provider 제어·파기·백업 같은 위험 조작은 관리자만 가능해진다.
### 심사 점수 (3인 평균 상위)
| 제안 | 평균 | 개별 |
|---|---|---|
| 키보드 연속 심사 모드 | 9.07 | 9, 9.2, 9 |
| 선택 케이스 일괄 판정 (가드레일 포함) | 8.23 | 8.5, 8.2, 8 |
| 판정 메모 프리셋 (자주 쓰는 사유 원클릭) | 8.20 | 8, 8.6, 8 |
| 판정 결과 일괄 내보내기 + 통보 초안 (decision export) | 7.50 | 7.5, 7.5, 7.5 |
| 온라인 스냅샷 백업·복구 (DB+미디어 번들) | 7.50 | 7.5, 8.5, 6.5 |
| 동일·유사 제출 자동 묶음 (중복 그룹) | 6.90 | 7, 7.2, 6.5 |
| 운영자 식별·역할 분리 (이름 붙은 토큰 + admin 게이트 + 감사 실명화) | 6.77 | 7, 7.8, 5.5 |
| 주의 후보 자동 큐레이션 트리아지 (watchlist auto-curation) | 6.30 | 6.5, 6.4, 6 |
| 판정 통계·스코어 캘리브레이션 대시보드 | 6.10 | 6, 7, 5.3 |
| 기준 DB 적중률·큐레이션 추천 리포트 | 6.10 | 6.5, 6.8, 5 |

View file

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

View file

@ -0,0 +1,74 @@
# 2026-09-23 codex 기능 검토 (원문)
> `codex exec --sandbox read-only` (codex-cli 0.154.0). 프롬프트: 하루 50~100건 운영 가정, 병목 · 없는 기능 · 점수 오도 시나리오 · 기준 DB 오염 · 상위 5개. 파일 링크의 절대 경로는 작성 당시 로컬 경로다. 검증 결과와 채택 여부는 INDEX 의 같은 날짜 항목 참조.
가장 큰 문제는 **검색·심사·권리 확인의 미완료 상태가 위험 점수와 기준 DB에 섞이고, 판정을 바꾼 뒤에도 과거 판단의 영향이 남는 것**입니다. 하루 50~100건에서는 검색 자체보다 보류 재처리와 잘못 남은 판정 근거를 정리하는 일이 병목이 될 가능성이 큽니다.
지정 문서·화면 구조와 관련 구현을 읽었습니다. 점수 시나리오와 메모 조회는 합성 입력으로 실행 확인했습니다. 파일·DB는 수정하지 않았으며, 처리 시간과 발생 빈도는 실사용 측정이 아닌 추정입니다.
**1. 시간을 잡아먹거나 실수를 유발하는 지점**
| 발견 | 운영에 미치는 영향 | 파일·줄 근거 |
|---|---|---|
| **F1. 검색 장애로 보류한 사진이 다음에는 ‘등록 권리 대상’이 된다.** 보류·반려 모두 활성 주의 후보를 만들고, 주의 후보도 유사도 0.9 이상이면 80점을 받는다. | “확인하지 못함”이 “위험하다고 확인함”처럼 재사용된다. 장애 때문에 보류한 정상 이미지가 재제출될 때 high로 올라가 반복 심사를 유발할 수 있다. | [sqlite_store.py:798](src/rights_filter/server/sqlite_store.py:798), [store_operations.py:167](src/rights_filter/server/store_operations.py:167), [internal_analyzer.py:53](src/rights_filter/analysis/internal_analyzer.py:53), [risk_scoring.py:190](src/rights_filter/analysis/risk_scoring.py:190) |
| **F2. ‘미사용’ 버튼의 뜻과 실제 효과가 다르다.** 행 버튼은 사용/미사용 두 개이고, 미사용을 `false_positive`로 전송한다. | “다른 증거를 대표 근거로 골랐으니 이것은 인용하지 않겠다”는 조작이 오탐 판정과 점수 제거로 이어진다. 가이드는 무관/오탐을 구분하지만 화면에서는 구분이 사라진다. | [app-render-evidence.js:336](web/operator-gui/app-render-evidence.js:336), [app-format.js:506](web/operator-gui/app-format.js:506), [risk_scoring.py:160](src/rights_filter/analysis/risk_scoring.py:160) |
| **F3. 보류가 작업 목록에서 사실상 완료처럼 빠진다.** 다음 이동은 미심사만 찾고, 백로그도 미심사만 센다. | 보류 필터는 있지만 담당자·다음 확인일·보류 사유별 후속 작업이 없다. 검색 복구나 자료 보완 후 돌아올 건을 운영자가 별도로 기억해야 한다. 누락 위험은 **추정**이다. | [app-keyboard-batch.js:67](web/operator-gui/app-keyboard-batch.js:67), [store_workflow.py:220](src/rights_filter/server/store_workflow.py:220), [index.html:182](web/operator-gui/index.html:182) |
| **F4. 판정 변경은 가능하지만, 변경을 완결하는 기능이 부족하다.** 승인으로 바꿔도 이전 주의 후보를 정리하지 않고 최신 스냅샷을 덮어쓴다. | 오조작이나 소명 후 승인 변경 시 기준 DB까지 사람이 찾아 정리해야 한다. 특히 **반려→메모 없는 승인**에서 이전 반려 메모가 조회되는 동작을 재현했다. 지원자용 내보내기도 이 메모를 사용한다. | [sqlite_store.py:790](src/rights_filter/server/sqlite_store.py:790), [store_workflow.py:328](src/rights_filter/server/store_workflow.py:328), [store_workflow.py:380](src/rights_filter/server/store_workflow.py:380) |
| **F5. 일괄 재분석의 대상 선정·복구가 수작업이다.** 브라우저가 선택 건을 순차 호출하며, 한 요청이 실패하면 반복을 중단한다. | “어제 한도 때문에 빠진 네이버만”, “기준 변경에 영향받은 건만” 처리하기 어렵다. 실패 후 남은 대상도 다시 골라야 한다. 장당 최대 5콜이면 문서상의 네이버 한도 100은 20장에 소진될 수 있어, 50~100건 운영에서 반복될 가능성이 높다. | [app-submissions.js:9](web/operator-gui/app-submissions.js:9), [store_operations.py:295](src/rights_filter/server/store_operations.py:295), [calibration-corpus.md:36](docs/operations/calibration-corpus.md:36) |
| **F6. 폴더 투입에서 서로 다른 제출이 같은 건으로 합쳐질 수 있다.** 제출 ID가 파일명 stem이다. | `지원자A/photo.jpg`, `지원자B/photo.png` 같은 입력을 내용 중복과 무관하게 충돌시킨다. 일부 건이 심사되지 않은 채 “다 처리했다”고 생각할 수 있다. 흔한 파일명에서의 발생 빈도는 **추정**, 충돌 한계 자체는 코드에 명시되어 있다. | [image_store.py:93](src/rights_filter/server/image_store.py:93), [calibration-corpus.md:63](docs/operations/calibration-corpus.md:63) |
**2. 추가할 가치가 명확한 기능**
중복 그룹·선례 승계, 일괄 판정, 선택 재분석, 판정 CSV는 이미 있습니다. 아래는 그 기능들이 아직 해결하지 못하는 부분입니다. 크기는 기존 구조를 활용하는 기준으로 S=국소 변경, M=화면·저장·워크플로 연결, L=여러 수명주기 또는 외부 시스템 연동입니다.
| 필요한 기능 | 왜 유용한가 / 없으면 생기는 일 | 크기 | 근거 |
|---|---|---|---|
| **보류 사유·담당자·기한·재개 조건을 가진 후속 심사함** | 검색 장애, 라이선스 확인, 지원자 자료 대기를 구분한다. 지금은 모두 보류로 묶여 미심사 백로그 밖에서 정체된다. | M | F1·F3. [review-guide.md:44](docs/operations/review-guide.md:44) |
| **판정 취소·재개·정정 이력과 파생 기준의 동시 정리** | 실수한 판정 복구와 소명 처리를 한 작업으로 끝낸다. 없으면 현재 승인과 과거 반려 근거가 공존한다. 판정별 메모·증거·스냅샷을 보존해야 한다. | M | F4. [sqlite_store.py:776](src/rights_filter/server/sqlite_store.py:776) |
| **조건별 재분석 작업 목록과 중단 후 재개** | 실패 제공자·미수행 사유·분석일·기준 변경으로 대상을 고르고 필요한 단계만 수행한다. 없으면 정상 분석도 반복하고 실패분을 재선택한다. | M | F5. [store_operations.py:298](src/rights_filter/server/store_operations.py:298) |
| **권리 증빙과 적용 범위 기록** | 라이선스 문서, 허용 제출자·용도·기간, 초상 동의 확인을 케이스 및 클리어 기준에 연결한다. 없으면 “이 사진이 승인된 적 있음”과 “이번 사용도 허용됨”을 구분하기 어렵다. | M | 클리어 등록은 지문·메모·원 제출 중심이다. [store_operations.py:236](src/rights_filter/server/store_operations.py:236) |
| **선례 승계 전 적용 조건 비교** | 같은 이미지라도 신청자·용도·계약 상태가 다르면 재검토하도록 한다. 현재 승계 가능 여부는 선례 판정의 일치만 본다. 권한이 다른 재제출에 승인 선례가 전파될 가능성은 **추정**이다. | M | [app-selectors.js:319](web/operator-gui/app-selectors.js:319), [app-navigation.js:455](web/operator-gui/app-navigation.js:455) |
| **투입 전 대사 화면과 안정적인 제출 ID** | 발견 파일 수·신규 건수·내용 중복·ID 충돌·지원하지 않는 파일을 따로 보여준다. 없으면 파일 정리 실수가 심사 누락으로 바뀐다. | M | F6. [image_store.py:105](src/rights_filter/server/image_store.py:105) |
| **판정 시점 기준의 케이스 리포트** | 당시 점수·사용 증거·메모·판정자·버전을 묶어 재현한다. 현재 내부 CSV는 현재 점수와 현재 사용 표시를 읽으므로 나중에 다시 내보내면 설명이 달라질 수 있다. | M | [store_workflow.py:339](src/rights_filter/server/store_workflow.py:339), [sqlite_store.py:802](src/rights_filter/server/sqlite_store.py:802) |
| **지원자 통지 인계·수신 확인 상태** | 폐쇄망에서는 우선 승인된 파일 교환 방식으로 인계 ID·수신 여부·정정 통지를 관리할 수 있다. 현재 ‘승인 대기/반려 예정’과 CSV만으로는 실제 전달 완료 여부를 알 수 없다. 중복·누락 통지는 **추정**이다. | M, 직접 연동 L | [sqlite_store.py:794](src/rights_filter/server/sqlite_store.py:794), [store_workflow.py:267](src/rights_filter/server/store_workflow.py:267) |
검색 캐시는 “전혀 없음”으로 판정하지 않습니다. 현재 **케이스별 쿼리 중복 실행 방지**가 있습니다. 보완할 것은 유효기간·강제 갱신·다른 중복 제출 간 재사용입니다. 성공했던 오래된 쿼리는 계속 생략되므로 ‘재분석’과 ‘최신 검색’이 다를 수 있습니다. 규모 M입니다. [store_enrichment.py:711](src/rights_filter/server/store_enrichment.py:711), [store_serialization.py:647](src/rights_filter/server/store_serialization.py:647)
**3. 점수·밴드가 판단을 오도하는 구체적 시나리오**
아래 점수는 각각 **표에 적힌 증거만 있고, 라벨 신뢰도는 1.0**인 합성 입력으로 실행 확인했습니다. 라이선스 유무와 이용 상황은 시나리오 가정입니다.
| 시나리오 | 증거 조합 → 실제 점수 | 잘못된 우선순위 또는 해석 |
|---|---|---|
| **권리 확인된 인물 사진이 먼저 올라온다.** | 신뢰 등록되지 않은 사이트의 완전일치 40 + 식별 가능 인물 30 + 라벨 10 → **80/high** | 해당 용도의 라이선스·동의가 확인되었다고 가정해도 점수에 반영되지 않는다. 다음 행보다 먼저 심사한다. |
| **무단 사용된 비인물 작품이 뒤로 밀린다.** | 등록 DB에 없는 그림의 완전일치 40 + 라벨 10 → **50/medium** | 사용 허락이 없다고 가정한 명확한 사본도 인물이 없어서 앞의 허가된 사진보다 낮다. ‘권리 확인 필요성’과 ‘얼굴 존재’가 섞인다. |
| **검색 장애 보류가 강한 위험 선례가 된다.** | 장애로 보류되어 만들어진 주의 후보와 유사도 1.0 → **80/high** | 새 권리 증거가 전혀 없어도 이전의 확인 불가 상태만으로 high다. |
| **클리어 기준이 있어도 고위험으로 남는다.** | 클리어 일치 0 + 외부 완전일치 40 + 인물 30 + 라벨 10 → **80/high** | 동일 용도로 유효하게 클리어된 자산이라고 가정해도 가점이 없을 뿐 기존 위험 점수는 그대로다. 운영자는 ‘클리어 이력’과 high를 다시 해석해야 한다. |
| **폐쇄망에서 미검색이 저위험처럼 보인다.** | `EXTERNAL_SKIPPED`, 사유 `disabled`, 다른 신호 없음 → **0/low** | 이 설정은 의도된 예외지만, 검색을 하지 않은 사진이 검색 완료 후 증거가 없는 사진과 같은 low에 들어간다. ‘low=확인 완료’로 읽으면 위험하다. |
| **계약처 도메인의 모든 이미지가 같은 취급을 받는다.** | 유일한 완전일치 출처가 신뢰 등록 호스트 → **0/low** | 계약이 일부 자산에만 적용되거나 만료되었다고 가정하면, 해당 사진의 사용 권한은 미확인인데 웹 증거가 제외된다. |
배점·클리어 처리 근거: [risk_scoring.py:49](src/rights_filter/analysis/risk_scoring.py:49), [risk_scoring.py:165](src/rights_filter/analysis/risk_scoring.py:165). 미수행 예외: [risk_scoring.py:278](src/rights_filter/analysis/risk_scoring.py:278). 신뢰 호스트 제외: [risk_scoring.py:125](src/rights_filter/analysis/risk_scoring.py:125), [risk_scoring.py:218](src/rights_filter/analysis/risk_scoring.py:218).
여기에 **검증되지 않은 정확성 인상**이 더해집니다. 통계는 판정 당시가 아닌 현재 밴드를 사용하고, 분모가 0이면 비율 0.0을 반환합니다. 화면도 숫자를 유지하고 색만 중립으로 바꿉니다. 따라서 ‘미탐 0%’는 실제 성능 근거가 아닐 수 있습니다. [store_workflow.py:214](src/rights_filter/server/store_workflow.py:214), [store_workflow.py:398](src/rights_filter/server/store_workflow.py:398), [app-stats.js:75](web/operator-gui/app-stats.js:75)
**4. 기준 DB의 오염·노후화 경로와 빠진 절차**
| 경로 | 빠진 운영 절차 |
|---|---|
| **주의 후보의 자기 강화.** 검색 미완료 보류와 실제 반려가 같은 유형으로 쌓이고 같은 80점 축에 들어간다. F1의 확인된 동작이다. | 보류 사유를 구조화하고, ‘검색 대기’는 권리 위험 기준에서 분리해야 한다. 후보의 확정 승격에는 별도의 확인 근거가 필요하다. |
| **정정 후 반대 기준이 공존.** 반려→승인은 기존 주의 후보를 정리하지 않고, 클리어는 별도 ID로 등록된다. 클리어·주의 후보가 동시에 활성일 수 있다. | 원 판정 변경 시 파생 기준의 유지·철회 여부를 함께 결정하고, 동일 이미지에 반대 기준이 있으면 조정 대상으로 올려야 한다. [store_operations.py:155](src/rights_filter/server/store_operations.py:155), [store_operations.py:229](src/rights_filter/server/store_operations.py:229) |
| **제외한 후보의 재생성·확정 후보의 상태 후퇴.** 같은 원 제출을 다시 보류/반려하면 기존 내용을 읽은 뒤 `active=True`, `entryStatus=watchlist`로 덮어쓴다. | 재판정 때 기존 제외·확정 결정을 존중하고, 이를 바꾸려면 변경 사유를 남겨야 한다. [store_operations.py:155](src/rights_filter/server/store_operations.py:155) |
| **클리어·신뢰 출처의 만료와 범위 상실.** 현재 클리어는 지문 중심이고 신뢰 출처는 활성 도메인 목록이다. 하위 도메인도 포함한다. | 책임자·증빙·허용 범위·만료일·정기 재확인일이 필요하다. 계약 갱신·종료 때 영향받는 케이스 목록을 만들어야 한다. [store_operations.py:236](src/rights_filter/server/store_operations.py:236), [store_search_candidates.py:721](src/rights_filter/server/store_search_candidates.py:721) |
| **기준을 철회해도 기존 케이스의 증거가 남는다.** 비활성화는 기준 항목을 갱신하고, 재점수는 저장된 증거를 다시 계산한다. 기존 지문 증거에서 현재 기준의 활성 상태를 재확인하는 경로는 해당 함수에 없다. | 기준 변경의 영향 대상을 찾아 과거 증거를 무효화·갱신하고, 처리 완료 여부를 확인하는 절차가 필요하다. [store_operations.py:100](src/rights_filter/server/store_operations.py:100), [store_search_candidates.py:744](src/rights_filter/server/store_search_candidates.py:744) |
출처별 신뢰도 **자동 학습은 지금 우선순위가 낮습니다**. 현재 ‘미사용’이 오탐으로 저장되고, 운영자 판정도 점수를 먼저 본 상태에서 만들어집니다. 이 기록을 그대로 학습하면 기존 점수와 조작 습관을 강화할 가능성이 있습니다. 먼저 상태 의미를 바로잡고, 출처별 오탐·무관·권리 확인 결과를 분리 집계하는 편이 낫습니다. 이 순환 문제는 구현 주석에도 명시되어 있습니다. [store_workflow.py:43](src/rights_filter/server/store_workflow.py:43)
**5. 지금 착수할 상위 5개**
| 순위 | 착수할 일 | 선정 근거와 완료 기준 |
|---|---|---|
| **1** | **보류 사유 분리 + 주의 후보의 80점 자동 승격 차단** | 확인 불가가 강한 위험 선례로 누적되는 F1을 먼저 끊어야 한다. 검색 대기 보류는 권리 기준과 분리하고, 복구 후 재심사함으로 돌아오게 한다. **완료 기준:** 장애 보류 사진의 재제출이 그 보류만으로 high가 되지 않는다. |
| **2** | **판정 정정의 일관성 확보** | F4는 실제 내보내기 내용까지 모순시킨다. 최신 판정 메모 조회를 우선 수정하고, 판정별 기록 보존·재개·파생 기준 정리를 연결한다. **완료 기준:** 반려→승인 후 화면·기준 DB·내보내기가 같은 결론과 사유를 가리킨다. |
| **3** | **투입 충돌 감지와 건수 대사** | F6은 큐에 제대로 나타나지 않아 운영자가 심사로 발견하기 어렵다. 먼저 충돌을 명시적으로 차단·보고하고, 안정적 ID로 확장한다. **완료 기준:** 동일 파일명 두 장이 서로 다른 제출로 보존되거나 명확한 오류 목록에 나온다. |
| **4** | **증거 인용 여부와 무관·오탐 상태 분리** | F2는 매일 반복되는 조작이며 점수와 향후 평가 데이터까지 바꾼다. ‘판정에 인용하지 않음’은 점수를 유지하고, 무관·오탐만 별도 사유로 제외한다. **완료 기준:** 대표 증거 하나를 선택하는 작업이 다른 유효 증거의 점수를 지우지 않는다. |
| **5** | **검색 완결성 표시 + 실제 판정 기반 캘리브레이션** | low가 미검색과 섞이고, 현재 코퍼스는 medium/low를 검증할 수 없다. 검색 상태를 밴드와 별도로 명시하고 분모 0은 ‘측정 불가’로 표시한다. 자사 원본·라이선스 자산·비인물 작품을 보강해 판정 시점 스냅샷으로 평가한다. **완료 기준:** 배포 설정별 검색 공백과 밴드별 실제 판정 결과를 구분해 볼 수 있다. [calibration-corpus.md:19](docs/operations/calibration-corpus.md:19), [calibration-corpus.md:101](docs/operations/calibration-corpus.md:101) |

View file

@ -0,0 +1,116 @@
# 2026-09-25 페르소나 리뷰 — 소크라테스 3단논법 검증
각 페르소나의 요구를 **대전제**, 코드·실측으로 확인한 사실을 **소전제**로 놓고 결론을 낸 뒤,
**반문**으로 두 전제를 공격해 결론이 살아남는지 본다. 판정: **성립 / 부분 성립 / 붕괴**.
근거는 모두 이 저장소의 코드와 2026-09-22 실이미지 20장 실측이다. 판정 데이터는 0건이다.
## 페르소나 1 — 심사 운영자 (하루 50~100건)
### 1-A. 순서
- 대전제: 운영자는 위험한 건부터 봐야 시간을 아낀다.
- 소전제: 큐는 `riskScore` 내림차순이고, v5 이후 실이미지 20장이 100 하나에서 75·80·92·95 네 단계로 갈라졌다.
- 결론: 큐에 순서가 생겼다.
- 반문: **그 순서가 위험의 순서인가?** 92 와 95 의 차이는 네이버 승격 신뢰도 0.8 과 1.0 의 차이다. 20장 모두 high 라 high 안의 순서는 "배경 인물인가"와 "이름 검색이 걸렸나"로만 갈린다. 두 축 모두 권리 위험과의 상관이 측정된 적 없다.
- 판정: **부분 성립.** 밴드 **사이**의 순서는 설계 근거가 있다. 밴드 **안**의 순서는 근거가 없다.
### 1-B. 첫 1초
- 대전제: 첫 1초에 방향을 잡을 수 있어야 한다.
- 소전제: 한 줄 요약 `웹 완전일치 사본 4곳 · 식별 가능 인물 · …` 을 실렌더로 확인했다.
- 결론: 방향을 잡을 수 있다.
- 반문: **"4곳"이 언론사인가, 스크래퍼인가?** 한 줄 요약은 사실을 말할 뿐 판단을 돕지 않는다. 원저작자 쪽일 가능성이 높은 언론·공식 계정과 무단 게시처를 가르는 분류가 없어서, 운영자는 결국 링크를 연다.
- 판정: **부분 성립.** 읽는 시간은 줄었지만 여는 횟수는 줄지 않았다.
### 1-C. 되돌리기
- 대전제: 실수한 판정은 흔적 없이 바로잡혀야 한다.
- 소전제: 판정을 바꿀 수 있다. 승인으로 바꾸면 파생된 주의 후보가 철회되고, 영향받은 케이스는 리스코어되며, 메모는 마지막 판정 것만 남는다(2026-09-23).
- 결론: 바로잡힌다.
- 반문: 반려에서 보류로 바꾸면 주의 후보는 남는다(의도). 전용 "취소" 동작은 없고 재판정만 가능하다.
- 판정: **성립.**
## 페르소나 2 — 권리·법무 담당 (판정의 책임자)
### 2-A. 재현 가능성
- 대전제: 판정은 "누가, 무엇을 보고" 내렸는지 사후에 재현 가능해야 한다.
- 소전제:
- (a) 감사 actor 가 `rights.ops` 로 하드코딩돼 있다(`sqlite_store.py` 외 다수).
- (b) 판정 시점의 표시 상태는 `decisionSnapshot` 에 저장된다.
- (c) 그런데 **내보내기는 스냅샷이 아니라 현재 값을 읽는다.** `store_workflow.py` `_export_row` 의 `riskScore` 와 `evidenceUsed` 가 그렇다.
- 결론: "누가"는 재현할 수 없다. "무엇을 보고"는 판정 뒤에 증거 표시를 바꾸거나 리스코어가 돌면 내보내기 내용이 달라진다.
- 반문: 스냅샷이 있으니 재현 **데이터**는 있지 않은가? 있다. 하지만 사람이 받는 산출물은 그것을 쓰지 않는다.
- 판정: **붕괴.** 데이터는 있으나 산출물이 그것을 쓰지 않는다.
### 2-B. 점수의 의미
- 대전제: "위험 점수"는 권리 위험을 반영해야 한다.
- 소전제: 점수 축은 웹 존재·인물·이름 검색·라벨·등록 일치다. 라이선스·동의 같은 권리 증빙 축은 없다. 비인물 작품의 완전일치 사본은 50/medium 이고, 라이선스가 있는 인물 사진은 80/high 다(codex 시나리오를 재확인).
- 결론: 점수는 권리 위험이 아니라 **노출도 × 식별성**을 잰다.
- 반문: 그러면 "높음 위험"이라는 이름이 과장인가? 그렇다. 운영자가 high 를 "침해 가능성 높음"으로 읽으면 오도된다.
- 판정: **붕괴(명칭 한정).** 순서 도구로는 쓸 만하다. "위험"이라는 이름은 증명되지 않았다.
## 페르소나 3 — 제출자
### 3-A. 거절 사유
- 대전제: 거절 사유는 정확하고, 실제로 전달되어야 한다.
- 소전제: 지원자용 내보내기는 내부 신호를 제거하고 마지막 판정의 메모만 싣는다(2026-09-23 수정). 통지 연동이나 수신 확인은 없다.
- 결론: 사유는 정확하다. 전달 여부는 알 수 없다.
- 판정: **부분 성립.**
### 3-B. 재제출의 공정성
- 대전제: 같은 이미지를 다시 냈을 때 새로 심사받아야 한다.
- 소전제: 이전 보류·반려로 생긴 주의 후보에 일치하면 +40 이 붙고, 한 줄 요약 맨 앞에 "주의 후보 일치"가 뜬다.
- 결론: 이전 판정이 다음 심사의 출발점이 된다.
- 반문: **그것이 공정한가, 선입견인가?** 같은 이미지라면 이전 판정을 참고하는 것이 일관성이다. 다만 검색 장애로 생긴 보류는 v7 에서 후보를 만들지 않게 막았다. 남은 위험은 운영자가 이전 결론을 그대로 따라가는 것이고, 이는 가이드("다르면 이유를 메모")로만 막혀 있다.
- 판정: **부분 성립.**
## 페르소나 4 — 폐쇄망 인프라·납품 담당
### 4-A. 네트워크 없이 동작하는가
- 대전제: 폐쇄망 납품물은 런타임에 네트워크 없이 동작해야 한다.
- 소전제: 설치 의존성(numpy·opencv·Pillow)은 오프라인 휠로 동봉한다. 그러나 **핵심 탐지(Vision·Naver)는 외부 API 호출이 필수**이고, `offline-install.md` 도 egress 가 필요하다고 명시한다.
- 결론: "폐쇄망"은 설치 측면에서만 성립한다. 기능 측면에서는 "제한적 egress 망"이다.
- 반문: **완전 폐쇄에서는 무엇이 남는가?** 지문·얼굴·출처 메타데이터와 기준 DB 일치만 남는다. 모든 신규 이미지가 low 와 "외부 검색 꺼짐"으로 떨어지고, 순서 도구로서의 가치는 대부분 사라진다.
- 판정: **부분 성립.** 두 운영 모드(egress 허용 / 완전 폐쇄)와 각각의 기대 성능을 납품 문서에 명시해야 한다.
### 4-B. 납품물을 추적할 수 있는가
- 대전제: 납품물은 버전으로 추적 가능해야 한다.
- 소전제: `VERSION` 0.1.0 과 `/health` 에 버전이 있다. 패키징 스크립트는 없고, main 에 병합되지 않은 커밋이 51개, 원격에 없는 커밋이 28개다.
- 결론: 버전의 출처는 있지만 납품 가능한 단위가 없다.
- 판정: **미성립.**
## 페르소나 5 — 팀장·도입 결정자
### 5-A. 효과 측정
- 대전제: 도구 도입의 효과는 측정되어야 한다.
- 소전제: 판정이 0건이다. 임계 70/30 은 미검증이다. 코퍼스 20장은 모두 웹에 유통되는 인물 사진이라 low 쪽 분모가 영원히 0이다. 측정 도구(스냅샷·`sweep_thresholds`·`score_distribution`)는 갖춰져 있다.
- 결론: 효과는 미측정이다. 도입 판단의 근거가 없다.
- 반문: **그럼 지금까지의 개선은 무엇에 근거했나?** 스코어러가 사흘 동안 v4 에서 v7 로 네 번 바뀌었다. 근거는 구조적 추론, 실이미지 20장, codex 교차검토다. **운영자 판단과의 대조는 한 번도 없다.**
- 판정: **붕괴(현 시점).** 측정 장치는 있고 데이터가 없다. 파일럿이 곧 측정이다.
## 종합
| 페르소나 | 판정 | 핵심 |
|---|---|---|
| 운영자 | 부분 성립 | 순서·첫 1초·되돌리기는 됨. 호스트 성격 구분이 없어 링크는 여전히 열어야 함 |
| 법무 | **붕괴** | actor 익명, 내보내기가 판정 시점이 아닌 현재 값을 읽음 |
| 제출자 | 부분 성립 | 사유 정확. 전달 추적 없음. 재제출에 이전 판정이 앵커로 작용 |
| 인프라 | 부분 / 미성립 | "폐쇄망"의 실제 범위가 불명확. 납품 단위 없음 |
| 결정자 | **붕괴** | 판정 0건. 가중치 네 번 변경이 모두 판정 데이터 없이 이루어짐 |
## 권고 (적용 비용이 낮은 순)
1. **내보내기를 판정 시점 스냅샷으로.** `decisionSnapshot` 에 이미 점수·표시 상태가 있다. 법무 2-A 의 절반이 이 한 곳에서 풀린다. (S)
2. **"위험" 명칭 교정.** 밴드 설명을 "노출·식별성 기반 검토 우선순위"로 바꾸고, 가이드에 "high ≠ 침해 판정"을 명시한다. (S)
3. **운영 모드 문서.** egress 허용 / 완전 폐쇄 두 모드와 각각의 기대 동작을 적는다. (S)
4. **운영자 신원.** 법무 2-A 의 나머지 절반이다. 앞서 건너뛰기로 한 결정의 재고를 권한다. 공유 토큰 하나라도 운영자 이름을 받아 actor 에 싣는 방식이면 된다. (M)
5. **호스트 성격 태깅.** 언론·공식 / 커뮤니티 / 스크래퍼 구분을 기준 DB 로 도입한다. 신뢰 출처 기능의 확장이다. (M)
6. **파일럿 40장.** `calibration-corpus.md` 1절 구성대로 진행한다. 결정자 5-A 와 운영자 1-A 반문의 유일한 해소 경로다. (사람)
7. **main 병합 · push · 패키징 스크립트.** (S, 사용자 확인 필요)

View file

@ -53,6 +53,11 @@ Required controls:
- Decision filter: unreviewed, held, rejected, approved, corrected - Decision filter: unreviewed, held, rejected, approved, corrected
- Sort by risk, newest, oldest, analysis failure, provider failure - Sort by risk, newest, oldest, analysis failure, provider failure
- Bulk selection only for non-decisive actions such as re-run analysis or assign reviewer; no bulk approve/reject in v1 - Bulk selection only for non-decisive actions such as re-run analysis or assign reviewer; no bulk approve/reject in v1
- **개정 2026-07-24**: v1 제약을 해제하고 일괄 승인/보류/반려를 가드레일과 함께 허용한다.
근거: 2026-07-24 리뷰에서 반복 판정이 운영자 처리량의 주 병목으로 확인됨.
필수 가드레일 — 선택에 고위험(high) 케이스가 포함되면 개수를 명시한 확인 단계,
반려는 메모 필수, 1회 최대 200건, 케이스별 개별 트랜잭션·감사 이벤트,
부분 실패 시 실패 케이스 목록 반환. 자동 판정은 여전히 금지(운영자 명시 조작만).
UX rule: the queue should never require opening every item just to understand why it is high risk. UX rule: the queue should never require opening every item just to understand why it is high risk.

15
pytest.ini Normal file
View file

@ -0,0 +1,15 @@
[pytest]
testpaths = tests
# `tests.rights_filter.server.test_sqlite_store` is imported by sibling specs for
# shared fakes, and every module under test lives in src/. Declaring both here
# means bare `pytest` works; without it only `python -m pytest` did (that form
# puts the CWD on sys.path as a side effect, which is easy to get wrong in CI).
pythonpath = . src
# Hang guard, not a performance budget. The slowest single spec (browser upload
# + real analysis) runs well under a minute; this only fires when something
# deadlocks, so an unattended `verify` run fails instead of hanging forever.
timeout = 300
addopts = --strict-markers

View file

@ -3,6 +3,9 @@
pytest==8.4.2 pytest==8.4.2
pytest-cov==6.0.0 pytest-cov==6.0.0
pytest-timeout==2.4.0 pytest-timeout==2.4.0
# Lint gate in verify.ps1. Pinned because ruff's default rule set moves between
# releases; ruff.toml states the selection, this states the version.
ruff==0.12.0
# Operator-GUI browser (E2E) tests. The Chromium binary is NOT pip-installed: # Operator-GUI browser (E2E) tests. The Chromium binary is NOT pip-installed:
# on the build/staging host run `playwright install chromium`, then bundle the # on the build/staging host run `playwright install chromium`, then bundle the
# browser cache (PLAYWRIGHT_BROWSERS_PATH) into the offline artifact for the # browser cache (PLAYWRIGHT_BROWSERS_PATH) into the offline artifact for the

10
ruff.toml Normal file
View file

@ -0,0 +1,10 @@
# Pinned so `verify` produces the same result on every machine. Without a config
# file ruff falls back to its built-in defaults, which shift between releases —
# a lint gate that changes under you is not a gate.
target-version = "py313"
line-length = 120
[lint]
# Ruff's default set (pycodestyle errors + pyflakes). Stated explicitly rather
# than inherited so a ruff upgrade cannot silently widen or narrow the gate.
select = ["E4", "E7", "E9", "F"]

View file

@ -1,6 +1,14 @@
from pathlib import Path from pathlib import Path
import os
import sys import sys
# Guard BEFORE importing rights_filter so a 3.13-only syntax in the package
# surfaces this friendly message instead of a raw SyntaxError/ImportError.
if sys.version_info < (3, 13):
sys.exit(
"Copyrighter requires Python 3.13+ - activate the offline venv "
"(see docs/operations/offline-install.md)."
)
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src" SRC = ROOT / "src"
@ -12,4 +20,8 @@ from rights_filter.server.__main__ import main
if __name__ == "__main__": if __name__ == "__main__":
# Anchor the working directory to the repo root so the cwd-relative default
# paths (data/, web/operator-gui) resolve the same regardless of where the
# launcher was invoked from.
os.chdir(ROOT)
main() main()

View file

@ -0,0 +1,99 @@
<#
.SYNOPSIS
docs/diagrams 의 다이어그램 4종을 spec JSON 에서 다시 생성한다.
.DESCRIPTION
PROJECT_MAP 에 적혀 있던 "archify validate -> deliver -> visual-check" 절차는
그대로는 실행되지 않았다 - archify 라는 실행 파일이 PATH 에 없고, 실제로는
저장소 밖 사용자 전역 스킬을 node 로 불러야 한다. 이 스크립트가 그 절차다.
순서:
1. validate (--quality showcase) 합성/기하 검증
2. deliver 산출물 HTML 을 원자적으로 교체
3. visual-check 실제 브라우저 증거 (Chrome 필요)
4. manifest 갱신 tests/test_diagram_sync.py 의 수신증
산출물은 바이트 결정적이다. 그래서 4번의 수신증만으로 드리프트를 잡을 수 있고,
테스트는 브라우저도 Node 도 없이 돈다.
.PARAMETER SkipVisualCheck
3단계를 건너뛴다. Chrome 이 없는 빌드 호스트용. 건너뛰면 그 사실을 출력한다
(조용한 통과 금지).
.PARAMETER ArchifyHome
archify 스킬 경로. 기본값은 $env:ARCHIFY_HOME, 없으면 ~/.claude/skills/archify.
.EXAMPLE
.\scripts
ebuild-diagrams.ps1
.EXAMPLE
.\scripts
ebuild-diagrams.ps1 -SkipVisualCheck
#>
[CmdletBinding()]
param(
[switch]$SkipVisualCheck,
[string]$ArchifyHome
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $repoRoot
if (-not $ArchifyHome) {
if ($env:ARCHIFY_HOME) { $ArchifyHome = $env:ARCHIFY_HOME }
else { $ArchifyHome = Join-Path $HOME '.claude/skills/archify' }
}
$cli = Join-Path $ArchifyHome 'bin/archify.mjs'
if (-not (Test-Path -LiteralPath $cli)) {
Write-Host "archify CLI not found: $cli" -ForegroundColor Red
Write-Host 'Set -ArchifyHome or $env:ARCHIFY_HOME to the archify skill directory.' -ForegroundColor Red
exit 1
}
# (type, basename) - tests/test_diagram_sync.py 및 update_diagram_manifest.py 와 같은 목록
$diagrams = @(
@{ Type = 'architecture'; Base = 'system-architecture' },
@{ Type = 'dataflow'; Base = 'evidence-dataflow' },
@{ Type = 'workflow'; Base = 'operator-review-workflow' },
@{ Type = 'workflow'; Base = 'review-guide' },
@{ Type = 'lifecycle'; Base = 'submission-lifecycle' }
)
$failures = @()
$skipped = @()
foreach ($d in $diagrams) {
$spec = Join-Path $repoRoot ("docs/diagrams/{0}.{1}.json" -f $d.Base, $d.Type)
$html = Join-Path $repoRoot ("docs/diagrams/{0}.html" -f $d.Base)
Write-Host "`n=== $($d.Base) ($($d.Type)) ===" -ForegroundColor Cyan
& node $cli validate $d.Type $spec --quality showcase
if ($LASTEXITCODE -ne 0) { $failures += "validate:$($d.Base)"; continue }
& node $cli deliver $d.Type $spec $html --quality showcase
if ($LASTEXITCODE -ne 0) { $failures += "deliver:$($d.Base)"; continue }
if ($SkipVisualCheck) { $skipped += $d.Base; continue }
& node $cli visual-check $html
# exit 2 = skipped (Chrome/Chromium 부재). 실패와 구분해 기록한다.
if ($LASTEXITCODE -eq 2) { $skipped += $d.Base }
elseif ($LASTEXITCODE -ne 0) { $failures += "visual-check:$($d.Base)" }
}
Write-Host "`n=== manifest ===" -ForegroundColor Cyan
& python (Join-Path $PSScriptRoot 'update_diagram_manifest.py')
if ($LASTEXITCODE -ne 0) { $failures += 'manifest' }
Write-Host ''
if ($skipped.Count -gt 0) {
Write-Host "VISUAL-CHECK SKIPPED (no browser evidence): $($skipped -join ', ')" -ForegroundColor Yellow
}
if ($failures.Count -gt 0) {
Write-Host "REBUILD FAILED: $($failures -join ', ')" -ForegroundColor Red
exit 1
}
Write-Host 'REBUILD OK - run verify.ps1 to confirm the diagram gate' -ForegroundColor Green
exit 0

View file

@ -0,0 +1,224 @@
"""위험도 스코어러의 밴드 분포를 신호 조합 전수로 측정한다.
**이 스크립트가 재는 것과 재지 않는 것**
재는 것: 스코어러의 구조적 거동. 어떤 신호 조합이 어느 밴드로 떨어지는지,
100점 포화가 얼마나 되는지, "웹 신호만으로 high 에 도달하는가"(v2 이후의 설계
원칙)가 지켜지는지.
재지 않는 것: **임계가 맞는지.** 이 격자는 신호 축을 균등 샘플링하므로 실제 큐의
분포가 아니다. 임계 검증은 실이미지 + 운영자 판정으로만 가능하고 그것은
scripts/sweep_thresholds.py 가 담당한다.
**왜 만들었나**: SCORER_VERSION 이 v1→v2→v3→v4 로 갔는데 v3(제공자별 풀링)와
v4(신뢰 출처)는 분포를 한 번도 측정하지 않고 나갔다. 2026-08-08 평가가 쓴
224조합 하네스는 저장소에 남아 있지 않고, 그 문서의 두 수치(174/224=77.7% 와
82.6%)가 서로 맞지 않아 격자가 달랐음을 시사한다 — 즉 과거 숫자와 비교 가능한
재현은 불가능하다. 그래서 격자를 여기에 **명시적으로** 고정해 새 기준선으로 삼는다.
앞으로 스코어러를 바꾸면 이 스크립트를 돌려 전후를 비교한다.
사용법:
python scripts/score_distribution.py
python scripts/score_distribution.py --json
"""
from __future__ import annotations
import argparse
import itertools
import json
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from rights_filter.analysis.risk_scoring import SCORER_VERSION, RiskScorer # noqa: E402
from rights_filter.domain.records import Evidence, EvidenceSource # noqa: E402
TRUSTED_HOST = "trusted.example"
OUTSIDE_HOST = "outside.example"
# --- 신호 축 -----------------------------------------------------------------
#
# 각 축은 스코어러가 실제로 구분하는 입력만 담는다. provider 를 명시하는 이유는
# v3 부터 풀과 dedup 버킷이 제공자별로 갈리기 때문이다 - 같은 match 타입이라도
# 제공자가 다르면 점수가 달라진다.
WEB_PRESENCE = {
"none": [],
"full": [("full", 0.9)],
"full+partial": [("full", 0.9), ("partial", 0.8)],
"all4": [("full", 0.9), ("partial", 0.8), ("page", 0.8), ("visual", 0.6)],
}
ENTITY_COUNT = {"0": 0, "1": 1, "3": 3}
FACE = {"none": None, "background": "background", "identifiable": "identifiable"}
FINGERPRINT = {"none": None, "weak": 0.5, "strong": 0.95}
NAVER = {"none": False, "promoted": True}
SECOND_PROVIDER = {"none": False, "cse_page": True}
def build_evidence(presence, entities, face, fingerprint, naver, second, host):
evidence = []
for match, confidence in WEB_PRESENCE[presence]:
evidence.append(
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason=f"vision {match}",
confidence=confidence,
data={"match": match, "provider": "google", "host": host},
)
)
for index in range(ENTITY_COUNT[entities]):
evidence.append(
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason=f"entity {index}",
confidence=0.9,
data={"match": "entity", "entity": f"E{index}", "provider": "google"},
)
)
if SECOND_PROVIDER[second]:
# 독립 제공자가 따로 찾은 게시본. v3 의 제공자별 풀이 이것을 Vision 과
# 합산하지 않고 따로 세는지가 여기서 드러난다.
evidence.append(
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="cse page",
confidence=0.35,
data={"match": "page", "provider": "google_custom_search", "host": host},
)
)
if FACE[face]:
evidence.append(
Evidence(
source=EvidenceSource.FACE_PERSON,
reason="face",
confidence=1.0,
data={"tier": FACE[face]},
)
)
if FINGERPRINT[fingerprint] is not None:
evidence.append(
Evidence(
source=EvidenceSource.FINGERPRINT,
reason="knowledge match",
confidence=1.0,
data={"similarity": FINGERPRINT[fingerprint]},
)
)
if NAVER[naver]:
evidence.append(
Evidence(
source=EvidenceSource.NAVER_SEARCH,
reason="naver promoted",
confidence=0.8,
data={"promoted": True},
)
)
return evidence
def combinations():
return itertools.product(
WEB_PRESENCE, ENTITY_COUNT, FACE, FINGERPRINT, NAVER, SECOND_PROVIDER
)
def measure(trusted: bool):
"""trusted=True 면 모든 웹 행이 등록된 신뢰 출처에 게시된 것으로 본다."""
host = TRUSTED_HOST if trusted else OUTSIDE_HOST
scorer = RiskScorer(frozenset({TRUSTED_HOST}) if trusted else None)
bands = Counter()
saturated = 0
web_only_high = 0
web_only_total = 0
rows = []
for presence, entities, face, fingerprint, naver, second in combinations():
evidence = build_evidence(presence, entities, face, fingerprint, naver, second, host)
result = scorer.score(evidence)
bands[result.band] += 1
if result.score == 100:
saturated += 1
# "웹 신호만" = 인물·지식DB·네이버가 전부 없는 조합. v2 가 세운 설계
# 원칙은 이 부류가 high 에 도달하지 않는다는 것이다.
if face == "none" and fingerprint == "none" and naver == "none":
web_only_total += 1
if result.band == "high":
web_only_high += 1
rows.append(
{
"presence": presence,
"entities": entities,
"face": face,
"fingerprint": fingerprint,
"naver": naver,
"second": second,
"score": result.score,
"band": result.band,
}
)
total = sum(bands.values())
return {
"total": total,
"bands": {band: bands.get(band, 0) for band in ("high", "medium", "low")},
"bandPct": {
band: round(100 * bands.get(band, 0) / total, 1) for band in ("high", "medium", "low")
},
"saturated": saturated,
"saturatedPct": round(100 * saturated / total, 1),
"webOnlyHigh": web_only_high,
"webOnlyTotal": web_only_total,
"rows": rows,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true", help="전체 행을 JSON 으로 출력")
args = parser.parse_args()
plain = measure(trusted=False)
trusted = measure(trusted=True)
if args.json:
print(json.dumps({"scorer": SCORER_VERSION, "plain": plain, "trusted": trusted}, ensure_ascii=False))
return 0
# 비ASCII 는 stdout 에 넣지 않는다 (콘솔이 cp949 라 죽은 이력).
print("scorer %s | grid %d combinations" % (SCORER_VERSION, plain["total"]))
print("")
print("%-22s %8s %8s" % ("", "default", "trusted"))
for band in ("high", "medium", "low"):
print(
"%-22s %7d%% %7d%%"
% (band, plain["bandPct"][band], trusted["bandPct"][band])
)
print(
"%-22s %7d%% %7d%%"
% ("saturated (==100)", plain["saturatedPct"], trusted["saturatedPct"])
)
print("")
print(
"web-signal-only high : %d/%d (default) | %d/%d (trusted)"
% (
plain["webOnlyHigh"],
plain["webOnlyTotal"],
trusted["webOnlyHigh"],
trusted["webOnlyTotal"],
)
)
print("")
print("counts default: %s" % plain["bands"])
print("counts trusted: %s" % trusted["bands"])
return 0
if __name__ == "__main__":
sys.exit(main())

144
scripts/sweep_thresholds.py Normal file
View file

@ -0,0 +1,144 @@
"""운영자 판정과 대조해 밴드 임계(기본 70/30)가 맞는지 스윕한다.
**왜 decision_stats 가 아니라 이것인가**
`decision_stats` 의 교차표는 제출물의 **현재** riskBand 를 쓴다. 그런데
`_rescore_submission` 이 14개 지점에서 점수를 덮어쓰고 폴더 리로드마다 큐 전체를
리스코어하므로, 판정한 뒤 이미지를 더 넣으면 이미 판정된 케이스의 밴드가 바뀌어
교차표가 소급 변동한다. 판정 시점의 점수를 보존하는 것은 `decisionSnapshot` 뿐이다.
그래서 이 스크립트는 스냅샷에서 (판정 시점 점수, 판정) 쌍만 뽑아 임계를 스윕한다.
**제외되는 행** — 제외 사유를 반드시 함께 읽어야 한다:
unreviewed 아직 판정 안 함
no_snapshot 판정은 있으나 스냅샷이 없음 (스냅샷 도입 이전)
legacy_pre_stamp 스코어러 스탬프 도입 이전 행. 원점수를 복원할 수 없다
**읽는 법**
over = high 로 분류했는데 운영자가 승인한 건수 → 과탐. 많으면 high 임계가 낮다.
under = low 로 분류했는데 운영자가 반려한 건수 → 미탐. 0 이 아니면 medium 임계가 높다.
분모(high_n / low_n)가 5 미만인 줄은 해석하지 않는다. 특히 low_n 이 0 이면
under 는 언제나 0 이므로 "미탐 없음"이 아니라 "측정 불가"다 — 코퍼스에 저위험으로
떨어지는 이미지가 섞이지 않으면 이 열은 영원히 0 이다.
사용법:
python scripts/sweep_thresholds.py data/corpus.sqlite3
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from collections import Counter
HIGH_CANDIDATES = (50, 60, 70, 80)
MEDIUM_CANDIDATES = (20, 30, 40)
MIN_DENOMINATOR = 5
def collect(db_path: str):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
pairs = []
skipped = Counter()
try:
for row in conn.execute("select payload from submissions"):
submission = json.loads(row["payload"])
decision = submission.get("decisionStatus", "unreviewed")
if decision == "unreviewed":
skipped["unreviewed"] += 1
continue
snapshot = submission.get("decisionSnapshot")
if not snapshot:
skipped["no_snapshot"] += 1
continue
if snapshot.get("scorerVersion") == "legacy":
skipped["legacy_pre_stamp"] += 1
continue
score = snapshot.get("riskScore")
if score is None:
skipped["no_score_in_snapshot"] += 1
continue
pairs.append(
{
"id": str(submission.get("id", "")),
"score": int(score),
"band": snapshot.get("riskBand"),
"decision": decision,
"scorerVersion": snapshot.get("scorerVersion"),
}
)
finally:
conn.close()
return pairs, skipped
def sweep(pairs):
results = []
for high in HIGH_CANDIDATES:
for medium in MEDIUM_CANDIDATES:
if medium >= high:
continue
high_rows = [p for p in pairs if p["score"] >= high]
low_rows = [p for p in pairs if p["score"] < medium]
results.append(
{
"high": high,
"medium": medium,
"highN": len(high_rows),
"overDetection": sum(1 for p in high_rows if p["decision"] == "approved"),
"lowN": len(low_rows),
"underDetection": sum(1 for p in low_rows if p["decision"] == "rejected"),
}
)
return results
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("db", help="판정이 쌓인 SQLite 경로")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
pairs, skipped = collect(args.db)
results = sweep(pairs)
if args.json:
print(json.dumps({"pairs": pairs, "skipped": dict(skipped), "sweep": results}, ensure_ascii=False))
return 0
# 비ASCII 는 stdout 에 넣지 않는다 (콘솔이 cp949 라 죽은 이력).
print("usable pairs: %d skipped: %s" % (len(pairs), dict(skipped)))
if not pairs:
print("")
print("no decisions to sweep yet - see docs/operations/calibration-corpus.md")
return 0
versions = Counter(p["scorerVersion"] for p in pairs)
print("scorer versions in sample: %s" % dict(versions))
if len(versions) > 1:
print("WARNING: mixed scorer versions - scores are not comparable across them")
print("")
print("%-18s %6s %6s %6s %6s" % ("thresholds", "highN", "over", "lowN", "under"))
for item in results:
flag = "" if item["highN"] >= MIN_DENOMINATOR and item["lowN"] >= MIN_DENOMINATOR else " (n<%d)" % MIN_DENOMINATOR
print(
"high>=%-3d med>=%-3d %6d %6d %6d %6d%s"
% (
item["high"],
item["medium"],
item["highN"],
item["overDetection"],
item["lowN"],
item["underDetection"],
flag,
)
)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,94 @@
"""docs/diagrams/manifest.json 을 산출물 실제 바이트에서 다시 쓴다.
수신증의 단일 작성자다. PowerShell 과 Python 이 각자 JSON 을 쓰면 들여쓰기·키 순서가
갈려 매니페스트가 왔다 갔다 하므로, rebuild-diagrams.ps1 도 이 스크립트를 부른다.
단독 실행도 된다: python scripts/update_diagram_manifest.py
"""
from __future__ import annotations
import hashlib
import io
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DIAGRAM_DIR = ROOT / "docs" / "diagrams"
# (타입, 파일 basename). 다이어그램을 추가하면 여기와 tests/test_diagram_sync.py 의
# NODE_COLLECTION 을 함께 고친다.
DIAGRAMS = [
("architecture", "system-architecture"),
("dataflow", "evidence-dataflow"),
("workflow", "operator-review-workflow"),
("workflow", "review-guide"),
("lifecycle", "submission-lifecycle"),
]
NOTE = (
"archify deliver 는 바이트 결정적이다. 이 수신증은 tests/test_diagram_sync.py 가 "
"강제한다 - HTML 을 직접 고치거나 spec 만 고치고 재생성을 잊으면 커밋 게이트가 막는다. "
"갱신은 손으로 하지 말고 scripts/rebuild-diagrams.ps1 로 한다."
)
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _generator_stamp(html: Path) -> str:
match = re.search(
r'<meta name="generator" content="([^"]+)"', html.read_text(encoding="utf-8")
)
if not match:
raise SystemExit(f"{html.name} 에 generator meta 가 없다 - archify 산출물이 맞는가?")
return match.group(1)
def main() -> int:
entries = []
for diagram_type, base in DIAGRAMS:
spec = DIAGRAM_DIR / f"{base}.{diagram_type}.json"
html = DIAGRAM_DIR / f"{base}.html"
for path in (spec, html):
if not path.exists():
raise SystemExit(f"없는 파일: {path}")
entries.append(
{
"type": diagram_type,
"spec": spec.name,
"html": html.name,
"specSha256": _sha256(spec),
"htmlSha256": _sha256(html),
"htmlBytes": html.stat().st_size,
}
)
generators = {_generator_stamp(DIAGRAM_DIR / e["html"]) for e in entries}
if len(generators) != 1:
raise SystemExit(
f"산출물마다 generator 가 다르다: {sorted(generators)} - 전부 재생성해야 한다."
)
manifest = {
"note": NOTE,
"generator": generators.pop(),
"diagrams": entries,
}
target = DIAGRAM_DIR / "manifest.json"
with io.open(target, "w", encoding="utf-8", newline="\n") as handle:
json.dump(manifest, handle, ensure_ascii=False, indent=2)
handle.write("\n")
# 비ASCII 는 stdout 에 넣지 않는다 (콘솔이 cp949 라 UnicodeEncodeError 로 죽은 이력).
print("manifest updated: %d diagrams, generator %s" % (len(entries), manifest["generator"]))
for entry in entries:
print(" %-30s %s.. %d B" % (entry["html"], entry["htmlSha256"][:8], entry["htmlBytes"]))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,3 +1,13 @@
"""In-memory reference implementation of the operator review flow.
NOT wired into the running server: the live operator console persists through
``rights_filter.server.store_operations`` / ``sqlite_store``, which is the source
of truth for decision semantics, audit records and knowledge-entry side effects.
This module backs unit tests and documents the intended domain shape; changing
behavior here does NOT change what operators see. Keep the two in step, or fold
this into the store path, rather than treating this file as production logic.
"""
from __future__ import annotations from __future__ import annotations
from rights_filter.domain.records import ( from rights_filter.domain.records import (

View file

@ -1,3 +1,13 @@
"""In-memory reference implementation of the operator review flow.
NOT wired into the running server: the live operator console persists through
``rights_filter.server.store_operations`` / ``sqlite_store``, which is the source
of truth for decision semantics, audit records and knowledge-entry side effects.
This module backs unit tests and documents the intended domain shape; changing
behavior here does NOT change what operators see. Keep the two in step, or fold
this into the store path, rather than treating this file as production logic.
"""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
@ -54,6 +64,7 @@ def _group_evidence(evidence: list[Evidence]) -> dict[str, list[dict[str, Any]]]
"naver": [], "naver": [],
"google": [], "google": [],
"llm": [], "llm": [],
"skipped": [],
"failures": [], "failures": [],
} }
for item in evidence: for item in evidence:
@ -70,6 +81,11 @@ def _group_name(item: Evidence) -> str:
return "google" return "google"
if item.source == EvidenceSource.LLM_SUMMARY: if item.source == EvidenceSource.LLM_SUMMARY:
return "llm" return "llm"
# A provider that was deliberately not called (quota reached, disabled) is
# operational status, not a failure — grouping the two together made a
# normally-configured case look like it had errored.
if item.source in {EvidenceSource.EXTERNAL_SKIPPED, EvidenceSource.SEARCH_SKIPPED}:
return "skipped"
return "failures" return "failures"

View file

@ -1,3 +1,13 @@
"""In-memory reference implementation of the operator review flow.
NOT wired into the running server: the live operator console persists through
``rights_filter.server.store_operations`` / ``sqlite_store``, which is the source
of truth for decision semantics, audit records and knowledge-entry side effects.
This module backs unit tests and documents the intended domain shape; changing
behavior here does NOT change what operators see. Keep the two in step, or fold
this into the store path, rather than treating this file as production logic.
"""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
@ -22,7 +32,14 @@ def register_manual_entry(
sample_fingerprints: list[str] | None = None, sample_fingerprints: list[str] | None = None,
**payload: Any, **payload: Any,
) -> KnowledgeBaseEntry: ) -> KnowledgeBaseEntry:
# Extra keys were screened for biometric templates and then silently dropped,
# so a caller passing a field this handler does not persist got a success
# response and no entry data. Reject instead of losing the input quietly.
assert_no_biometric_template(payload) assert_no_biometric_template(payload)
if payload:
raise ValueError(
"unsupported knowledge entry fields: " + ", ".join(sorted(payload))
)
entry = create_manual_knowledge_entry( entry = create_manual_knowledge_entry(
entry_type=entry_type, entry_type=entry_type,
name=name, name=name,

View file

@ -1,3 +1,13 @@
"""In-memory reference implementation of the operator review flow.
NOT wired into the running server: the live operator console persists through
``rights_filter.server.store_operations`` / ``sqlite_store``, which is the source
of truth for decision semantics, audit records and knowledge-entry side effects.
This module backs unit tests and documents the intended domain shape; changing
behavior here does NOT change what operators see. Keep the two in step, or fold
this into the store path, rather than treating this file as production logic.
"""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any

View file

@ -1,35 +1,227 @@
from __future__ import annotations from __future__ import annotations
import logging
import os
import threading
from io import BytesIO from io import BytesIO
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from rights_filter.analysis.preprocessing import ImagePayload from rights_filter.analysis.preprocessing import ImagePayload
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] 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) @dataclass(frozen=True)
class FacePersonSignal: class FacePersonSignal:
face_count: int face_count: int
person_count: int person_count: int
face_boxes: tuple[FaceBox, ...] = () 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 @property
def present(self) -> bool: def present(self) -> bool:
return self.face_count > 0 or self.person_count > 0 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: class HeuristicFacePersonDetector:
"""Presence-only detector used by the standalone implementation. """Presence-only detector used by the standalone implementation.
Target applications should replace this with their approved local detector or Prefers YuNet (OpenCV Zoo) when its weights are present and degrades to the
internal service. It intentionally emits no identity or embedding data. 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: 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: try:
import cv2 import cv2
import numpy as np import numpy as np
@ -43,12 +235,33 @@ class HeuristicFacePersonDetector:
if decoded is None: if decoded is None:
return FacePersonSignal(face_count=0, person_count=0) 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/
# analysis pipeline and take down every ingest/re-analysis request.
if not (hasattr(cv2, "CascadeClassifier") and hasattr(cv2, "data")):
global _warned_missing_cascade
if not _warned_missing_cascade:
_warned_missing_cascade = True
logger.warning(
"OpenCV build %s lacks CascadeClassifier/data — face "
"presence detection disabled (install "
"opencv-python-headless==4.13.0.92).",
getattr(cv2, "__version__", "?"),
)
return FacePersonSignal(face_count=0, person_count=0)
try: try:
gray = cv2.cvtColor(decoded, cv2.COLOR_BGR2GRAY) gray = cv2.cvtColor(decoded, cv2.COLOR_BGR2GRAY)
except Exception: except Exception:
return FacePersonSignal(face_count=0, person_count=0) return FacePersonSignal(face_count=0, person_count=0)
face_boxes: list[FaceBox] = [] face_boxes: list[FaceBox] = []
try:
for cascade_name in ( for cascade_name in (
"haarcascade_frontalface_default.xml", "haarcascade_frontalface_default.xml",
"haarcascade_profileface.xml", "haarcascade_profileface.xml",
@ -68,12 +281,16 @@ class HeuristicFacePersonDetector:
_boxes_overlap(box, existing) for existing in face_boxes _boxes_overlap(box, existing) for existing in face_boxes
): ):
face_boxes.append(box) face_boxes.append(box)
except Exception:
# Any cascade/runtime failure degrades to a presence-negative signal
# rather than crashing the caller.
return FacePersonSignal(face_count=0, person_count=0)
face_count = len(face_boxes) return _signal_from_boxes(
return FacePersonSignal( face_boxes,
face_count=face_count, HAAR_FALLBACK_CONFIDENCE,
person_count=face_count, "haar",
face_boxes=tuple(face_boxes), max(0, int(width) * int(height)),
) )
def _boxes_overlap(a: FaceBox, b: FaceBox, threshold: float = 0.5) -> bool: def _boxes_overlap(a: FaceBox, b: FaceBox, threshold: float = 0.5) -> bool:
@ -99,8 +316,10 @@ def _decode_with_pillow(content: bytes, cv2: object, np: object) -> object | Non
try: try:
from PIL import Image from PIL import Image
with Image.open(BytesIO(content)) as image: from rights_filter.analysis.preprocessing import apply_exif_orientation
rgb = image.convert("RGB")
with Image.open(BytesIO(content)) as opened:
rgb = apply_exif_orientation(opened).convert("RGB")
return cv2.cvtColor(np.array(rgb), cv2.COLOR_RGB2BGR) return cv2.cvtColor(np.array(rgb), cv2.COLOR_RGB2BGR)
except Exception: except Exception:
return None return None

View file

@ -26,6 +26,19 @@ class FingerprintService:
if left_value.startswith("unavailable:") or right_value.startswith("unavailable:"): if left_value.startswith("unavailable:") or right_value.startswith("unavailable:"):
return 0.0 return 0.0
if _looks_like_64bit_hash(left_value) and _looks_like_64bit_hash(right_value): if _looks_like_64bit_hash(left_value) and _looks_like_64bit_hash(right_value):
# A near-uniform image (blank, solid colour, very low detail) makes
# this 8x8 average hash collapse to nearly-all-ones or nearly-all-
# zeros. Two such hashes that differ by a bit or two would score
# ~0.98 and be reported as a knowledge-base match worth +80, even
# though neither hash carries usable information. Refuse to infer
# similarity from an uninformative hash.
#
# Identical hashes are handled above and still match: this cannot
# separate "same picture" from "two different blank images" — that
# needs the exact (SHA-256) fingerprint, which callers compare
# separately — but it does remove the near-miss false positives.
if _is_degenerate_hash(left_value) or _is_degenerate_hash(right_value):
return 0.0
distance = (int(left_value, 16) ^ int(right_value, 16)).bit_count() distance = (int(left_value, 16) ^ int(right_value, 16)).bit_count()
return 1 - (distance / 64) return 1 - (distance / 64)
return 0.0 return 0.0
@ -35,8 +48,13 @@ def _perceptual_hash(content: bytes, exact_digest: str) -> str:
try: try:
from PIL import Image from PIL import Image
with Image.open(BytesIO(content)) as image: from rights_filter.analysis.preprocessing import apply_exif_orientation
thumbnail = image.convert("L").resize((8, 8))
with Image.open(BytesIO(content)) as opened:
# Normalize orientation first: the same photo saved rotated with an
# EXIF tag would otherwise hash differently from its upright copy and
# never match as a duplicate.
thumbnail = apply_exif_orientation(opened).convert("L").resize((8, 8))
if hasattr(thumbnail, "get_flattened_data"): if hasattr(thumbnail, "get_flattened_data"):
pixels = list(thumbnail.get_flattened_data()) pixels = list(thumbnail.get_flattened_data())
else: else:
@ -51,6 +69,18 @@ def _perceptual_hash(content: bytes, exact_digest: str) -> str:
return f"{bits:016x}" return f"{bits:016x}"
# A 64-bit average hash is degenerate when nearly every bit agrees: the image had
# almost no variation around its mean, so the bits describe the threshold, not the
# picture. 4 of 64 bits is the practical cut-off — below it, unrelated images
# routinely collide.
_MIN_INFORMATIVE_BITS = 4
def _is_degenerate_hash(value: str) -> bool:
set_bits = int(value, 16).bit_count()
return set_bits <= _MIN_INFORMATIVE_BITS or set_bits >= 64 - _MIN_INFORMATIVE_BITS
def _looks_like_64bit_hash(value: str) -> bool: def _looks_like_64bit_hash(value: str) -> bool:
if len(value) != 16: if len(value) != 16:
return False return False

View file

@ -1,12 +1,18 @@
from __future__ import annotations 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.fingerprints import FingerprintService
from rights_filter.analysis.preprocessing import ImagePayload from rights_filter.analysis.preprocessing import ImagePayload
from rights_filter.analysis.provenance import provenance_evidence
from rights_filter.domain.records import ( from rights_filter.domain.records import (
Evidence, Evidence,
EvidenceSource, EvidenceSource,
InMemoryRightsFilterRepository, InMemoryRightsFilterRepository,
KnowledgeEntryType,
) )
@ -39,6 +45,11 @@ class InternalAnalyzer:
) )
) )
# Read the original bytes here, while they are still in hand: the
# external-derivative build destroys all metadata and nothing read the
# rights fields first.
evidence.extend(provenance_evidence(image.content))
for entry in self.repository.active_knowledge_entries(): for entry in self.repository.active_knowledge_entries():
for sample in entry.sample_fingerprints: for sample in entry.sample_fingerprints:
similarity = self.fingerprint_service.similarity( similarity = self.fingerprint_service.similarity(
@ -46,11 +57,13 @@ class InternalAnalyzer:
) )
if similarity >= self.similarity_threshold: if similarity >= self.similarity_threshold:
entry_status = entry.entry_status or "confirmed" entry_status = entry.entry_status or "confirmed"
reason = ( entry_type = str(entry.entry_type)
f"주의 후보 이미지 유사도 {similarity:.2f}" if entry_type == KnowledgeEntryType.CLEARED_REFERENCE:
if entry_status == "watchlist" reason = f"이전에 클리어된 이미지와 일치 {similarity:.2f}"
else f"Knowledge base image similarity {similarity:.2f}" elif entry_status == "watchlist":
) reason = f"주의 후보 이미지 유사도 {similarity:.2f}"
else:
reason = f"Knowledge base image similarity {similarity:.2f}"
evidence.append( evidence.append(
Evidence( Evidence(
source=EvidenceSource.FINGERPRINT, source=EvidenceSource.FINGERPRINT,
@ -60,6 +73,9 @@ class InternalAnalyzer:
"knowledge_entry_id": entry.id, "knowledge_entry_id": entry.id,
"knowledge_name": entry.name, "knowledge_name": entry.name,
"knowledge_entry_status": entry_status, "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, "source_submission_id": entry.source_submission_id,
"similarity": similarity, "similarity": similarity,
"provenance": entry.provenance, "provenance": entry.provenance,
@ -72,12 +88,11 @@ class InternalAnalyzer:
evidence.append( evidence.append(
Evidence( Evidence(
source=EvidenceSource.FACE_PERSON, source=EvidenceSource.FACE_PERSON,
reason="Face/person detected", reason=face_person_reason(signal),
confidence=0.8, # The detector's own score, not a hardcoded 0.8: a Haar
data={ # fallback must not claim the confidence YuNet earns.
"face_count": signal.face_count, confidence=signal.confidence,
"person_count": signal.person_count, data=face_person_evidence_data(signal),
},
) )
) )

View file

@ -1,16 +1,83 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any from typing import Any
from rights_filter.analysis.llm_prompts import (
CLAIM_TEXT_PLACEHOLDER,
EMPTY_SUMMARY_TEXT,
EVIDENCE_LABEL_PREFIX,
LEGACY_PROMPT_VERSION,
NEEDS_CHECK_HEADING,
NEEDS_CHECK_PLACEHOLDER,
PROMPT_ID,
PROMPT_VERSION,
SUMMARY_HEADING,
SYSTEM_PROMPT,
)
from rights_filter.domain.records import Evidence, EvidenceSource from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.integrations.http_json import UrllibJsonTransport from rights_filter.integrations.http_json import UrllibJsonTransport
logger = logging.getLogger(__name__)
# The model reads at most this many evidence rows. Real cases carry ~49 rows and
# dumping all of them produced 13k-62k character prompts that overran the context
# window: the model saw a fraction while the stored summary still claimed every
# row as its source. Capping the input is what makes the citation honest.
MAX_PROMPT_EVIDENCE = 12
# Single source of truth for the model. env_clients re-exports this rather than
# declaring its own: two defaults that disagree is how the client ended up
# pointing at a model that was never installed while the wiring pointed
# elsewhere, and nothing failed until a request 404'd at runtime.
DEFAULT_MODEL = "qwen2.5:7b"
# Data fields the model is allowed to see. A whitelist rather than a blocklist:
# evidence payloads grow, and a field nobody vetted must not reach the prompt by
# default. Notably absent — promotion_reason (the model copies it verbatim and
# the "summary" becomes a reprint of the evidence list) and the face-crop fields
# (crop geometry and privacy notes are not summarization input).
PROMPT_DATA_FIELDS = (
"match",
"similarity",
"page_title",
# The publishing host, not the provider. "domain" used to sit here and it
# resolved to the API name, so every prompt line read domain=google and the
# model could not say where an image was found — the one thing a rights
# reviewer most needs from it.
"host",
"query",
"entity",
"label",
"knowledge_name",
"tier",
)
@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
# Claims that cited at least one real evidence label. Anything the model
# asserted without a valid citation never reaches this list.
claims: tuple[dict[str, Any], ...] = ()
dropped_claims: int = 0
needs_check: tuple[str, ...] = field(default=())
@dataclass @dataclass
class FakeInternalLlmClient: class FakeInternalLlmClient:
summary: str = "" summary: str = ""
error: Exception | None = None error: Exception | None = None
model: str = "fake-llm"
def summarize_evidence(self, evidence: list[Evidence]) -> str: def summarize_evidence(self, evidence: list[Evidence]) -> str:
if self.error: if self.error:
@ -22,10 +89,13 @@ class OllamaGenerateLlmClient:
def __init__( def __init__(
self, self,
base_url: str = "http://127.0.0.1:11434", base_url: str = "http://127.0.0.1:11434",
model: str = "qwen2.5:0.5b-instruct", model: str = DEFAULT_MODEL,
transport: Any | None = None, transport: Any | None = None,
timeout: int = 30, timeout: int = 120,
connect_timeout: float = 2, connect_timeout: float = 2,
num_ctx: int = 8192,
num_predict: int = 512,
keep_alive: str = "10m",
) -> None: ) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.model = model self.model = model
@ -34,26 +104,59 @@ class OllamaGenerateLlmClient:
# generation still gets the full `timeout`. # generation still gets the full `timeout`.
self.transport = transport or UrllibJsonTransport(connect_timeout=connect_timeout) self.transport = transport or UrllibJsonTransport(connect_timeout=connect_timeout)
self.timeout = timeout self.timeout = timeout
# Ollama defaults the context window to a couple of thousand tokens and
# silently truncates past it. Setting it explicitly is what makes "the
# model read the evidence we cited" true rather than assumed.
self.num_ctx = num_ctx
self.num_predict = num_predict
# Keep the weights resident between cases so the second case in a batch
# does not pay the load again.
self.keep_alive = keep_alive
def summarize_evidence(self, evidence: list[Evidence]) -> str: def summarize_evidence(self, evidence: list[Evidence]) -> str:
return self.summarize_evidence_detailed(evidence).text
def summarize_evidence_detailed(self, evidence: list[Evidence]) -> LlmSummaryResult:
selected = _select_prompt_evidence(evidence)
labels = _evidence_labels(selected)
payload = { payload = {
"model": self.model, "model": self.model,
"system": ( "system": SYSTEM_PROMPT,
"Summarize only the provided source evidence for an internal image " "prompt": _evidence_prompt(selected),
"rights review operator. Do not make a final decision. Do not add "
"claims that are not grounded in source evidence."
),
"prompt": _evidence_prompt(evidence),
"stream": False, "stream": False,
"options": {"temperature": 0.1}, # Constrain decoding to JSON so the citation field is a field, not a
# string the reader has to guess at.
"format": "json",
"keep_alive": self.keep_alive,
"options": {
"temperature": 0.1,
"num_ctx": self.num_ctx,
"num_predict": self.num_predict,
},
} }
started = time.monotonic()
response = self.transport.request_json( response = self.transport.request_json(
"POST", "POST",
self._generate_url(), self._generate_url(),
payload=payload, payload=payload,
timeout=self.timeout, timeout=self.timeout,
) )
return _response_text(response) elapsed_ms = int((time.monotonic() - started) * 1000)
raw = _response_text(response)
claims, dropped, needs_check = _verified_claims(raw, set(labels))
return LlmSummaryResult(
text=_render_summary(claims, needs_check),
# 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,
claims=claims,
dropped_claims=dropped,
needs_check=needs_check,
)
def _generate_url(self) -> str: def _generate_url(self) -> str:
if self.base_url.endswith("/api"): if self.base_url.endswith("/api"):
@ -67,7 +170,7 @@ class InternalLlmAssistant:
def summarize(self, submission_id: str, evidence: list[Evidence]) -> Evidence: def summarize(self, submission_id: str, evidence: list[Evidence]) -> Evidence:
try: try:
summary = self.client.summarize_evidence(evidence) result = self._summarize(evidence)
except Exception as exc: except Exception as exc:
return Evidence( return Evidence(
source=EvidenceSource.ENRICHMENT_FAILURE, source=EvidenceSource.ENRICHMENT_FAILURE,
@ -76,8 +179,42 @@ class InternalLlmAssistant:
data={"submission_id": submission_id}, data={"submission_id": submission_id},
) )
source_urls = _source_urls(evidence) # Provenance for every summary row: which model and which prompt made it,
source_evidence_ids = _source_evidence_ids(evidence) # 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,
# How much of what the model said survived citation checking. A
# rising dropped count is the signal that the model or the prompt
# has stopped working — without it, fabrication is invisible.
"grounded_claims": len(result.claims),
"dropped_claims": result.dropped_claims,
}
# The linked sources must be what the summary actually rests on, not the
# whole case. Deriving them from the full evidence list was the original
# defect: the prompt is capped at MAX_PROMPT_EVIDENCE, so a case with 79
# rows produced a summary read from 12 while the UI reported 78 "linked
# sources". The policy check passed and the number was fiction.
#
# Cited rows first (what the verified claims point at); the shown rows as
# a fallback when nothing verified, so the row is still storable and the
# count still describes something the model actually read.
shown = _select_prompt_evidence(evidence)
labels = _evidence_labels(shown)
cited_labels = {
label for claim in result.claims for label in claim.get("evidence", [])
}
cited = [labels[label] for label in sorted(cited_labels) if label in labels]
grounded = bool(cited)
source_evidence = cited or shown
source_urls = _source_urls(source_evidence)
source_evidence_ids = _source_evidence_ids(source_evidence)
if not source_urls and not source_evidence_ids: if not source_urls and not source_evidence_ids:
return Evidence( return Evidence(
source=EvidenceSource.LLM_SUMMARY, source=EvidenceSource.LLM_SUMMARY,
@ -85,10 +222,11 @@ class InternalLlmAssistant:
confidence=0.0, confidence=0.0,
data={ data={
"submission_id": submission_id, "submission_id": submission_id,
"summary": summary, "summary": result.text,
"source_urls": [], "source_urls": [],
"source_evidence_ids": [], "source_evidence_ids": [],
"verified": False, "verified": False,
**stamp,
}, },
) )
@ -98,13 +236,30 @@ class InternalLlmAssistant:
confidence=0.0, confidence=0.0,
data={ data={
"submission_id": submission_id, "submission_id": submission_id,
"summary": summary, "summary": result.text,
"source_urls": source_urls, "source_urls": source_urls,
"source_evidence_ids": source_evidence_ids, "source_evidence_ids": source_evidence_ids,
"verified": True, # True only when a claim survived citation checking. Previously
# this was True whenever any source existed, which made it a
# restatement of "the case has evidence" rather than a statement
# about the summary.
"verified": grounded,
**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]: def _source_urls(evidence: list[Evidence]) -> list[str]:
urls: list[str] = [] urls: list[str] = []
@ -125,13 +280,149 @@ def _source_evidence_ids(evidence: list[Evidence]) -> list[str]:
return ids return ids
def _evidence_prompt(evidence: list[Evidence]) -> str: def _prompt_priority(item: Evidence) -> int:
lines = ["Source evidence:"] """Lower sorts first. Keeps the rows that carry risk when the cap bites."""
for index, item in enumerate(evidence, start=1): data = item.data
lines.append( if item.source == EvidenceSource.FINGERPRINT:
f"{index}. source={item.source.value}; reason={item.reason}; " try:
f"confidence={item.confidence}; data={item.data}" if float(data.get("similarity") or 0) >= 0.9:
return 0
except (TypeError, ValueError):
pass
return 3
if item.source == EvidenceSource.WEB_DETECTION:
return 1 if data.get("match") in {"full", "partial", "page"} else 3
if item.source == EvidenceSource.NAVER_SEARCH:
return 1 if data.get("promoted") else 3
if item.source == EvidenceSource.FACE_PERSON:
return 2
return 4
def _select_prompt_evidence(evidence: list[Evidence]) -> list[Evidence]:
ordered = sorted(
(item for item in evidence if item.source != EvidenceSource.LLM_SUMMARY),
key=lambda item: (_prompt_priority(item), -float(item.confidence or 0)),
) )
return ordered[:MAX_PROMPT_EVIDENCE]
def _evidence_labels(selected: list[Evidence]) -> dict[str, Evidence]:
return {
f"{EVIDENCE_LABEL_PREFIX}{index}": item
for index, item in enumerate(selected, start=1)
}
def _evidence_prompt(selected: list[Evidence]) -> str:
lines = ["Source evidence:"]
for label, item in _evidence_labels(selected).items():
fields = [f"source={item.source.value}", f"reason={item.reason}"]
for key in PROMPT_DATA_FIELDS:
value = item.data.get(key)
if value not in (None, "", [], {}):
fields.append(f"{key}={value}")
lines.append(f"[{label}] " + "; ".join(fields))
return "\n".join(lines)
def _verified_claims(
raw: str, valid_labels: set[str]
) -> tuple[tuple[dict[str, Any], ...], int, tuple[str, ...]]:
"""Keep only the claims that cite evidence the model was actually given.
Verification is a set-membership test on a JSON field, not an attempt to
parse prose. An earlier design checked entities (URLs, numbers) inside free
text, which passes any confident-sounding sentence that happens to mention a
real URL — and so stamps "근거 확인됨" onto exactly the hallucinations it was
meant to catch.
"""
document = _load_json_object(raw)
if document is None:
logger.warning("LLM returned unparseable output; dropping the summary")
return (), 0, ()
claims: list[dict[str, Any]] = []
dropped = 0
for entry in document.get("claims") or []:
if not isinstance(entry, dict):
dropped += 1
continue
text = str(entry.get("text", "")).strip()
cited = _normalized_labels(entry.get("evidence"))
grounded = [label for label in cited if label in valid_labels]
if not text or not grounded or _is_template_echo(text):
# No text, every cited label was invented, or the model copied the
# shape example through instead of filling it in. Either way there is
# nothing the operator can check it against.
dropped += 1
continue
claims.append({"text": text, "evidence": grounded})
needs_check = tuple(
str(item).strip()
for item in (document.get("needs_check") or [])
if str(item).strip() and not _is_template_echo(str(item))
)
return tuple(claims), dropped, needs_check
def _is_template_echo(text: str) -> bool:
"""True when the model returned our own shape example instead of content.
Citation checking alone does not catch this: qwen2.5:0.5b echoed the
placeholder as the claim text while citing every real label, so the claim was
perfectly "grounded" and completely empty. The placeholder is ours, so the
check can be exact rather than heuristic.
"""
stripped = text.strip()
if CLAIM_TEXT_PLACEHOLDER in stripped or NEEDS_CHECK_PLACEHOLDER in stripped:
return True
# Any other angle-bracket-wrapped stand-in is the same failure.
return stripped.startswith("<") and stripped.endswith(">")
def _load_json_object(raw: str) -> dict[str, Any] | None:
text = (raw or "").strip()
if not text:
return None
try:
parsed = json.loads(text)
except (ValueError, TypeError):
# Some models wrap the object in prose despite format=json. Take the
# outermost brace pair rather than giving up.
start, end = text.find("{"), text.rfind("}")
if start < 0 or end <= start:
return None
try:
parsed = json.loads(text[start : end + 1])
except (ValueError, TypeError):
return None
return parsed if isinstance(parsed, dict) else None
def _normalized_labels(value: Any) -> list[str]:
if isinstance(value, str):
value = [value]
if not isinstance(value, (list, tuple)):
return []
return [str(item).strip().strip("[]").upper() for item in value if str(item).strip()]
def _render_summary(
claims: tuple[dict[str, Any], ...], needs_check: tuple[str, ...]
) -> str:
if not claims and not needs_check:
return EMPTY_SUMMARY_TEXT
lines: list[str] = [SUMMARY_HEADING]
for claim in claims:
lines.append(f"- {claim['text']} [{', '.join(claim['evidence'])}]")
if not claims:
lines.append("- 인용이 확인된 근거가 없습니다.")
if needs_check:
lines.append(NEEDS_CHECK_HEADING)
lines.extend(f"- {item}" for item in needs_check)
return "\n".join(lines) return "\n".join(lines)

View file

@ -0,0 +1,65 @@
"""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 the MODEL INPUT changes in a way that could alter
output — SYSTEM_PROMPT wording, the format contract, the language, or the
PROMPT_DATA_FIELDS whitelist in llm_assistance.py. The whitelist is part of the
prompt even though it lives in another module: changing `domain` to `host` there
rewrote every evidence line the model reads, and leaving the stamp alone made
two different prompts share one version.
"""
from __future__ import annotations
PROMPT_ID = "operator-review-note-ko"
# ko-v4: evidence lines carry host=<publishing host> where ko-v3 carried
# domain=<api name>.
PROMPT_VERSION = "ko-v4-structured-host"
# Marks rows written before prompt versioning existed. Never assume the text
# below produced them.
LEGACY_PROMPT_VERSION = "legacy"
# Evidence is labelled [E1]..[En] in the prompt and the model cites those labels
# rather than the real evidence ids. The ids are 24-hex-char hashes; asking a
# small model to echo them verbatim invites transcription errors that are
# indistinguishable from fabricated citations.
EVIDENCE_LABEL_PREFIX = "E"
# Asking for JSON rather than prose is what makes grounding checkable. A claim
# carries the labels it rests on as a field, so verification is a set-membership
# test on that field — not an attempt to parse Korean sentences and guess which
# words a trailing citation covers.
# The placeholders used in the shape example below. A small model can copy them
# through verbatim instead of filling them in — observed with qwen2.5:0.5b, which
# returned "<한국어 문장>" as the claim text while citing every real label, so the
# citation check passed it. Naming them here lets the verifier reject the echo.
CLAIM_TEXT_PLACEHOLDER = "<한국어 문장>"
NEEDS_CHECK_PLACEHOLDER = "<한국어 구절>"
SYSTEM_PROMPT = (
"You draft review notes for an internal image-rights operator, grounded "
"ONLY in the labelled source evidence given to you.\n"
"Reply with a JSON object and nothing else, in exactly this shape:\n"
'{"claims": [{"text": "' + CLAIM_TEXT_PLACEHOLDER + '", "evidence": ["E1"]}], '
'"needs_check": ["' + NEEDS_CHECK_PLACEHOLDER + '"]}\n'
"Rules:\n"
"- Write every 'text' value in Korean.\n"
"- Every claim MUST cite at least one evidence label it rests on, using "
"only the labels provided. Never invent a label.\n"
"- Never assert anything the cited evidence does not support. If the "
"evidence is thin, say so plainly in a claim citing what little exists.\n"
"- Do NOT state or recommend a final decision (approve / hold / reject).\n"
"- 'needs_check' lists what the operator should verify before deciding.\n"
"- At most 4 claims and 4 needs_check items."
)
# Rendered headings. Kept here so the prompt contract and the text the operator
# reads move together.
SUMMARY_HEADING = "요약:"
NEEDS_CHECK_HEADING = "확인 필요:"
EMPTY_SUMMARY_TEXT = "요약: 근거를 요약할 수 없습니다."

View file

@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import logging
from io import BytesIO from io import BytesIO
from dataclasses import dataclass, field from dataclasses import dataclass, field
from rights_filter.domain.records import DataClass from rights_filter.domain.records import DataClass
logger = logging.getLogger(__name__)
FaceBox = tuple[int, int, int, int] FaceBox = tuple[int, int, int, int]
@ -29,24 +32,31 @@ def build_external_derivative(
if original.width <= 0 or original.height <= 0: if original.width <= 0 or original.height <= 0:
raise PreprocessingError("image dimensions must be positive") raise PreprocessingError("image dimensions must be positive")
longest_side = max(original.width, original.height)
if longest_side > max_side:
scale = max_side / longest_side
width = max(1, round(original.width * scale))
height = max(1, round(original.height * scale))
else:
width = original.width
height = original.height
if original.metadata.get("format", "").upper() == "AVIF":
content = _jpeg_derivative(original.content, width, height)
else:
# Re-encode through PIL to drop embedded EXIF/GPS metadata from the image # Re-encode through PIL to drop embedded EXIF/GPS metadata from the image
# bytes. Clearing only the metadata dict (below) left camera serials and # bytes and to apply the EXIF orientation. Clearing only the metadata dict
# GPS coordinates inside the bytes that get shipped to external APIs. # (below) left camera serials and GPS coordinates inside the bytes shipped to
content = _reencode_without_metadata(original.content, width, height) # external APIs; skipping the orientation shipped rotated phone photos
if content is None: # sideways (and hid faces from the detector, which has no EXIF awareness).
# The output size is derived from the UPRIGHT image, since a 90° rotation
# swaps width/height relative to the stored dimensions.
derived = _upright_derivative(
original.content,
max_side,
force_jpeg=original.metadata.get("format", "").upper() == "AVIF",
)
if derived is None:
# Re-encoding failed (Pillow missing or undecodable bytes): fall back to
# the original bytes, but warn — those bytes may still carry EXIF/GPS
# metadata that is about to be sent to an external API. On an air-gapped
# target without Pillow this fires on every submission.
logger.warning(
"external derivative fell back to original bytes; embedded EXIF/GPS "
"metadata may not have been stripped before external submission"
)
content = original.content content = original.content
width, height = _scaled_size(original.width, original.height, max_side)
else:
content, width, height = derived
return ImagePayload( return ImagePayload(
content=content, content=content,
@ -70,8 +80,10 @@ def build_face_crop_derivatives(
try: try:
from PIL import Image from PIL import Image
with Image.open(BytesIO(original.content)) as image: with Image.open(BytesIO(original.content)) as opened:
image = image.convert("RGB") # Face boxes come from the detector, which sees the upright image —
# crop from the same orientation or the boxes address the wrong pixels.
image = apply_exif_orientation(opened).convert("RGB")
crops: list[ImagePayload] = [] crops: list[ImagePayload] = []
image_width, image_height = image.size image_width, image_height = image.size
for box in face_boxes[:max_crops]: for box in face_boxes[:max_crops]:
@ -100,46 +112,69 @@ def build_face_crop_derivatives(
return [] return []
def _jpeg_derivative(content: bytes, width: int, height: int) -> bytes | None:
try:
from PIL import Image
with Image.open(BytesIO(content)) as image:
image = image.convert("RGB")
if image.size != (width, height):
image = image.resize((width, height))
output = BytesIO()
image.save(output, format="JPEG", quality=90, optimize=True)
return output.getvalue()
except Exception:
return None
_REENCODABLE_FORMATS = {"JPEG", "PNG", "WEBP", "GIF", "BMP", "TIFF"} _REENCODABLE_FORMATS = {"JPEG", "PNG", "WEBP", "GIF", "BMP", "TIFF"}
def _reencode_without_metadata(content: bytes, width: int, height: int) -> bytes | None: def _scaled_size(width: int, height: int, max_side: int) -> tuple[int, int]:
"""Re-save the image (preserving format where possible) so embedded EXIF/GPS longest_side = max(width, height)
metadata is dropped. Returns None when PIL is unavailable or the bytes are if longest_side <= max_side:
not a decodable image, so the caller can fall back to the original content.""" return width, height
scale = max_side / longest_side
return max(1, round(width * scale)), max(1, round(height * scale))
def apply_exif_orientation(image: object) -> object:
"""Rotate/flip an opened PIL image per its EXIF orientation tag.
Every consumer that reasons about pixels (external derivative, face crops,
perceptual hash, face detection) must call this: a phone photo stored
rotated with orientation=6 otherwise reaches the detector on its side, where
upright-trained cascades miss the face entirely.
"""
try:
from PIL import ImageOps
return ImageOps.exif_transpose(image) or image
except Exception:
return image
def _upright_derivative(
content: bytes, max_side: int, force_jpeg: bool = False
) -> tuple[bytes, int, int] | None:
"""Re-save the image upright and metadata-free, capped to `max_side`.
Returns (bytes, width, height) using the orientation-corrected dimensions, or
None when PIL is unavailable / the bytes are not a decodable image so the
caller can fall back to the original content.
"""
try: try:
from PIL import Image from PIL import Image
with Image.open(BytesIO(content)) as image: with Image.open(BytesIO(content)) as opened:
source_format = (image.format or "").upper() source_format = (opened.format or "").upper()
save_format = source_format if source_format in _REENCODABLE_FORMATS else "JPEG" image = apply_exif_orientation(opened)
save_format = (
"JPEG"
if force_jpeg or source_format not in _REENCODABLE_FORMATS
else source_format
)
if save_format in {"JPEG", "BMP"} and image.mode not in {"RGB", "L"}: if save_format in {"JPEG", "BMP"} and image.mode not in {"RGB", "L"}:
image = image.convert("RGB") image = image.convert("RGB")
elif image.mode == "P": elif image.mode == "P":
image = image.convert("RGBA") image = image.convert("RGBA")
width, height = _scaled_size(int(image.width), int(image.height), max_side)
if image.size != (width, height): if image.size != (width, height):
image = image.resize((width, height)) image = image.resize((width, height))
# Rebuild from raw pixels so NO metadata (EXIF/GPS/ICC) survives — # Rebuild from raw pixels so NO metadata (EXIF/GPS/ICC) survives —
# Pillow can otherwise re-emit image.info["exif"] when re-saving. # Pillow can otherwise re-emit image.info["exif"] when re-saving.
clean = Image.frombytes(image.mode, image.size, image.tobytes()) clean = Image.frombytes(image.mode, image.size, image.tobytes())
output = BytesIO() output = BytesIO()
if save_format == "JPEG":
clean.save(output, format="JPEG", quality=90, optimize=True)
else:
clean.save(output, format=save_format) clean.save(output, format=save_format)
return output.getvalue() return output.getvalue(), width, height
except Exception: except Exception:
return None return None

View file

@ -0,0 +1,287 @@
"""Where did this image come from — read from the file itself, no model.
Two signals share one evidence source because they answer the same operator
question ("이 이미지가 어디서 왔나") and because a new EvidenceSource costs a
dozen plumbing edits; paying that once for both is the point.
1. Rights metadata. The copyright holder often writes the answer into the file
(EXIF Copyright/Artist, IPTC CopyrightNotice/Credit/Source). The pipeline
destroys all metadata when it builds the external derivative, and nothing
read it first — so the most authoritative field available was discarded
unread on every submission. This is transcription, not inference.
2. Screen-capture forensics. Deterministic image processing only: letterbox
bars, a subtitle band, broadcast frame sizes. No model, no weights. Tuned for
precision over recall — it ships as a zero-point label, so a miss costs
nothing while a false label costs operator trust before there is any
calibration data to correct it.
Both are read from the ORIGINAL bytes, so the hook has to sit where the original
is still in hand (InternalAnalyzer.analyze), not after derivative building.
"""
from __future__ import annotations
import logging
from io import BytesIO
from typing import Any
from rights_filter.domain.records import Evidence, EvidenceSource
logger = logging.getLogger(__name__)
# Operator-facing names for the rights fields.
RIGHTS_FIELD_LABELS = {
"copyright": "저작권",
"artist": "작성자",
"copyright_notice": "저작권 고지",
"credit": "크레딧",
"source": "출처",
"byline": "작성자",
}
# EXIF tags that state rights. Deliberately short: GPS, camera serial, owner
# name and lens data are NOT read, let alone stored. The whitelist is the
# privacy boundary, so it lives as a constant and is pinned by a test.
RIGHTS_EXIF_TAGS = {
0x8298: "copyright",
0x013B: "artist",
}
# IPTC IIM datasets, keyed (record, dataset).
RIGHTS_IPTC_TAGS = {
(2, 116): "copyright_notice",
(2, 110): "credit",
(2, 115): "source",
(2, 80): "byline",
}
# Frame sizes that essentially only occur as broadcast / screen output. On their
# own they mean little, which is why geometry alone never triggers the label.
BROADCAST_FRAME_SIZES = {
(1920, 1080),
(1280, 720),
(3840, 2160),
(720, 480),
(1440, 1080),
}
# A letterbox bar has to be this dark and this uniform to count.
LETTERBOX_MAX_MEAN = 24.0
LETTERBOX_MAX_STD = 8.0
# ...and cover at least this share of the frame height, top and bottom.
LETTERBOX_MIN_BAR_RATIO = 0.04
# Subtitle band: the bottom strip carries far more edge energy than the frame as
# a whole when burned-in text is present.
SUBTITLE_BAND_TOP = 0.72
SUBTITLE_EDGE_RATIO = 2.5
# Floor only — "there is actually something in the band", so an almost featureless
# frame cannot pass on a ratio computed from near-zero noise. The RATIO is the
# discriminator; a high floor here would reject real single-line subtitles, which
# occupy a small fraction of a band this tall.
SUBTITLE_MIN_EDGE_DENSITY = 0.01
# Independent evidence categories required before the label is emitted. Geometry
# (letterbox + broadcast size) is ONE category: a letterboxed 1920x1080 frame is
# one observation stated twice, and counting it twice would let a cinematic crop
# alone trigger the label.
#
# Consequence, measured 2026-08-08: only two categories exist, so requiring two
# means the label is really "geometry AND burned-in subtitles". A broadcast
# screenshot without subtitles can never be labelled, which is why 56 real images
# produced zero PROVENANCE rows. That is a coverage limit, not a bug — the
# alternative (geometry alone) was rejected above for precision. Lifting it needs
# a genuinely independent third signal (channel bug / lower-third bar), not a
# lower threshold. Until then treat capture forensics as near-zero recall.
MIN_CAPTURE_CATEGORIES = 2
def extract_rights_metadata(content: bytes) -> dict[str, str]:
"""Rights fields the author wrote into the file. Empty dict when there are none."""
if not content:
return {}
try:
from PIL import Image
except Exception:
return {}
found: dict[str, str] = {}
try:
with Image.open(BytesIO(content)) as opened:
found.update(_exif_rights(opened))
found.update(_iptc_rights(opened))
except Exception:
# Unreadable container (e.g. .svg, which the store accepts but Pillow
# cannot open) is "no metadata", not an error worth failing analysis for.
logger.debug("rights metadata extraction skipped", exc_info=True)
return {}
return {key: value for key, value in found.items() if value}
def _exif_rights(opened: Any) -> dict[str, str]:
try:
exif = opened.getexif()
except Exception:
return {}
if not exif:
return {}
values: dict[str, str] = {}
for tag, name in RIGHTS_EXIF_TAGS.items():
value = exif.get(tag)
if value:
values[name] = _clean_text(value)
return values
def _iptc_rights(opened: Any) -> dict[str, str]:
try:
from PIL import IptcImagePlugin
info = IptcImagePlugin.getiptcinfo(opened)
except Exception:
return {}
if not info:
return {}
values: dict[str, str] = {}
for key, name in RIGHTS_IPTC_TAGS.items():
value = info.get(key)
if isinstance(value, (list, tuple)):
value = value[0] if value else None
if value:
values[name] = _clean_text(value)
return values
def _clean_text(value: Any) -> str:
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
return " ".join(str(value).split())[:200]
def detect_screen_capture(content: bytes, width: int = 0, height: int = 0) -> dict[str, Any]:
"""Deterministic screen/broadcast capture signals. No model involved."""
decoded = _decode_gray(content)
if decoded is None:
return {}
frame_height, frame_width = decoded.shape[:2]
categories: dict[str, Any] = {}
geometry: list[str] = []
if _has_letterbox(decoded):
geometry.append("letterbox")
if (frame_width, frame_height) in BROADCAST_FRAME_SIZES:
geometry.append("broadcast_frame_size")
if geometry:
categories["geometry"] = geometry
if _has_subtitle_band(decoded):
categories["subtitle_band"] = True
if len(categories) < MIN_CAPTURE_CATEGORIES:
return {}
return {
"categories": sorted(categories),
"detail": categories,
"frame": f"{frame_width} x {frame_height}",
}
def _decode_gray(content: bytes) -> Any | None:
if not content:
return None
try:
import cv2
import numpy as np
except Exception:
return None
try:
decoded = cv2.imdecode(np.frombuffer(content, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)
except Exception:
return None
if decoded is None or decoded.size == 0:
return None
return decoded
def _has_letterbox(gray: Any) -> bool:
height = gray.shape[0]
bar = max(1, int(height * LETTERBOX_MIN_BAR_RATIO))
if height <= bar * 3:
return False
top, bottom = gray[:bar], gray[-bar:]
return _is_flat_dark(top) and _is_flat_dark(bottom)
def _is_flat_dark(band: Any) -> bool:
return float(band.mean()) <= LETTERBOX_MAX_MEAN and float(band.std()) <= LETTERBOX_MAX_STD
def _has_subtitle_band(gray: Any) -> bool:
try:
import cv2
except Exception:
return False
height = gray.shape[0]
start = int(height * SUBTITLE_BAND_TOP)
if height - start < 8:
return False
edges = cv2.Canny(gray, 100, 200)
band_density = float(edges[start:].mean()) / 255.0
frame_density = float(edges.mean()) / 255.0
if band_density < SUBTITLE_MIN_EDGE_DENSITY:
return False
if frame_density <= 0:
return False
return (band_density / frame_density) >= SUBTITLE_EDGE_RATIO
def provenance_evidence(content: bytes) -> list[Evidence]:
"""Both provenance signals for one image, as evidence rows.
Emitted at zero score (see RiskScorer): they ship before any calibration
data exists, so no weight would be defensible yet. The operator sees them
and decides; decision_stats later shows whether they predict rejections.
"""
evidence: list[Evidence] = []
rights = extract_rights_metadata(content)
if rights:
evidence.append(
Evidence(
source=EvidenceSource.PROVENANCE,
reason=_rights_reason(rights),
# Transcription, not inference — the field either is in the file
# or it is not. That the field itself may be wrong is a separate
# question, which is why it is labelled self-reported.
confidence=1.0,
data={"provenance_kind": "rights_metadata", "rights_fields": rights},
)
)
capture = detect_screen_capture(content)
if capture:
evidence.append(
Evidence(
source=EvidenceSource.PROVENANCE,
reason=f"화면/방송 캡처 추정 ({'·'.join(capture['categories'])})",
confidence=0.6,
data={
"provenance_kind": "screen_capture",
"capture_categories": capture["categories"],
"frame": capture["frame"],
},
)
)
return evidence
def _rights_reason(rights: dict[str, str]) -> str:
parts = [
f"{RIGHTS_FIELD_LABELS.get(key, key)} {value}"
for key, value in rights.items()
]
# Self-reported is not a hedge, it is the accurate description: metadata is
# trivially forgeable, so this must never read as proof of clearance.
return "원본 파일 권리 표기(자기신고): " + " / ".join(parts)

View file

@ -1,13 +1,189 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlparse
from rights_filter.analysis.face_person_detection import TIER_BACKGROUND
from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
# --- v5 축 예산 ----------------------------------------------------------------
#
# 2026-09-22, 실이미지 20장 첫 실측: 20/20 이 100점이었다. 탐지는 정상이었고
# (15장은 웹에서 완전일치 사본이 나왔다) 문제는 순서가 없다는 것이었다 - 큐가
# 점수 내림차순인데 전부 100 이라 정렬이 no-op 이다. 원인은 축별 예산의 **합**이다.
# v2~v4 는 축을 하나씩 캡했지만(웹 게시 60, 라벨 20) 인물 35 를 더하면 캡된 축만으로
# 이미 115 라 상한을 넘고, 캡이 없던 두 축(검색결과 사본 유사도 +80/행, 네이버 승격)이
# 그 위에 쌓였다. 캡 없는 두 축만 캡해도(80/50, 60/40 시뮬레이션) 20/20 이 그대로
# 100 이었다 - 축 하나를 손보는 것으로는 안 되고 예산을 총합 기준으로 다시 짜야 한다.
#
# 등록 권리 대상 일치 80 운영자가 지식DB 에 등록·확정한 판단. 단독 high.
# 주의 후보 일치 40 보류·반려로 자동 생성된 미확정 항목. 단독 medium. (v7)
# 웹 존재 40 사본 · Vision 매치 · CSE 페이지가 한 풀. 단독 medium.
# 인물 30 식별 가능 30 / 배경 10
# 이름 있는 검색 승격 15 네이버 텍스트 검색. 단독으로는 밴드 못 만든다.
# 라벨 10 Vision 엔티티. 단독으로는 밴드 못 만든다.
# -------------------------
# 등록 제외 최대 95 → 100 에 닿지 않아 웹에 있는 이미지들 사이에 순서가 남는다.
# 외부 검색 미수행·실패 하한 30 점수가 아니라 바닥. 아래 FAILURE_FLOOR_POINTS 참조.
#
# 흔한 케이스(웹 사본 + 식별 가능 인물)가 정확히 70 으로 high 문턱에 서고, 그 위로
# 이름 있는 검색·라벨이, 아래로 근사 사본·배경 인물이 순서를 만든다. 웹 신호만으로는
# 최대 65 라 high 에 닿지 않는다 - v2 가 세웠다가 v3 의 제공자별 풀링이 깨뜨린 원칙이다.
#
# "웹 존재"가 한 풀인 이유: Vision 의 full/partial/page/visual, CSE 의 page,
# 검색결과 사본 유사도 1.00 은 전부 "이 이미지가 웹에 있다"는 **하나의 사실**의
# 다른 뷰다. 발견된 URL 수만큼 세면 그 사실이 여러 번 계산된다 - v2 가 Vision 안에서
# 고친 그 오류가 v4 까지 사본 유사도 행(+80/행, 코퍼스 평균 216/장)에 남아 있었다.
# 풀 안에서는 뷰의 강도가 점수를 정한다(완전일치 40 > 근사·부분·페이지 30 > 유사 10).
# dedup 버킷은 v3 대로 제공자별이라 CSE 의 page 행은 Vision 의 page 행에 먹히지 않고
# 사유로 남는다 - 다만 점수는 같은 풀로 간다.
#
# 등록 권리 대상 일치를 웹 풀과 갈라 둔 이유: 그것은 웹의 사실이 아니라 운영자가
# 등록한 판단이고, 단독으로 high 에 닿는 유일한 축이다. 라벨이 그 길을 대신할 수
# 없는 이유는 그대로다 - Vision 은 "Dog" 와 유명인 이름을 구분할 정보를 주지 않고,
# MID 를 카테고리로 푸는 지식그래프 조회는 폐쇄망에서 불가능하다. 알려진 권리 대상은
# 지식DB 로 올라가고, 모르는 유명인은 라벨만으로 high 가 되지 않는다(의도된 미탐).
# `category` 분기를 쓰는 곳 없이 다시 넣지 말 것 - 예전 것은 아무 producer 도 쓰지 않는
# 키를 읽어 유명인이 안전한 것처럼 읽혔다.
FACE_PERSON_IDENTIFIABLE_POINTS = 30
FACE_PERSON_BACKGROUND_POINTS = 10
KNOWLEDGE_MATCH_MAX_POINTS = 80
KNOWLEDGE_MATCH_STRONG_POINTS = 80 # 유사도 0.9 이상
KNOWLEDGE_MATCH_WEAK_POINTS = 30
# 주의 후보(보류·반려로 자동 생성, 운영자 확정 전)와의 일치. "다시 보라"는 뜻이지
# "알려진 권리 대상"이 아니므로 단독 medium. 확정으로 승격하면 위의 80 축이 된다.
# 이 축까지 최대로 나오면 등록 제외 합이 135 라 상한에 닿을 수 있는데, 그 조합
# (주의 후보 + 웹 사본 + 인물 + 검색 + 라벨)은 순서를 잃어도 되는 케이스다.
WATCHLIST_MATCH_MAX_POINTS = 40
WATCHLIST_MATCH_STRONG_POINTS = 40
WATCHLIST_MATCH_WEAK_POINTS = 20
WEB_PRESENCE_MAX_POINTS = 40
WEB_COPY_EXACT_POINTS = 40 # 검색결과 사본 유사도 0.99 이상 · Vision full
WEB_COPY_NEAR_POINTS = 30 # 유사도 0.9~0.99 · Vision partial/page · CSE page · 레거시 url 행
WEB_COPY_WEAK_POINTS = 10 # 유사도 0.9 미만 · Vision visual
NAVER_PROMOTED_POINTS = 15 # 쿼리 버킷당 round(15 * confidence)
NAVER_MAX_POINTS = 15
WEB_ENTITY_LABEL_POINTS = 10 # 라벨당 round(10 * confidence)
WEB_ENTITY_MAX_POINTS = 10
# 외부 검색 실패·미수행은 증거가 아니라 커버리지 공백이다. "확인 못 했다"는 케이스는
# 반드시 사람이 봐야 하므로 medium 하한까지 끌어올리되, 이미 그 위에 있는 케이스에는
# 아무것도 더하지 않는다 - 더하면 네이버 장애 하루가 웹에 있는 모든 이미지를 high 로
# 밀어 올린다. 미수행(일일 한도 도달)도 같은 공백이다: v5 까지는 0점·무사유라 하루
# 21장째부터 검색이 조용히 빠졌고 점수는 낮게 나왔다.
FAILURE_FLOOR_POINTS = 30
# 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.
# v2: uncategorized web entities capped in aggregate (WEB_ENTITY_MAX_POINTS)
# and web-presence match types pooled under WEB_PRESENCE_MAX_POINTS.
# v3: the presence pool and the match-type dedup bucket are both keyed by
# provider. Before v3 a Google custom-search page hit was dropped whole -
# no points, no reason line - whenever Vision had already reported a
# match="page" row, because both rows landed in one global "page" bucket.
# v4: web-detection rows published on an operator-registered trusted host score
# zero. The exclusion is per ROW, not per case - an image that is on our own
# site AND on a scraper blog still counts the scraper.
# v5: budgets reallocated so every axis but a registered match sums to 95;
# search-result copies join ONE web-presence pool (was +80 per row) shared
# across providers; naver hits and labels are pooled at 15 / 10; a provider
# failure is a 30-point floor instead of +30 per row.
# v6: a provider that hit its daily quota before reaching this case shares the
# failure floor and says so; before v6 it scored zero with no reason line.
# v7: an unconfirmed watchlist match (auto-created by a hold or rejection) is
# its own 40-point axis instead of the confirmed 80 - a hold taken because
# the search was down no longer comes back as high by itself.
SCORER_VERSION = "v7"
# 점수 분해에 쓰는 사람이 읽는 이름. 키는 코드가, 라벨은 운영자가 본다. 순서는 위 예산표.
_COMPONENT_LABELS = {
"knowledge": "등록 권리 대상 일치",
"watchlist": "주의 후보 일치 (보류 · 반려 이력)",
"web_presence": "웹 존재 (사본 · 매치)",
"face": "인물",
"naver": "이름 있는 검색 승격",
"web_entity": "웹 엔티티 라벨",
"failure": "외부 검색 미수행 · 실패 (검토 하한)",
}
_POOL_CAPS = {
"knowledge": KNOWLEDGE_MATCH_MAX_POINTS,
"watchlist": WATCHLIST_MATCH_MAX_POINTS,
"web_presence": WEB_PRESENCE_MAX_POINTS,
"naver": NAVER_MAX_POINTS,
"web_entity": WEB_ENTITY_MAX_POINTS,
}
class RiskScorer: class RiskScorer:
def __init__(
self,
trusted_hosts: frozenset[str] | None = None,
host_labels: dict[str, str] | None = None,
) -> None:
"""trusted_hosts: publishing hosts the team owns or licenses.
Supplied by the store from active `trusted_host` knowledge entries. The
default empty set keeps the scorer's behaviour identical to v3, so a
deployment that registers nothing sees no change.
"""
self.trusted_hosts = frozenset(
host for host in (trusted_hosts or frozenset()) if _is_hostname(host)
)
# Display-only categories (news / scraper). They name where a copy was
# found in the headline; they never change the score.
self.host_labels = {
host: label for host, label in (host_labels or {}).items() if _is_hostname(host)
}
def _host_label_of(self, host: str) -> str:
for known, label in self.host_labels.items():
if host == known or host.endswith(f".{known}"):
return label
return ""
def _trusted_host_of(self, item: Evidence) -> str:
"""The registered trusted host this row was published on, if any.
Subdomains of a registered host belong to the same party (a licensed
partner's CDN is still that partner), so a suffix match is intended.
"""
if not self.trusted_hosts:
return ""
host = _evidence_host(item)
if not host:
return ""
for trusted in self.trusted_hosts:
if host == trusted or host.endswith(f".{trusted}"):
return trusted
return ""
def score(self, evidence: list[Evidence]) -> ScoreResult: def score(self, evidence: list[Evidence]) -> ScoreResult:
score = 0
reasons: list[str] = [] reasons: list[str] = []
counted_web_buckets: set[str] = set() counted_web_buckets: set[str] = set()
counted_naver_buckets: set[str] = set()
counted_face_person = False
face_points = 0
failure_seen = False
# 축별 원점수. 상한은 루프가 끝난 뒤 한 번에 건다 - 같은 사실을 몇 행이
# 말하든 축의 예산을 넘지 못한다.
pools: dict[str, int] = {}
excluded: dict[str, int] = {}
# 한 줄 요약(headline)용 집계. 점수에는 관여하지 않는다. 사유 목록은 신호
# 원문의 덤프라 운영자가 첫 1초에 "무엇이 나왔나"를 읽는 역할을 못 한다.
tally = _Tally()
def pool(key: str, points: int) -> None:
pools[key] = pools.get(key, 0) + points
for item in evidence: for item in evidence:
if item.data.get("operator_status") in {"irrelevant", "false_positive"}: if item.data.get("operator_status") in {"irrelevant", "false_positive"}:
@ -15,31 +191,116 @@ class RiskScorer:
if item.source == EvidenceSource.FINGERPRINT: if item.source == EvidenceSource.FINGERPRINT:
if item.data.get("contributed") is False or item.data.get("status") == "queued": if item.data.get("contributed") is False or item.data.get("status") == "queued":
continue continue
similarity = _safe_float(item.data.get("similarity", 0)) # A match against a CLEARED reference is a positive signal: the
if similarity >= 0.9: # team already confirmed this image is safe to use. It must not
score += 80 # add risk. 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":
tally.cleared = True
if item.reason:
reasons.append(item.reason) reasons.append(item.reason)
elif item.reason != "Image fingerprints generated": continue
score += 30 if item.reason == "Image fingerprints generated":
continue
similarity = _safe_float(item.data.get("similarity", 0))
if _is_search_result_copy(item):
# A copy found among search results is the strongest view of
# "this image is on the web" - the same fact Vision's match
# rows report, so it shares their pool. Trusted hosts apply
# here as they do to every other published-on-the-web row.
trusted = self._trusted_host_of(item)
if trusted:
reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외")
excluded[trusted] = excluded.get(trusted, 0) + 1
continue
copy_points = _search_result_copy_points(similarity)
pool("web_presence", copy_points)
tally.presence[_presence_tier(copy_points)].add(_presence_key(item))
elif item.data.get("knowledge_entry_status") == "watchlist":
# A hold or rejection that nobody has confirmed since. It says
# "an operator wanted to see this image again", which earns a
# review (medium) - not "this is a known rights subject". Until
# v7 it scored the confirmed 80, so an image held because the
# search was down came back as high on its own.
pool(
"watchlist",
WATCHLIST_MATCH_STRONG_POINTS if similarity >= 0.9 else WATCHLIST_MATCH_WEAK_POINTS,
)
tally.watchlist_best = max(tally.watchlist_best, similarity)
elif similarity >= 0.9:
# A match against an operator-registered rights subject. Not a
# fact about the web but a judgement the team recorded, so it
# is the one axis that reaches high on its own.
pool("knowledge", KNOWLEDGE_MATCH_STRONG_POINTS)
tally.knowledge_best = max(tally.knowledge_best, similarity)
else:
pool("knowledge", KNOWLEDGE_MATCH_WEAK_POINTS)
tally.knowledge_weak = True
reasons.append(item.reason) reasons.append(item.reason)
elif item.source == EvidenceSource.FACE_PERSON: elif item.source == EvidenceSource.FACE_PERSON:
score += 35 # "A person is present" is one signal no matter how many rows
# assert it. Re-analysis appends a fresh FACE_PERSON row while the
# previous one remains, so without this bucket the same image
# accrued the portrait weight per rerun and drifted into the high
# band on its own.
if counted_face_person:
continue
counted_face_person = True
face_points = _face_person_points(item)
tally.face = _face_label(item)
reasons.append(item.reason) reasons.append(item.reason)
elif item.source == EvidenceSource.WEB_DETECTION: elif item.source == EvidenceSource.WEB_DETECTION:
# Found on a host the team controls or licenses. "It is on the
# web" is only a risk signal when the web in question is not
# ours, so this row contributes nothing - but the operator still
# needs to see WHY the case is quieter than the evidence list
# looks, hence the reason.
trusted = self._trusted_host_of(item)
if trusted:
reasons.append(f"신뢰 출처 {trusted} — 위험 점수에서 제외")
excluded[trusted] = excluded.get(trusted, 0) + 1
continue
points, kind = _web_detection_points(item)
if kind == _WEB_PRESENCE and points:
# Tallied per host BEFORE the dedup bucket: the bucket keeps
# one match type from scoring twice, but "found on 5 hosts"
# is exactly what the headline has to say.
tally.presence[_presence_tier(points)].add(_presence_key(item))
bucket = _web_detection_bucket(item) bucket = _web_detection_bucket(item)
if bucket and bucket in counted_web_buckets: if bucket and bucket in counted_web_buckets:
continue continue
points = _web_detection_points(item) if kind == _WEB_LABEL:
score += points pool("web_entity", points)
tally.labels += 1
elif kind == _WEB_PRESENCE:
pool("web_presence", points)
if points: if points:
reasons.append(item.reason) reasons.append(item.reason)
if bucket: if bucket:
counted_web_buckets.add(bucket) counted_web_buckets.add(bucket)
elif item.source == EvidenceSource.NAVER_SEARCH: elif item.source == EvidenceSource.NAVER_SEARCH:
points = _naver_search_points(item) points = _naver_search_points(item)
score += points if not points:
if points: continue
# Count one promoted hit per executed query. Multiple promoted
# results from the same search are one signal, not N.
bucket = _naver_search_bucket(item)
if bucket and bucket in counted_naver_buckets:
continue
pool("naver", points)
tally.naver_hits += 1
reasons.append(item.reason) reasons.append(item.reason)
if bucket:
counted_naver_buckets.add(bucket)
elif item.source == EvidenceSource.PROVENANCE:
# Zero-point label by design. These signals ship before any
# calibration data exists, so no weight would be defensible yet:
# measure how they correlate with real rejections in
# decision_stats first, then assign points. Surface the reason so
# the operator can act on it even at zero.
if item.reason:
reasons.append(item.reason)
continue
elif item.source == EvidenceSource.LLM_SUMMARY: elif item.source == EvidenceSource.LLM_SUMMARY:
continue continue
elif item.source in { elif item.source in {
@ -51,17 +312,62 @@ class RiskScorer:
# An LLM-summary failure only means narration didn't run; it is # An LLM-summary failure only means narration didn't run; it is
# not a search/coverage gap, so it must not add risk or surface # not a search/coverage gap, so it must not add risk or surface
# as a reason. Genuine provider failures (search / web-detection, # as a reason. Genuine provider failures (search / web-detection,
# e.g. "External API failed") still contribute the +30 signal. # e.g. "External API failed") raise the case to the review floor.
if item.reason.startswith("LLM assistance failed"): if item.reason.startswith("LLM assistance failed"):
continue continue
score += ( if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}:
30 failure_seen = True
if item.source tally.failures += 1
in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}
else 0
)
reasons.append(item.reason) reasons.append(item.reason)
elif _is_transient_skip(item):
# A provider that normally runs hit its daily quota before
# reaching THIS case. To the operator that is the same gap as
# a failed call, and until v6 it passed silently: the 21st
# image of the day scored lower than the 20th for no reason
# anyone could see.
failure_seen = True
tally.skips += 1
reasons.append(f"{_skip_provider(item) or '외부 검색'} 미수행 — {item.reason}")
elif item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.EXTERNAL_SKIPPED}:
# Scores nothing (see below), but a low band with no external
# check behind it must not read as "checked and clean".
tally.not_run = True
# Every other skip applies to every case alike while it lasts -
# "disabled" (no key in this deployment, or the emergency stop the
# console shows as provider state) and the governance switches
# (compliance, metadata logging, online mode). Zero points and no
# reason line: a floor there would make the low band unreachable
# by configuration rather than by evidence.
# Every distinct row still surfaces as a reason; only the combined weight
# of each axis is bounded. Components follow the budget table above so
# the operator reads the panel in the same order the comment explains.
components: list[dict[str, Any]] = []
score = 0
for key in ("knowledge", "watchlist", "web_presence", "face", "naver", "web_entity"):
if key == "face":
if face_points:
score += face_points
components.append({"key": key, "label": _COMPONENT_LABELS[key], "points": face_points})
continue
raw = pools.get(key, 0)
if not raw:
continue
capped = min(raw, _POOL_CAPS[key])
score += capped
components.append(
{"key": key, "label": _COMPONENT_LABELS[key], "points": capped, "raw": raw, "cap": _POOL_CAPS[key]}
)
if failure_seen and score < FAILURE_FLOOR_POINTS:
# 바닥이지 가산이 아니다: 하한까지 모자란 만큼만 채운다. 이미 그 위에
# 있는 케이스는 실패 사유만 남고 점수는 움직이지 않는다.
fill = FAILURE_FLOOR_POINTS - score
score = FAILURE_FLOOR_POINTS
components.append(
{"key": "failure", "label": _COMPONENT_LABELS["failure"], "points": fill, "floor": FAILURE_FLOOR_POINTS}
)
raw_total = score
score = min(100, score) score = min(100, score)
if score >= 70: if score >= 70:
band = "high" band = "high"
@ -70,31 +376,294 @@ class RiskScorer:
else: else:
band = "low" band = "low"
return ScoreResult(score=score, band=band, reasons=_unique(reasons)) breakdown = {
"total": raw_total,
"final": score,
# 100 상한에 걸렸다는 사실 자체가 정보다 - 걸린 케이스들은 서로
# 구별되지 않으므로 우선순위 판단에 쓸 수 없다.
"clamped": raw_total > score,
"components": components,
"excluded": [
{"label": f"신뢰 출처 {host}", "rows": rows}
for host, rows in sorted(excluded.items())
],
"headline": _headline(tally, sum(excluded.values()), self._host_label_of),
}
return ScoreResult(score=score, band=band, reasons=_unique(reasons), breakdown=breakdown)
def _web_detection_points(item: Evidence) -> int: @dataclass
class _Tally:
"""What the headline counts. Scoring never reads it."""
knowledge_best: float = 0.0
knowledge_weak: bool = False
watchlist_best: float = 0.0
cleared: bool = False
presence: dict[str, set[str]] = field(default_factory=lambda: {"exact": set(), "near": set(), "weak": set()})
face: str = ""
naver_hits: int = 0
labels: int = 0
failures: int = 0
skips: int = 0
not_run: bool = False
def _host_breakdown(places: set[str], host_label_of: Any) -> str:
"""" (언론·공식 2 · 무단 게시처 1)" for the places the team has characterised."""
if host_label_of is None:
return ""
counts: dict[str, int] = {}
for place in places:
label = host_label_of(place)
if label:
counts[label] = counts.get(label, 0) + 1
if not counts:
return ""
ordered = [label for label in ("언론·공식", "무단 게시처") if label in counts]
return " (" + " · ".join(f"{label} {counts[label]}" for label in ordered) + ")"
def _presence_tier(points: int) -> str:
if points >= WEB_COPY_EXACT_POINTS:
return "exact"
if points >= WEB_COPY_NEAR_POINTS:
return "near"
return "weak"
def _presence_key(item: Evidence) -> str:
# One "place" per host. A row that names no URL at all counts as its own place.
return _evidence_host(item) or str(item.data.get("url") or item.data.get("result_url") or id(item))
def _face_label(item: Evidence) -> str:
tier = str(item.data.get("tier", "")).strip()
if tier == TIER_BACKGROUND:
return "배경 인물"
return "식별 가능 인물" if tier else "인물"
def _headline(tally: _Tally, excluded_rows: int, host_label_of: Any = None) -> str:
"""운영자가 첫 1초에 읽는 한 줄. 사유 목록의 압축이고 순서는 위 예산표를 따른다.
실 콘솔에서 큐의 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...` 처럼
잘린 영어 조각 둘이었다 - 신호 원문은 개발자용이지 운영자용이 아니다. 여기서는
"웹 완전일치 사본 3곳 · 식별 가능 인물" 처럼 발견된 **사실과 규모**만 말한다.
"""
parts: list[str] = []
if tally.knowledge_best:
parts.append(f"등록 권리 대상 일치 {tally.knowledge_best:.2f}")
elif tally.knowledge_weak:
parts.append("등록 권리 대상 약한 일치")
if tally.watchlist_best:
parts.append(f"주의 후보 일치 {tally.watchlist_best:.2f}")
if tally.cleared:
parts.append("클리어 이력 일치")
for tier, text, unit in (("exact", "웹 완전일치 사본", "곳"), ("near", "웹 부분·페이지 일치", "곳"), ("weak", "웹 유사 이미지", "건")):
places = tally.presence[tier]
if not places:
continue
parts.append(f"{text} {len(places)}{unit}{_host_breakdown(places, host_label_of)}")
break
if tally.face:
parts.append(tally.face)
if tally.naver_hits:
parts.append(f"이름 있는 검색 승격 {tally.naver_hits}건")
if tally.labels:
parts.append(f"라벨 {tally.labels}개")
if tally.failures:
parts.append("외부 검색 실패")
if tally.skips:
parts.append("일일 한도로 일부 미수행")
elif tally.not_run:
parts.append("외부 검색 꺼짐")
if excluded_rows:
parts.append(f"신뢰 출처 제외 {excluded_rows}행")
return " · ".join(parts) if parts else "신호 없음"
def _is_search_result_copy(item: Evidence) -> bool:
"""A FINGERPRINT row that matched a SEARCH RESULT image, not a knowledge entry.
store_search_candidates writes those rows with the result `url` and the
`source_evidence_ids` of the search hit they came from; a knowledge match
(internal_analyzer) carries `knowledge_entry_id` and neither of those. Both
shapes survive the persistence round-trip under the same keys. A row with
nothing but a similarity is read as a knowledge match, which is what every
caller that predates search-result matching meant by it.
"""
if item.data.get("knowledge_entry_id"):
return False
return bool(item.data.get("source_evidence_ids") or item.data.get("url"))
# The one policy refusal that differs from one case to the next within a day and
# is visible nowhere else. "disabled" is NOT here on purpose: it is the reason
# both for a provider with no key in this deployment and for the emergency stop,
# and the console already shows either as provider state; the governance
# switches (compliance, metadata logging, online mode) never vary between cases.
_TRANSIENT_SKIP_MARKERS = ("usage limit",)
def _is_transient_skip(item: Evidence) -> bool:
reason = item.reason.casefold()
return any(marker in reason for marker in _TRANSIENT_SKIP_MARKERS)
def has_coverage_gap(evidence: list[Evidence]) -> bool:
"""True when an external check that normally runs did not run for this case.
The store asks this before turning a HOLD into a watchlist entry: a case held
because the search was down is "look again after the search is back", not
"this image is suspect" - and a watchlist entry would say the latter to every
later submission of the same image.
"""
for item in evidence:
if item.data.get("operator_status") in {"irrelevant", "false_positive"}:
continue
if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}:
if not item.reason.startswith("LLM assistance failed"):
return True
elif item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.EXTERNAL_SKIPPED}:
if _is_transient_skip(item):
return True
return False
def _skip_provider(item: Evidence) -> str:
# Rows restored from persistence lose `provider` but keep `domain`, which held
# the provider name ("internal" when there was none) - the round trip has to
# produce the same reason line the fresh row did.
provider = str(item.data.get("provider", "")).strip()
if not provider:
provider = str(item.data.get("domain", "")).strip()
return "" if provider == "internal" else provider
def _search_result_copy_points(similarity: float) -> int:
if similarity >= 0.99:
return WEB_COPY_EXACT_POINTS
if similarity >= 0.9:
return WEB_COPY_NEAR_POINTS
return WEB_COPY_WEAK_POINTS
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
# How a web-detection row's points are combined with the rest.
_WEB_PRESENCE = "presence" # pooled under WEB_PRESENCE_MAX_POINTS
_WEB_LABEL = "label" # pooled under WEB_ENTITY_MAX_POINTS
_WEB_STANDALONE = "standalone" # added on its own
def _is_hostname(value: str) -> bool:
"""A hostname we can match on has a label separator.
Without this a trusted entry of "com" would clear the whole web, and a
provider name like "google" would match nothing while looking as if it did.
"""
text = str(value).strip().lower()
return bool(text) and "." in text and " " not in text
def _evidence_host(item: Evidence) -> str:
"""Where this row says the image is published.
Mirrors the priority in store_serialization._result_host, which is what
writes the persisted `host` key: the resolved host wins, a provider-supplied
display host (CSE `domain`) comes next, and a URL is parsed only as a
fallback. The two live apart on purpose - that one persists a value, this one
matches against an operator list - and neither may import the other's layer.
"""
data = item.data
host = str(data.get("host", "")).strip().lower()
if _is_hostname(host):
return host.removeprefix("www.")
domain = str(data.get("domain", "")).strip().lower()
if _is_hostname(domain):
return domain.removeprefix("www.")
for key in ("url", "result_url", "page_url", "source_page_url", "image_url"):
raw = str(data.get(key, "")).strip()
if not raw:
continue
try:
parsed = urlparse(raw if "://" in raw else f"//{raw}").hostname or ""
except ValueError:
continue
if _is_hostname(parsed):
return parsed.lower().removeprefix("www.")
return ""
def _web_detection_points(item: Evidence) -> tuple[int, str]:
"""Points for one web-detection row, and which pool they belong to.
Every presence view lands in the one web-presence pool; only the strength
of the view differs. Bare Vision entities describe the subject rather than
its rights and pool separately.
"""
if item.data.get("weak_hint"): if item.data.get("weak_hint"):
return 0 return 0, _WEB_STANDALONE
if item.data.get("match") == "full": # Google custom IMAGE search results (match="search_image") are context-only:
return 45 # existence of a text-query image hit is not itself a rights signal (the
if item.data.get("match") in {"partial", "page"}: # matching pHash FINGERPRINT evidence carries the score). Fresh evidence has
return 35 # no bare "url" key so it scores 0, but the persistence round-trip aliases
if item.data.get("match") == "visual": # result_url -> url; without this explicit gate the round-trip would flip the
return 10 # same evidence into the url fallback below, violating the
if item.data.get("url"): # score-preservation invariant and inflating bands on any query that returns
return 45 # anything.
if item.data.get("category") in {"character", "celebrity", "work"}: match = item.data.get("match")
return 45 if match == "search_image":
return 0, _WEB_STANDALONE
if match == "full":
return WEB_COPY_EXACT_POINTS, _WEB_PRESENCE
if match in {"partial", "page"}:
return WEB_COPY_NEAR_POINTS, _WEB_PRESENCE
if match == "visual":
return WEB_COPY_WEAK_POINTS, _WEB_PRESENCE
# Legacy rows only: every current producer sets `match`, so a web-detection
# row that has a URL and no match type was written by an older build. It
# keeps a presence weight rather than being dropped - and it must stay below
# the `match` checks above, because the persistence round-trip aliases
# result_url -> url on every row.
if not match and item.data.get("url"):
return WEB_COPY_NEAR_POINTS, _WEB_PRESENCE
if item.data.get("entity"): if item.data.get("entity"):
return round(40 * item.confidence) return round(WEB_ENTITY_LABEL_POINTS * item.confidence), _WEB_LABEL
return 0 return 0, _WEB_STANDALONE
def _web_presence_pool(item: Evidence) -> str:
# Rows restored from persistence lose `provider` but keep `domain`, which
# held the provider name; an unattributable row pools on its own rather than
# joining someone else's response.
provider = str(item.data.get("provider", "")).strip()
if not provider:
provider = str(item.data.get("domain", "")).strip()
return provider or "unattributed"
def _web_detection_bucket(item: Evidence) -> str: def _web_detection_bucket(item: Evidence) -> str:
match = str(item.data.get("match", "")) match = str(item.data.get("match", ""))
if match in {"full", "partial", "page", "visual"}: if match in {"full", "partial", "page", "visual"}:
return match # Scoped by provider: two providers each reporting a hosting page are two
# findings. Unscoped, the second one deduped away silently.
return f"{_web_presence_pool(item)}:{match}"
# Distinct named entities are independent risk signals (e.g. two different # Distinct named entities are independent risk signals (e.g. two different
# celebrities/characters in one image), so they must not share one "entity" # celebrities/characters in one image), so they must not share one "entity"
# bucket and dedup each other away. Key the bucket by the entity name; only # bucket and dedup each other away. Key the bucket by the entity name; only
@ -103,16 +672,26 @@ def _web_detection_bucket(item: Evidence) -> str:
if match == "entity" or entity: if match == "entity" or entity:
return f"entity:{entity.casefold()}" if entity else "entity" return f"entity:{entity.casefold()}" if entity else "entity"
if item.data.get("url"): if item.data.get("url"):
return "url" return f"{_web_presence_pool(item)}:url"
return "" return ""
def _naver_search_points(item: Evidence) -> int: def _naver_search_points(item: Evidence) -> int:
if item.data.get("promoted"): if item.data.get("promoted"):
return round(50 * item.confidence) return round(NAVER_PROMOTED_POINTS * item.confidence)
return 0 return 0
def _naver_search_bucket(item: Evidence) -> str:
# Dedup promoted Naver hits by the query that produced them. Distinct queries
# remain distinct signals; repeated hits from one query collapse to one.
signature = str(item.data.get("query_signature", "")).strip()
if signature:
return f"naver:{signature}"
query = str(item.data.get("query", "")).strip().casefold()
return f"naver-query:{query}" if query else ""
def _unique(values: list[str]) -> list[str]: def _unique(values: list[str]) -> list[str]:
result: list[str] = [] result: list[str] = []
for value in values: for value in values:

View file

@ -14,12 +14,26 @@ class SearchResultPromoter:
if item.source != EvidenceSource.NAVER_SEARCH: if item.source != EvidenceSource.NAVER_SEARCH:
return item return item
if item.reason == "Naver search returned no results": # Structural no-result detection covers the image/blog/web variants
return replace(item, confidence=0.0, data={**item.data, "promoted": False}) # (each emits a different "... returned no results" reason). A real hit
# always carries at least one result field; a no-result item carries
# none, so it must never be promoted even if its query text contained a
# rights marker.
if not _has_result_content(item.data):
return replace(
item,
confidence=0.0,
data={**item.data, "promoted": False, "promotion_reason": "no search results"},
)
# Scan only the human-readable result fields — never the query and never
# the URL. The query is our own generated input (it would self-promote any
# result whose query contained a marker word), and a URL's host/path
# routinely contains marker substrings for unrelated reasons
# ("/game/", "album" in a CDN path, a "cover" directory), which promoted
# irrelevant results to 0.8 confidence and +40 points.
text = " ".join( text = " ".join(
str(item.data.get(key, "")) str(item.data.get(key, "")) for key in ("title", "description")
for key in ("title", "description", "query", "result_url")
).lower() ).lower()
promoted = _has_named_rights_signal(text) promoted = _has_named_rights_signal(text)
data = { data = {
@ -32,9 +46,15 @@ class SearchResultPromoter:
return replace(item, confidence=0.8 if promoted else 0.2, data=data) return replace(item, confidence=0.8 if promoted else 0.2, data=data)
def _has_named_rights_signal(text: str) -> bool: def _has_result_content(data: dict) -> bool:
markers = { return any(data.get(key) for key in ("title", "description", "result_url", "image_url"))
"iu",
# Markers that indicate a named person / work / rights context. English markers
# plus the Korean equivalents actually emitted by Naver results and by this
# project's own query templates, since Naver is a Korean-language provider and an
# English-only list produces systematic false negatives.
_RIGHTS_MARKERS = {
"official", "official",
"album", "album",
"cover", "cover",
@ -45,8 +65,33 @@ def _has_named_rights_signal(text: str) -> bool:
"drama", "drama",
"copyright", "copyright",
"celebrity", "celebrity",
"goods",
"공식",
"앨범",
"커버",
"캐릭터",
"웹툰",
"화보",
"저작권",
"연예인",
"아이돌",
"굿즈",
"포스터",
# NOTE: intentionally NOT including bare '게임'/'방송' — as CJK substrings they
# over-match benign Korean text ('방송통신위원회', casual game blogs) and
# over-promote. Their English counterparts (game/broadcast) stay, matched on
# word boundaries.
} }
# Match whole words only. Plain `in` matching let short markers like "iu"
# or "cover" hit inside unrelated words/URLs ("premium", "discover"),
# falsely promoting context-only results. def _has_named_rights_signal(text: str) -> bool:
return any(re.search(rf"\b{re.escape(marker)}\b", text) for marker in markers) # ASCII markers match whole words only (so short ones like "cover" don't hit
# inside "discover"); CJK markers have no word boundaries in regex, so match
# them as substrings (Korean has no spaces between a marker and its context).
for marker in _RIGHTS_MARKERS:
if marker.isascii():
if re.search(rf"\b{re.escape(marker)}\b", text):
return True
elif marker in text:
return True
return False

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from enum import StrEnum from enum import StrEnum
from itertools import count from itertools import count
from typing import Any from typing import Any
@ -16,6 +16,10 @@ def _new_id(prefix: str) -> str:
class EvidenceSource(StrEnum): class EvidenceSource(StrEnum):
FINGERPRINT = "fingerprint" FINGERPRINT = "fingerprint"
FACE_PERSON = "face_person" FACE_PERSON = "face_person"
# Where the image came from, read from the file itself: rights metadata the
# author embedded, and deterministic screen-capture signals. One source for
# both because they answer the same operator question.
PROVENANCE = "provenance"
WEB_DETECTION = "web_detection" WEB_DETECTION = "web_detection"
NAVER_SEARCH = "naver_search" NAVER_SEARCH = "naver_search"
LLM_SUMMARY = "llm_summary" LLM_SUMMARY = "llm_summary"
@ -33,6 +37,22 @@ class KnowledgeEntryType(StrEnum):
WEBTOON = "webtoon" WEBTOON = "webtoon"
GAME = "game" GAME = "game"
REJECTED_IMAGE = "rejected_image" 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"
# A publishing host the team controls or licenses (own site, contracted
# partner, CDN). Finding an image THERE is not evidence of infringement, so
# web-detection rows from that host score zero instead of +45 "it is on the
# web". The entry's name is the host; aliases carry the party's other hosts.
TRUSTED_HOST = "trusted_host"
# Publishing hosts the team has characterised, for display only - they do not
# change the score (no decision data yet says how much they should). A copy on
# a news / official host usually points at the original rights holder; one on
# a known scraper is a republication. Operators opened every link to tell the
# two apart (persona review 2026-09-25, 운영자 1-B).
NEWS_HOST = "news_host"
SCRAPER_HOST = "scraper_host"
OTHER = "other" OTHER = "other"
@ -58,6 +78,16 @@ class DataClass(StrEnum):
LLM_SUMMARY = "llm_summary" LLM_SUMMARY = "llm_summary"
PROVIDER_METADATA = "provider_metadata" PROVIDER_METADATA = "provider_metadata"
OPERATOR_NOTE = "operator_note" OPERATOR_NOTE = "operator_note"
# Rights fields transcribed from the submitted file (EXIF/IPTC) plus
# deterministic capture signals. Self-reported by whoever made the file, so
# it is treated as a claim to verify, never as proof.
RIGHTS_METADATA = "rights_metadata"
# 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) @dataclass(frozen=True)
@ -73,6 +103,11 @@ class ScoreResult:
score: int score: int
band: str band: str
reasons: list[str] = field(default_factory=list) reasons: list[str] = field(default_factory=list)
# How the number was arrived at. reasons say WHICH evidence spoke; this says
# what each kind contributed and where a pool ceiling or the trusted-host
# exclusion swallowed points. Without it a case with 51 evidence rows and a
# score of 35 is unexplainable to the operator who has to act on it.
breakdown: dict[str, Any] = field(default_factory=dict)
@dataclass @dataclass
@ -242,9 +277,36 @@ class InMemoryRightsFilterRepository:
def create_rejected_image_entry( def create_rejected_image_entry(
self, decision_id: str, submission_id: str, fingerprints: list[str] self, decision_id: str, submission_id: str, fingerprints: list[str]
) -> KnowledgeBaseEntry: ) -> KnowledgeBaseEntry:
name = f"rejected:{submission_id}"
# One submission has one rejection reference. Re-deciding the same case
# previously appended another entry each time, so a submission rejected
# N times seeded N duplicate reference images into every later similarity
# check. Refresh the existing entry in place instead.
incumbent = next(
(
entry
for entry in self._knowledge_entries.values()
if entry.active
and entry.entry_type == KnowledgeEntryType.REJECTED_IMAGE
and entry.name == name
),
None,
)
if incumbent is not None:
merged = list(
dict.fromkeys([*incumbent.sample_fingerprints, *fingerprints])
)
entry = replace(
incumbent,
source_decision_id=decision_id,
sample_fingerprints=merged,
)
self.save_knowledge_entry(entry)
return entry
entry = KnowledgeBaseEntry.create( entry = KnowledgeBaseEntry.create(
entry_type=KnowledgeEntryType.REJECTED_IMAGE, entry_type=KnowledgeEntryType.REJECTED_IMAGE,
name=f"rejected:{submission_id}", name=name,
provenance=KnowledgeProvenance.AUTOMATIC_REJECTION, provenance=KnowledgeProvenance.AUTOMATIC_REJECTION,
source_decision_id=decision_id, source_decision_id=decision_id,
sample_fingerprints=fingerprints, sample_fingerprints=fingerprints,

View file

@ -76,6 +76,22 @@ class GovernancePolicyRegistry:
"delete with decision audit policy", "delete with decision audit policy",
"append correction rather than overwriting audit trail", "append correction rather than overwriting audit trail",
), ),
DataClass.RIGHTS_METADATA: DataClassPolicy(
{"operator", "admin"},
"evidence retention policy",
"delete with analysis evidence policy",
"re-extract from the corrected original",
),
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 +99,80 @@ class GovernancePolicyRegistry:
return self._policies[data_class] 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: def assert_no_biometric_template(payload: dict[str, Any]) -> None:
forbidden_keys = {"embedding", "face_embedding", "biometric_template"} if _BIOMETRIC_KEYS.intersection(payload):
if forbidden_keys.intersection(payload):
raise ValueError("biometric template storage is not allowed") 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( def assert_operator_evidence_payload_allowed(
data_class: DataClass, payload: dict[str, Any] data_class: DataClass, payload: dict[str, Any]
) -> None: ) -> None:

View file

@ -2,12 +2,11 @@ from __future__ import annotations
import base64 import base64
from typing import Any from typing import Any
from urllib.parse import quote_plus
from rights_filter.analysis.preprocessing import ImagePayload from rights_filter.analysis.preprocessing import ImagePayload
from rights_filter.domain.records import Evidence, EvidenceSource from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.integrations.external_policy import ExternalApiPolicy from rights_filter.integrations.external_policy import ExternalApiPolicy
from rights_filter.integrations.http_json import UrllibJsonTransport from rights_filter.integrations.http_json import HttpJsonError, UrllibJsonTransport
class FakeWebDetectionClient: class FakeWebDetectionClient:
@ -54,11 +53,22 @@ class GoogleVisionRestClient:
payload["parent"] = self.parent payload["parent"] = self.parent
response = self.transport.request_json( response = self.transport.request_json(
"POST", "POST",
f"{self.endpoint}?key={quote_plus(self.api_key)}", self.endpoint,
# Pass the key as a header so it never appears in the request URL
# (which would surface in tracebacks / logs on error).
headers={"X-goog-api-key": self.api_key},
payload=payload, payload=payload,
timeout=self.timeout, timeout=self.timeout,
) )
web_detection = (response.get("responses") or [{}])[0].get("webDetection", {}) first = (response.get("responses") or [{}])[0]
error = first.get("error")
if error:
# Vision returns per-image errors inside a 200 response. Surfacing it
# as an exception lets the adapter record a FAILURE instead of a
# silent "no results" (which also wrongly debited quota).
message = error.get("message") if isinstance(error, dict) else str(error)
raise HttpJsonError(f"Vision per-image error: {message or 'unknown error'}")
web_detection = first.get("webDetection", {})
return { return {
"web_entities": [ "web_entities": [
{ {
@ -102,6 +112,10 @@ class CloudVisionWebDetectionAdapter:
source=EvidenceSource.EXTERNAL_SKIPPED, source=EvidenceSource.EXTERNAL_SKIPPED,
reason=reason or "external API skipped", reason=reason or "external API skipped",
confidence=1.0, confidence=1.0,
# Deliberately NOT provider-tagged: a policy refusal means the
# call never ran, and the provider badge derives "failed" from
# any attributed failure-source row. Leaving skips unattributed
# keeps the badge at "not run", which is what actually happened.
data={"submission_id": submission_id}, data={"submission_id": submission_id},
) )
] ]
@ -114,14 +128,27 @@ class CloudVisionWebDetectionAdapter:
source=EvidenceSource.FAILURE, source=EvidenceSource.FAILURE,
reason=f"External API failed: {exc}", reason=f"External API failed: {exc}",
confidence=1.0, confidence=1.0,
data={"submission_id": submission_id}, data={"submission_id": submission_id, "provider": "google"},
) )
] ]
# Record the quota debit only after the request actually succeeds, so a # Record the quota debit only after the request actually succeeds, so a
# failed/timed-out call does not permanently consume the daily limit. # failed/timed-out call does not permanently consume the daily limit.
policy.record_call() policy.record_call()
try:
return _map_response(submission_id, response) return _map_response(submission_id, response)
except Exception as exc:
# A malformed/unexpected response shape must not abort the caller
# after the quota was already spent — surface it as failure evidence
# like a transport error does.
return [
Evidence(
source=EvidenceSource.FAILURE,
reason=f"External API response could not be parsed: {exc}",
confidence=1.0,
data={"submission_id": submission_id, "provider": "google"},
)
]
def _map_response(submission_id: str, response: dict[str, Any]) -> list[Evidence]: def _map_response(submission_id: str, response: dict[str, Any]) -> list[Evidence]:
@ -198,6 +225,9 @@ def _map_response(submission_id: str, response: dict[str, Any]) -> list[Evidence
) )
for label in response.get("best_guess_labels", []): for label in response.get("best_guess_labels", []):
# Tolerate a bare string / non-mapping entry instead of raising on .get.
if not isinstance(label, dict):
continue
text = label.get("label", "") text = label.get("label", "")
evidence.append( evidence.append(
Evidence( Evidence(

View file

@ -1,10 +1,15 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any, Mapping from typing import Any, Mapping
from rights_filter.analysis.llm_assistance import InternalLlmAssistant, OllamaGenerateLlmClient from rights_filter.analysis.llm_assistance import (
DEFAULT_MODEL,
InternalLlmAssistant,
OllamaGenerateLlmClient,
)
from rights_filter.integrations.cloud_vision_web_detection import ( from rights_filter.integrations.cloud_vision_web_detection import (
CloudVisionWebDetectionAdapter, CloudVisionWebDetectionAdapter,
GoogleVisionRestClient, GoogleVisionRestClient,
@ -17,6 +22,19 @@ from rights_filter.integrations.google_custom_search import (
from rights_filter.integrations.naver_search import NaverOpenApiSearchClient, NaverSearchAdapter from rights_filter.integrations.naver_search import NaverOpenApiSearchClient, NaverSearchAdapter
from rights_filter.integrations.search_policy import SearchApiPolicy from rights_filter.integrations.search_policy import SearchApiPolicy
logger = logging.getLogger(__name__)
# Chosen by measurement against real stored evidence (54 rows, 12 selected) on
# the GPU target, not by parameter count:
# qwen2.5:0.5b-instruct 55.7s — echoed the shape example as its claim text
# qwen2.5:7b 8.8s — held the contract, 2 grounded claims
# qwen3:8b 35.7s — held the contract but 4x slower (thinking)
# 7b is also already present on the build machine, so the default costs no
# additional model transfer across the air gap.
#
# Re-exported, not redeclared: the client owns the value so the two cannot drift.
DEFAULT_OLLAMA_MODEL = DEFAULT_MODEL
@dataclass @dataclass
class ProviderRuntime: class ProviderRuntime:
@ -46,10 +64,12 @@ def build_provider_runtime(
google_adapter = _google_adapter(env, transport) google_adapter = _google_adapter(env, transport)
google_custom_search_adapter = _google_custom_search_adapter(env, transport) google_custom_search_adapter = _google_custom_search_adapter(env, transport)
llm_assistant = _llm_assistant(env, transport) llm_assistant = _llm_assistant(env, transport)
search_limit = _optional_int(env.get("COPYRIGHTER_NAVER_DAILY_LIMIT")) search_limit = _daily_limit(env.get("COPYRIGHTER_NAVER_DAILY_LIMIT"), "COPYRIGHTER_NAVER_DAILY_LIMIT")
google_limit = _optional_int(env.get("COPYRIGHTER_GOOGLE_DAILY_LIMIT")) google_limit = _daily_limit(env.get("COPYRIGHTER_GOOGLE_DAILY_LIMIT"), "COPYRIGHTER_GOOGLE_DAILY_LIMIT")
google_custom_search_limit = _optional_int(env.get("COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT")) google_custom_search_limit = _daily_limit(
llm_limit = _optional_int(env.get("COPYRIGHTER_LLM_DAILY_LIMIT")) env.get("COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT"), "COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT"
)
llm_limit = _daily_limit(env.get("COPYRIGHTER_LLM_DAILY_LIMIT"), "COPYRIGHTER_LLM_DAILY_LIMIT")
face_crop_search_enabled = _truthy(env.get("COPYRIGHTER_GOOGLE_FACE_CROP_SEARCH")) face_crop_search_enabled = _truthy(env.get("COPYRIGHTER_GOOGLE_FACE_CROP_SEARCH"))
auto_naver_query_limit = _bounded_int( auto_naver_query_limit = _bounded_int(
env.get("COPYRIGHTER_AUTO_NAVER_QUERY_LIMIT"), env.get("COPYRIGHTER_AUTO_NAVER_QUERY_LIMIT"),
@ -173,7 +193,7 @@ def build_provider_runtime(
"llm", "llm",
"Ollama local LLM", "Ollama local LLM",
llm_assistant is not None, llm_assistant is not None,
f"Ollama local API configured ({env.get('OLLAMA_MODEL', 'qwen2.5:0.5b-instruct')})", f"Ollama local API configured ({env.get('OLLAMA_MODEL', DEFAULT_OLLAMA_MODEL)})",
llm_limit, llm_limit,
"없음", "없음",
"source-linked summary only", "source-linked summary only",
@ -193,13 +213,13 @@ def _naver_adapter(env: Mapping[str, str], transport: Any | None) -> NaverSearch
client_id=client_id, client_id=client_id,
client_secret=client_secret, client_secret=client_secret,
transport=transport, transport=transport,
display=_optional_int(env.get("NAVER_SEARCH_DISPLAY")) or 10, display=_bounded_int(env.get("NAVER_SEARCH_DISPLAY"), default=10, minimum=1, maximum=100),
sort=env.get("NAVER_SEARCH_SORT", "sim"), sort=env.get("NAVER_SEARCH_SORT", "sim"),
image_pages=_bounded_int(env.get("NAVER_SEARCH_PAGES"), default=1, minimum=1, maximum=10), image_pages=_bounded_int(env.get("NAVER_SEARCH_PAGES"), default=1, minimum=1, maximum=10),
blog_display=_optional_int(env.get("NAVER_BLOG_SEARCH_DISPLAY")) or 3, blog_display=_bounded_int(env.get("NAVER_BLOG_SEARCH_DISPLAY"), default=3, minimum=1, maximum=100),
blog_sort=env.get("NAVER_BLOG_SEARCH_SORT", "sim"), blog_sort=env.get("NAVER_BLOG_SEARCH_SORT", "sim"),
blog_pages=_bounded_int(env.get("NAVER_BLOG_SEARCH_PAGES"), default=1, minimum=1, maximum=10), blog_pages=_bounded_int(env.get("NAVER_BLOG_SEARCH_PAGES"), default=1, minimum=1, maximum=10),
web_display=_optional_int(env.get("NAVER_WEB_SEARCH_DISPLAY")) or 3, web_display=_bounded_int(env.get("NAVER_WEB_SEARCH_DISPLAY"), default=3, minimum=1, maximum=100),
web_pages=_bounded_int(env.get("NAVER_WEB_SEARCH_PAGES"), default=1, minimum=1, maximum=10), web_pages=_bounded_int(env.get("NAVER_WEB_SEARCH_PAGES"), default=1, minimum=1, maximum=10),
) )
return NaverSearchAdapter(client) return NaverSearchAdapter(client)
@ -239,8 +259,15 @@ def _google_custom_search_adapter(
def _llm_assistant(env: Mapping[str, str], transport: Any | None) -> InternalLlmAssistant | None: def _llm_assistant(env: Mapping[str, str], transport: Any | None) -> InternalLlmAssistant | None:
client = OllamaGenerateLlmClient( client = OllamaGenerateLlmClient(
base_url=env.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434"), base_url=env.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434"),
model=env.get("OLLAMA_MODEL", "qwen2.5:0.5b-instruct"), # 0.5B could not hold the output contract: it answered in English and
# reprinted the evidence list instead of summarizing it. Reading evidence
# and citing it needs a 3B-class model.
model=env.get("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL),
transport=transport, transport=transport,
timeout=_bounded_int(env.get("OLLAMA_TIMEOUT"), default=120, minimum=5, maximum=600),
num_ctx=_bounded_int(env.get("OLLAMA_NUM_CTX"), default=8192, minimum=1024, maximum=131072),
num_predict=_bounded_int(env.get("OLLAMA_NUM_PREDICT"), default=512, minimum=64, maximum=4096),
keep_alive=env.get("OLLAMA_KEEP_ALIVE", "10m"),
) )
return InternalLlmAssistant(client) return InternalLlmAssistant(client)
@ -283,6 +310,23 @@ def _optional_int(value: str | None) -> int | None:
return None return None
def _daily_limit(value: str | None, env_name: str) -> int | None:
# Unset means "no daily limit" (intentional). A malformed non-empty value
# must NOT silently mean unlimited (fail-open) — fall back to a conservative
# cap and warn so the misconfiguration is visible.
if not value:
return None
try:
return max(0, int(value))
except (ValueError, TypeError):
logger.warning(
"%s is not an integer (%r); applying a conservative daily limit of 100",
env_name,
value,
)
return 100
def _bounded_int(value: str | None, default: int, minimum: int, maximum: int) -> int: def _bounded_int(value: str | None, default: int, minimum: int, maximum: int) -> int:
if not value: if not value:
return default return default

View file

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass import threading
from dataclasses import dataclass, field
from datetime import date
@dataclass @dataclass
@ -11,8 +13,24 @@ class ExternalApiPolicy:
allow_online_sync: bool = False allow_online_sync: bool = False
daily_limit: int | None = None daily_limit: int | None = None
calls_made: int = 0 calls_made: int = 0
window_day: str = ""
_lock: threading.Lock = field(
default_factory=threading.Lock, repr=False, compare=False
)
def _roll_window_locked(self) -> None:
# daily_limit is a per-day budget. Reset the counter when the calendar
# day changes; otherwise the count only ever grows and, once the limit is
# hit, web detection stays disabled until the process restarts (it never
# re-windows for the next day). Mirrors SearchApiPolicy.
today = date.today().isoformat()
if self.window_day != today:
self.window_day = today
self.calls_made = 0
def can_call(self) -> tuple[bool, str | None]: def can_call(self) -> tuple[bool, str | None]:
with self._lock:
self._roll_window_locked()
if self.disabled: if self.disabled:
return False, "external API disabled" return False, "external API disabled"
if not self.compliance_approved: if not self.compliance_approved:
@ -26,4 +44,6 @@ class ExternalApiPolicy:
return True, None return True, None
def record_call(self) -> None: def record_call(self) -> None:
with self._lock:
self._roll_window_locked()
self.calls_made += 1 self.calls_made += 1

View file

@ -63,6 +63,10 @@ class GoogleCustomSearchClient:
items: list[Any] = [] items: list[Any] = []
for page_index in range(max(1, pages)): for page_index in range(max(1, pages)):
params = { params = {
# The Custom Search JSON API's documented auth is the `key` query
# parameter; keep it here rather than an X-goog-api-key header
# (unverified for this endpoint — a wrong choice silently breaks
# search). Vision, which is verified, uses the header.
"key": self.api_key, "key": self.api_key,
"cx": self.cx, "cx": self.cx,
"q": query, "q": query,
@ -98,7 +102,7 @@ class GoogleCustomSearchAdapter:
raise ValueError("Google custom search requires a text query") raise ValueError("Google custom search requires a text query")
requested_calls = _requested_calls(self.client, "image_pages") requested_calls = _requested_calls(self.client, "image_pages")
allowed, reason = policy.can_call(self.provider, requested_calls=requested_calls) allowed, reason = policy.try_reserve(self.provider, requested_calls=requested_calls)
if not allowed: if not allowed:
return [ return [
Evidence( Evidence(
@ -115,8 +119,18 @@ class GoogleCustomSearchAdapter:
) )
] ]
try:
try: try:
response = self.client.search_image(query) response = self.client.search_image(query)
except Exception:
# The request never reached the provider — refund the
# reservation. A failure AFTER a successful fetch keeps the
# debit, since the provider did serve the call.
policy.release(requested_calls)
raise
# Map inside the try so a malformed payload becomes ENRICHMENT_FAILURE
# evidence instead of an uncaught exception that aborts the request.
return _map_image_response(submission_id, query, response)
except Exception as exc: # pragma: no cover - defensive adapter boundary except Exception as exc: # pragma: no cover - defensive adapter boundary
return [ return [
Evidence( Evidence(
@ -133,9 +147,6 @@ class GoogleCustomSearchAdapter:
) )
] ]
policy.record_call(requested_calls)
return _map_image_response(submission_id, query, response)
def search_web_pages( def search_web_pages(
self, submission_id: str, query: str, policy: SearchApiPolicy self, submission_id: str, query: str, policy: SearchApiPolicy
) -> list[Evidence]: ) -> list[Evidence]:
@ -143,7 +154,7 @@ class GoogleCustomSearchAdapter:
raise ValueError("Google custom search requires a text query") raise ValueError("Google custom search requires a text query")
requested_calls = _requested_calls(self.client, "web_pages") requested_calls = _requested_calls(self.client, "web_pages")
allowed, reason = policy.can_call(self.provider, requested_calls=requested_calls) allowed, reason = policy.try_reserve(self.provider, requested_calls=requested_calls)
if not allowed: if not allowed:
return [ return [
Evidence( Evidence(
@ -160,8 +171,16 @@ class GoogleCustomSearchAdapter:
) )
] ]
try:
try: try:
response = self.client.search_web(query) response = self.client.search_web(query)
except Exception:
# The request never reached the provider — refund the
# reservation. A failure AFTER a successful fetch keeps the
# debit, since the provider did serve the call.
policy.release(requested_calls)
raise
return _map_web_response(submission_id, query, response)
except Exception as exc: # pragma: no cover - defensive adapter boundary except Exception as exc: # pragma: no cover - defensive adapter boundary
return [ return [
Evidence( Evidence(
@ -178,9 +197,6 @@ class GoogleCustomSearchAdapter:
) )
] ]
policy.record_call(requested_calls)
return _map_web_response(submission_id, query, response)
def _map_image_response( def _map_image_response(
submission_id: str, query: str, response: dict[str, Any] submission_id: str, query: str, response: dict[str, Any]

View file

@ -5,13 +5,32 @@ import socket
from typing import Any from typing import Any
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit from urllib.parse import urlsplit
from urllib.request import Request, urlopen from urllib.request import Request, getproxies, proxy_bypass, urlopen
def _proxy_applies(scheme: str, host: str) -> bool:
"""True when the system proxy configuration would route `host` for `scheme`."""
try:
if scheme not in getproxies():
return False
return not proxy_bypass(host)
except Exception:
return False
class HttpJsonError(RuntimeError): class HttpJsonError(RuntimeError):
pass pass
# Cap the response body so a runaway/hostile API (or a misrouted large payload)
# cannot exhaust memory. JSON API responses are small; 10 MiB is generous.
_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
# An HTTP error body only ever becomes part of an exception message, so keep it
# small — the full 10 MiB cap would put a whole error page into a log line.
_MAX_ERROR_DETAIL_BYTES = 4096
class UrllibJsonTransport: class UrllibJsonTransport:
def __init__(self, connect_timeout: float | None = None) -> None: def __init__(self, connect_timeout: float | None = None) -> None:
# When set, probe TCP connectivity with this (short) timeout before the # When set, probe TCP connectivity with this (short) timeout before the
@ -36,12 +55,27 @@ class UrllibJsonTransport:
request.add_header("Content-Type", "application/json") request.add_header("Content-Type", "application/json")
try: try:
with urlopen(request, timeout=timeout) as response: with urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8") raw_bytes = response.read(_MAX_RESPONSE_BYTES + 1)
if len(raw_bytes) > _MAX_RESPONSE_BYTES:
raise HttpJsonError(
f"response body exceeded {_MAX_RESPONSE_BYTES} bytes"
)
# JSON must be UTF-8; decode leniently so a malformed byte raises a
# controlled HttpJsonError at json.loads rather than a bare
# UnicodeDecodeError escaping the transport contract.
raw = raw_bytes.decode("utf-8", errors="replace")
except HTTPError as exc: except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace") try:
# Apply the same cap on the error path: an error page can be far
# larger than a JSON reply, and it is only used for a message.
detail = exc.read(_MAX_ERROR_DETAIL_BYTES).decode("utf-8", errors="replace")
except Exception:
detail = ""
raise HttpJsonError(f"HTTP {exc.code}: {detail}") from exc raise HttpJsonError(f"HTTP {exc.code}: {detail}") from exc
except URLError as exc: except URLError as exc:
raise HttpJsonError(str(exc.reason)) from exc raise HttpJsonError(str(exc.reason)) from exc
except (OSError, socket.timeout) as exc:
raise HttpJsonError(f"request failed: {exc}") from exc
if not raw: if not raw:
return {} return {}
@ -55,6 +89,12 @@ class UrllibJsonTransport:
host = parts.hostname host = parts.hostname
if not host: if not host:
return return
# A direct TCP probe bypasses the system proxy. On a proxy-only network
# the probe always fails while the actual (proxied) request would have
# succeeded, so skip the fast-fail check when a proxy applies to this host
# and let urlopen route through it.
if _proxy_applies(parts.scheme, host):
return
port = parts.port or (443 if parts.scheme == "https" else 80) port = parts.port or (443 if parts.scheme == "https" else 80)
try: try:
with socket.create_connection((host, port), timeout=self.connect_timeout): with socket.create_connection((host, port), timeout=self.connect_timeout):

View file

@ -162,7 +162,7 @@ class NaverSearchAdapter:
raise ValueError("Naver search requires a text query") raise ValueError("Naver search requires a text query")
requested_calls = _requested_calls(self.client, "image_pages") requested_calls = _requested_calls(self.client, "image_pages")
allowed, reason = policy.can_call(self.provider, requested_calls=requested_calls) allowed, reason = policy.try_reserve(self.provider, requested_calls=requested_calls)
if not allowed: if not allowed:
return [ return [
Evidence( Evidence(
@ -178,9 +178,15 @@ class NaverSearchAdapter:
) )
] ]
try:
try: try:
response = self.client.search_image(query) response = self.client.search_image(query)
policy.record_call(requested_calls) except Exception:
# The request never reached the provider — refund the
# reservation. A failure AFTER a successful fetch keeps the
# debit, since the provider did serve the call.
policy.release(requested_calls)
raise
return _map_response(submission_id, query, response) return _map_response(submission_id, query, response)
except Exception as exc: # pragma: no cover - defensive adapter boundary except Exception as exc: # pragma: no cover - defensive adapter boundary
return [ return [
@ -204,7 +210,7 @@ class NaverSearchAdapter:
raise ValueError("Naver search requires a text query") raise ValueError("Naver search requires a text query")
requested_calls = _requested_calls(self.client, "blog_pages") requested_calls = _requested_calls(self.client, "blog_pages")
allowed, reason = policy.can_call(self.provider, requested_calls=requested_calls) allowed, reason = policy.try_reserve(self.provider, requested_calls=requested_calls)
if not allowed: if not allowed:
return [ return [
Evidence( Evidence(
@ -221,9 +227,15 @@ class NaverSearchAdapter:
) )
] ]
try:
try: try:
response = self.client.search_blog(query) response = self.client.search_blog(query)
policy.record_call(requested_calls) except Exception:
# The request never reached the provider — refund the
# reservation. A failure AFTER a successful fetch keeps the
# debit, since the provider did serve the call.
policy.release(requested_calls)
raise
return _map_blog_response(submission_id, query, response) return _map_blog_response(submission_id, query, response)
except Exception as exc: # pragma: no cover - defensive adapter boundary except Exception as exc: # pragma: no cover - defensive adapter boundary
return [ return [
@ -248,7 +260,7 @@ class NaverSearchAdapter:
raise ValueError("Naver search requires a text query") raise ValueError("Naver search requires a text query")
requested_calls = _requested_calls(self.client, "web_pages") requested_calls = _requested_calls(self.client, "web_pages")
allowed, reason = policy.can_call(self.provider, requested_calls=requested_calls) allowed, reason = policy.try_reserve(self.provider, requested_calls=requested_calls)
if not allowed: if not allowed:
return [ return [
Evidence( Evidence(
@ -265,9 +277,15 @@ class NaverSearchAdapter:
) )
] ]
try:
try: try:
response = self.client.search_web(query) response = self.client.search_web(query)
policy.record_call(requested_calls) except Exception:
# The request never reached the provider — refund the
# reservation. A failure AFTER a successful fetch keeps the
# debit, since the provider did serve the call.
policy.release(requested_calls)
raise
return _map_web_response(submission_id, query, response) return _map_web_response(submission_id, query, response)
except Exception as exc: # pragma: no cover - defensive adapter boundary except Exception as exc: # pragma: no cover - defensive adapter boundary
return [ return [

View file

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date
@dataclass @dataclass
@ -10,8 +12,53 @@ class SearchApiPolicy:
allowed_providers: set[str] = field(default_factory=lambda: {"naver"}) allowed_providers: set[str] = field(default_factory=lambda: {"naver"})
daily_limit: int | None = None daily_limit: int | None = None
calls_made: int = 0 calls_made: int = 0
window_day: str = ""
_lock: threading.Lock = field(
default_factory=threading.Lock, repr=False, compare=False
)
def _roll_window_locked(self) -> None:
# daily_limit is a per-day budget. Reset the counter when the calendar
# day changes; otherwise the count only ever grows and, once the limit is
# hit, the provider stays disabled until the process restarts (it never
# re-windows for the next day).
today = date.today().isoformat()
if self.window_day != today:
self.window_day = today
self.calls_made = 0
def try_reserve(self, provider: str, requested_calls: int = 1) -> tuple[bool, str | None]:
"""Atomically check the budget and debit it in one locked step.
Checking and debiting separately let concurrent operator requests both
pass the check before either recorded, overshooting daily_limit by the
number of racing callers. Callers refund with release() when the request
never actually reached the provider.
"""
with self._lock:
self._roll_window_locked()
if self.disabled:
return False, "search API disabled"
if not self.compliance_approved:
return False, "search API compliance not approved"
if provider not in self.allowed_providers:
return False, "search provider not allowed"
calls = max(1, int(requested_calls or 1))
if self.daily_limit is not None and self.calls_made + calls > self.daily_limit:
return False, "search API usage limit reached"
self.calls_made += calls
return True, None
def release(self, count: int = 1) -> None:
"""Return an unused reservation (the request never reached the provider)."""
with self._lock:
self._roll_window_locked()
self.calls_made = max(0, self.calls_made - max(1, int(count or 1)))
def can_call(self, provider: str, requested_calls: int = 1) -> tuple[bool, str | None]: def can_call(self, provider: str, requested_calls: int = 1) -> tuple[bool, str | None]:
"""Non-reserving budget check, for callers that only need to look."""
with self._lock:
self._roll_window_locked()
if self.disabled: if self.disabled:
return False, "search API disabled" return False, "search API disabled"
if not self.compliance_approved: if not self.compliance_approved:
@ -24,4 +71,6 @@ class SearchApiPolicy:
return True, None return True, None
def record_call(self, count: int = 1) -> None: def record_call(self, count: int = 1) -> None:
with self._lock:
self._roll_window_locked()
self.calls_made += max(1, int(count or 1)) self.calls_made += max(1, int(count or 1))

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from rights_filter.analysis.internal_analyzer import InternalAnalyzer from rights_filter.analysis.internal_analyzer import InternalAnalyzer
@ -14,6 +15,8 @@ from rights_filter.domain.records import (
from rights_filter.integrations.cloud_vision_web_detection import CloudVisionWebDetectionAdapter from rights_filter.integrations.cloud_vision_web_detection import CloudVisionWebDetectionAdapter
from rights_filter.integrations.external_policy import ExternalApiPolicy from rights_filter.integrations.external_policy import ExternalApiPolicy
logger = logging.getLogger(__name__)
@dataclass(frozen=True) @dataclass(frozen=True)
class SubmissionImage: class SubmissionImage:
@ -86,6 +89,17 @@ class BatchAnalyzer:
# has_analysis_run() skip the submission permanently on re-runs. # has_analysis_run() skip the submission permanently on re-runs.
failed += 1 failed += 1
continue continue
except Exception:
# Isolate any unexpected per-submission failure (e.g. an OpenCV
# build missing CascadeClassifier, a decoder edge case) so one
# bad image cannot abort the whole batch/seed. Log with the
# submission id for diagnosis instead of swallowing silently.
logger.exception(
"Unexpected analysis failure for submission %s; skipping.",
submission.submission_id,
)
failed += 1
continue
external_skipped += sum( external_skipped += sum(
1 for item in evidence if item.source == EvidenceSource.EXTERNAL_SKIPPED 1 for item in evidence if item.source == EvidenceSource.EXTERNAL_SKIPPED

View file

@ -2,13 +2,17 @@ from __future__ import annotations
import argparse import argparse
import os import os
import sys
from pathlib import Path 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.integrations.env_clients import build_provider_runtime
from rights_filter.server.env_file import load_env_file from rights_filter.server.env_file import load_env_file
from rights_filter.server.http_app import build_server from rights_filter.server.http_app import build_server
from rights_filter.server.image_store import LocalSubmissionImageStore from rights_filter.server.image_store import LocalSubmissionImageStore
from rights_filter.server.store_backup import restore_backup
from rights_filter.server.sqlite_store import CopyrighterStore from rights_filter.server.sqlite_store import CopyrighterStore
from rights_filter.version import release_version
def _provider_readiness(runtime) -> str: def _provider_readiness(runtime) -> str:
@ -24,6 +28,24 @@ def _provider_readiness(runtime) -> str:
def main() -> None: def main() -> None:
# When the server is started as a service with stdout redirected to a file,
# Python switches to block buffering and the startup diagnostics below -
# including the "token not set" security warning - sit in the buffer
# indefinitely (the process never exits). Line-buffer so they reach the log
# immediately.
#
# errors="replace" is not cosmetic: the delivery target is a Korean Windows
# host whose console codepage is cp949, and a single character outside it
# (an em dash in a warning string was enough) raised UnicodeEncodeError from
# print() and killed the server before it ever bound the port. A diagnostic
# message must never be able to prevent startup - degrade the character, not
# the process.
try:
sys.stdout.reconfigure(line_buffering=True, errors="replace")
sys.stderr.reconfigure(errors="replace")
except (AttributeError, OSError, ValueError):
pass
parser = argparse.ArgumentParser(description="Run the local Copyrighter API server.") parser = argparse.ArgumentParser(description="Run the local Copyrighter API server.")
parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=9500) parser.add_argument("--port", type=int, default=9500)
@ -31,9 +53,27 @@ def main() -> None:
parser.add_argument("--images", default="data/submissions") parser.add_argument("--images", default="data/submissions")
parser.add_argument("--static", default="web/operator-gui") parser.add_argument("--static", default="web/operator-gui")
parser.add_argument("--env", default=".env") parser.add_argument("--env", default=".env")
parser.add_argument(
"--restore",
metavar="SNAPSHOT_ZIP",
help="Restore a backup snapshot into --db and exit (server must be stopped).",
)
args = parser.parse_args() args = parser.parse_args()
load_env_file(Path(args.env), os.environ) if args.restore:
# Restore is offline on purpose: swapping the database under a running
# server would leave open connections on a file that no longer exists.
result = restore_backup(args.restore, Path(args.db))
print(f"Restored database: {result['database']}")
print(f"Restored media files: {result['mediaFiles']}")
return
env_path = Path(args.env)
load_env_file(env_path, os.environ)
# Log the resolved env path (and whether it was found) so an operator running
# from an unexpected directory can see which .env - if any - was actually
# loaded, rather than silently getting no token / disabled providers.
print(f".env: {env_path.resolve()} ({'loaded' if env_path.exists() else 'not found'})")
provider_runtime = build_provider_runtime(os.environ) provider_runtime = build_provider_runtime(os.environ)
print("Provider readiness: " + _provider_readiness(provider_runtime)) print("Provider readiness: " + _provider_readiness(provider_runtime))
image_store = LocalSubmissionImageStore(Path(args.images)) image_store = LocalSubmissionImageStore(Path(args.images))
@ -43,17 +83,29 @@ def main() -> None:
auth_token = os.environ.get("COPYRIGHTER_AUTH_TOKEN", "").strip() or None auth_token = os.environ.get("COPYRIGHTER_AUTH_TOKEN", "").strip() or None
if auth_token is None: if auth_token is None:
print("WARNING: COPYRIGHTER_AUTH_TOKEN not set — data routes are unauthenticated.") print("WARNING: COPYRIGHTER_AUTH_TOKEN not set - data routes are unauthenticated.")
elif len(auth_token) < 32:
print(
"WARNING: COPYRIGHTER_AUTH_TOKEN is shorter than 32 characters - "
"use a longer, high-entropy token (e.g. `python -c \"import secrets; print(secrets.token_urlsafe(32))\"`)."
)
static_path = Path(args.static)
if not static_path.exists():
print(
f"WARNING: static GUI directory not found: {static_path.resolve()} - "
"the operator UI will return 404. Pass --static or run from the repo root."
)
server = build_server( server = build_server(
host=args.host, host=args.host,
port=args.port, port=args.port,
store=store, store=store,
image_store=image_store, image_store=image_store,
static_dir=Path(args.static), static_dir=static_path,
auth_token=auth_token, auth_token=auth_token,
) )
print(f"Copyrighter API server listening on http://{args.host}:{args.port}") print(f"Copyrighter {release_version()} (scorer {SCORER_VERSION}) listening on http://{args.host}:{args.port}")
print(f"SQLite DB: {Path(args.db).resolve()}") print(f"SQLite DB: {Path(args.db).resolve()}")
print(f"Submission images: {Path(args.images).resolve()}") print(f"Submission images: {Path(args.images).resolve()}")
server.serve_forever() server.serve_forever()

View file

@ -0,0 +1,107 @@
"""Failed-authentication throttling for the operator server.
``COPYRIGHTER_AUTH_TOKEN`` is the only gate in front of the data routes, and
nothing else slows down guessing it: every wrong bearer token costs an attacker
one cheap request. This adds a fixed-window cap on *failed* attempts per client
address, so an online brute force stops being viable while a working operator —
whose attempts succeed, clearing the counter — never meets it.
Deliberately only failures are counted. Throttling successful traffic would
punish an operator working through a review queue for no security gain.
Deployment note: the client identity is the peer address. On the intended
single-host / closed-network deployment that is the real client. Behind a
reverse proxy every request would share one address, and one bad actor could
then throttle the operator too — put the proxy in front of a token it controls,
or terminate closer to the console, if that ever becomes the topology.
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
# A wrong token ten times in a minute is not an operator typo pattern; it is a
# script. Generous enough that a person fumbling a paste never notices.
MAX_FAILURES_PER_WINDOW = 10
WINDOW_SECONDS = 60
# Bound the table so a spoofed-source flood cannot grow it without limit. Far
# above the number of clients a single-operator console legitimately sees.
MAX_TRACKED_CLIENTS = 1024
@dataclass
class _Window:
failures: int
started_at: float
class FailedAuthRateLimiter:
"""Fixed-window cap on failed auth attempts, keyed by client address."""
def __init__(
self,
max_failures: int = MAX_FAILURES_PER_WINDOW,
window_seconds: int = WINDOW_SECONDS,
max_tracked_clients: int = MAX_TRACKED_CLIENTS,
clock=time.monotonic,
) -> None:
self._max_failures = max(1, max_failures)
self._window_seconds = max(1, window_seconds)
self._max_tracked_clients = max(1, max_tracked_clients)
self._clock = clock
self._lock = threading.Lock()
self._windows: dict[str, _Window] = {}
def retry_after(self, client: str) -> int:
"""Seconds the client must wait, or 0 when it may attempt now."""
with self._lock:
window = self._windows.get(client)
if window is None:
return 0
elapsed = self._clock() - window.started_at
if elapsed >= self._window_seconds:
# Window rolled; the client starts clean.
del self._windows[client]
return 0
if window.failures < self._max_failures:
return 0
# Round up so a caller that waits exactly this long is past the edge
# rather than one fractional second short of it.
return max(1, int(self._window_seconds - elapsed) + 1)
def record_failure(self, client: str) -> None:
with self._lock:
now = self._clock()
window = self._windows.get(client)
if window is None or now - window.started_at >= self._window_seconds:
self._evict_if_full_locked(now)
self._windows[client] = _Window(failures=1, started_at=now)
return
window.failures += 1
def record_success(self, client: str) -> None:
"""A correct token clears the client's record."""
with self._lock:
self._windows.pop(client, None)
def _evict_if_full_locked(self, now: float) -> None:
if len(self._windows) < self._max_tracked_clients:
return
expired = [
client
for client, window in self._windows.items()
if now - window.started_at >= self._window_seconds
]
for client in expired:
del self._windows[client]
if len(self._windows) < self._max_tracked_clients:
return
# Still full of live windows: drop the oldest so the table stays bounded.
oldest = min(self._windows, key=lambda client: self._windows[client].started_at)
del self._windows[oldest]
__all__ = ["FailedAuthRateLimiter", "MAX_FAILURES_PER_WINDOW", "WINDOW_SECONDS"]

View file

@ -9,7 +9,9 @@ def load_env_file(path: Path | str, environ: MutableMapping[str, str]) -> None:
if not env_path.exists(): if not env_path.exists():
return return
for raw_line in env_path.read_text(encoding="utf-8").splitlines(): # utf-8-sig so a BOM on the file doesn't get glued to the first key (which
# would silently drop the first variable — it would never match its name).
for raw_line in env_path.read_text(encoding="utf-8-sig").splitlines():
line = raw_line.strip() line = raw_line.strip()
if not line or line.startswith("#"): if not line or line.startswith("#"):
continue continue
@ -22,10 +24,25 @@ def load_env_file(path: Path | str, environ: MutableMapping[str, str]) -> None:
key = key.strip() key = key.strip()
if not key or key in environ: if not key or key in environ:
continue continue
environ[key] = _strip_quotes(value.strip()) environ[key] = _parse_value(value.strip())
def _strip_quotes(value: str) -> str: def _parse_value(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: if _is_quoted(value):
# Quoted values are verbatim — a '#' inside quotes is part of the secret.
return value[1:-1] return value[1:-1]
return _strip_inline_comment(value)
def _is_quoted(value: str) -> bool:
return len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}
def _strip_inline_comment(value: str) -> str:
# ` #` starts a trailing comment on an unquoted value (dotenv convention).
# Without this, `TOKEN=abc123 # staging key` silently loaded the comment as
# part of the token and every authenticated request failed with no clue why.
marker = value.find(" #")
if marker == -1:
return value return value
return value[:marker].rstrip()

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib
import hmac import hmac
import json import json
import mimetypes import mimetypes
@ -10,10 +11,14 @@ from http import HTTPStatus
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
from rights_filter.analysis.risk_scoring import SCORER_VERSION
from rights_filter.server.auth_rate_limit import FailedAuthRateLimiter
from rights_filter.server.operator_identity import OPERATOR_HEADER, clean_operator_name, operator_scope
from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES
from rights_filter.server.sqlite_store import CopyrighterStore from rights_filter.server.sqlite_store import CopyrighterStore
from rights_filter.version import release_version
# Windows Python builds may not know .woff2, which would serve the bundled # Windows Python builds may not know .woff2, which would serve the bundled
# 2MB font as application/octet-stream. # 2MB font as application/octet-stream.
@ -35,6 +40,17 @@ class _PayloadTooLarge(Exception):
"""Raised when a request body exceeds _MAX_BODY_BYTES.""" """Raised when a request body exceeds _MAX_BODY_BYTES."""
# Content types treated as the trusted operator app shell, which receives the
# strict script-src CSP + framing/nosniff headers. Both javascript spellings are
# listed because mimetypes maps .js to text/javascript on Python 3.12+.
_APP_SHELL_CONTENT_TYPES = {
"text/html",
"application/javascript",
"text/javascript",
"text/css",
}
# Media routes loaded via <img>/<link>, which cannot send an Authorization # Media routes loaded via <img>/<link>, which cannot send an Authorization
# header — these rely on the session cookie instead. # header — these rely on the session cookie instead.
_MEDIA_PREFIXES = ( _MEDIA_PREFIXES = (
@ -51,6 +67,19 @@ _PROTECTED_PREFIXES = ("/api/", *_MEDIA_PREFIXES)
# load media without ever carrying the token in a URL. # load media without ever carrying the token in a URL.
_SESSION_COOKIE = "copyrighter_session" _SESSION_COOKIE = "copyrighter_session"
# Methods that change state — cookie-authenticated ones get an Origin/Referer
# CSRF check (a header-authenticated request cannot be forged cross-site because
# browsers never auto-attach an Authorization header).
_STATE_CHANGING_METHODS = {"POST", "PATCH", "PUT", "DELETE"}
def _session_tag(token: str) -> str:
# Deterministic HMAC of the token so the session cookie carries a derived
# tag, not the raw COPYRIGHTER_AUTH_TOKEN. A leaked cookie can still ride the
# session but no longer reveals the token itself (which is also the .env
# secret / Authorization bearer). No server-side session store needed.
return hmac.new(token.encode("utf-8"), b"copyrighter-session-v1", hashlib.sha256).hexdigest()
def build_server( def build_server(
host: str, host: str,
@ -61,18 +90,48 @@ def build_server(
auth_token: str | None = None, auth_token: str | None = None,
) -> ThreadingHTTPServer: ) -> ThreadingHTTPServer:
static_root = Path(static_dir).resolve() static_root = Path(static_dir).resolve()
# Shared across every worker thread: the token is the only gate in front of
# the data routes, so failed attempts have to be counted somewhere central.
auth_rate_limiter = FailedAuthRateLimiter()
class CopyrighterHandler(BaseHTTPRequestHandler): class CopyrighterHandler(BaseHTTPRequestHandler):
server_version = "CopyrighterHTTP/0.1" server_version = "CopyrighterHTTP/0.1"
# Bound how long a slow/idle client can hold a worker thread's socket, so
# a handful of slowloris-style connections cannot exhaust the thread pool.
timeout = 30
# Every request runs under the operator name the console sent, so the
# store's audit events say who acted (see operator_identity).
def _as_operator(self, handler) -> None:
with operator_scope(clean_operator_name(self.headers.get(OPERATOR_HEADER))):
handler()
def do_GET(self) -> None: def do_GET(self) -> None:
self._as_operator(self._handle_GET)
def do_POST(self) -> None:
self._as_operator(self._handle_POST)
def do_PATCH(self) -> None:
self._as_operator(self._handle_PATCH)
def _handle_GET(self) -> None:
path = _path(self.path) path = _path(self.path)
if not self._require_auth(): if not self._require_auth():
return return
active_store = lambda: store.active_submission_image_store(image_store.root) # noqa: E731 - lazy: only opens a DB connection on routes that use it active_store = lambda: store.active_submission_image_store(image_store.root) # noqa: E731 - lazy: only opens a DB connection on routes that use it
try: try:
if path == "/health": if path == "/health":
self._json({"status": "ok", "port": self.server.server_port}) self._json(
{
"status": "ok",
"port": self.server.server_port,
# Which build and which scorer produced the scores on
# screen - the first thing to ask about a delivered site.
"version": release_version(),
"scorerVersion": SCORER_VERSION,
}
)
elif path == "/api/providers/health": elif path == "/api/providers/health":
self._json({"status": "ok", "providers": store.providers()}) self._json({"status": "ok", "providers": store.providers()})
elif path == "/api/bootstrap": elif path == "/api/bootstrap":
@ -86,6 +145,21 @@ def build_server(
self._json(store.providers()) self._json(store.providers())
elif path == "/api/audit-events": elif path == "/api/audit-events":
self._json(store.audit_events()) self._json(store.audit_events())
elif path == "/api/decision-memo-presets":
self._json(store.decision_memo_presets())
elif path == "/api/decisions/export":
query = parse_qs(urlparse(self.path).query)
self._json(
store.export_decisions(
audience=(query.get("audience", ["internal"])[0]),
status=(query.get("status", [""])[0]),
since=(query.get("since", [""])[0]),
)
)
elif path == "/api/admin/backups":
self._json(store.backups())
elif path == "/api/stats/decisions":
self._json(store.decision_stats())
elif path.startswith("/media/"): elif path.startswith("/media/"):
self._file(active_store().media_path(unquote(path.removeprefix("/media/"))), untrusted=True) self._file(active_store().media_path(unquote(path.removeprefix("/media/"))), untrusted=True)
elif path.startswith("/knowledge-media/"): elif path.startswith("/knowledge-media/"):
@ -105,7 +179,7 @@ def build_server(
except ValueError as exc: except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def do_POST(self) -> None: def _handle_POST(self) -> None:
path = _path(self.path) path = _path(self.path)
if not self._require_auth(): if not self._require_auth():
return return
@ -125,6 +199,15 @@ def build_server(
elif path.startswith("/api/submissions/") and path.endswith("/search-auto"): elif path.startswith("/api/submissions/") and path.endswith("/search-auto"):
submission_id = unquote(path.split("/")[3]) submission_id = unquote(path.split("/")[3])
self._json(store.run_auto_search(submission_id, active_store())) self._json(store.run_auto_search(submission_id, active_store()))
elif path == "/api/evidence/status-batch":
self._json(
store.mark_evidence_status_batch(
str(body.get("submission_id", "")),
[str(item) for item in (body.get("evidence_ids") or [])],
str(body.get("status", "")),
str(body.get("note", "")),
)
)
elif path.startswith("/api/evidence/") and path.endswith("/status"): elif path.startswith("/api/evidence/") and path.endswith("/status"):
evidence_id = unquote(path.split("/")[3]) evidence_id = unquote(path.split("/")[3])
self._json( self._json(
@ -186,6 +269,9 @@ def build_server(
elif path.startswith("/api/collections/candidates/") and path.endswith("/promote"): elif path.startswith("/api/collections/candidates/") and path.endswith("/promote"):
candidate_id = unquote(path.split("/")[4]) candidate_id = unquote(path.split("/")[4])
self._json(store.promote_collection_candidate(candidate_id, body)) self._json(store.promote_collection_candidate(candidate_id, body))
elif path.startswith("/api/collections/candidates/") and path.endswith("/dismiss"):
candidate_id = unquote(path.split("/")[4])
self._json(store.dismiss_collection_candidate(candidate_id))
elif path.startswith("/api/knowledge/") and path.endswith("/promote-watchlist"): elif path.startswith("/api/knowledge/") and path.endswith("/promote-watchlist"):
entry_id = unquote(path.split("/")[3]) entry_id = unquote(path.split("/")[3])
self._json(store.promote_watchlist_entry(entry_id)) self._json(store.promote_watchlist_entry(entry_id))
@ -198,6 +284,39 @@ def build_server(
elif path.startswith("/api/knowledge/") and path.endswith("/reactivate"): elif path.startswith("/api/knowledge/") and path.endswith("/reactivate"):
entry_id = unquote(path.split("/")[3]) entry_id = unquote(path.split("/")[3])
self._json(store.reactivate_knowledge_entry(entry_id, str(body.get("reason", "")))) self._json(store.reactivate_knowledge_entry(entry_id, str(body.get("reason", ""))))
elif path == "/api/submissions/decision-batch":
raw_ids = body.get("submission_ids", [])
if not isinstance(raw_ids, list):
raise ValueError("submission_ids must be a list")
self._json(
store.record_decision_batch(
[str(value) for value in raw_ids],
str(body.get("decision", "")),
str(body.get("memo", "")),
active_store(),
)
)
elif path == "/api/decision-memo-presets":
self._json(
store.save_decision_memo_preset(
str(body.get("label", "")), str(body.get("body", ""))
)
)
elif path.startswith("/api/decision-memo-presets/") and path.endswith("/delete"):
preset_id = unquote(path.split("/")[3])
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": elif path == "/api/providers/emergency-disable":
self._json(store.emergency_disable_external_providers()) self._json(store.emergency_disable_external_providers())
else: else:
@ -211,7 +330,7 @@ def build_server(
except ValueError as exc: except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def do_PATCH(self) -> None: def _handle_PATCH(self) -> None:
path = _path(self.path) path = _path(self.path)
if not self._require_auth(): if not self._require_auth():
return return
@ -256,46 +375,120 @@ def build_server(
# routes are protected. # routes are protected.
if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES): if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES):
return True return True
client = self.client_address[0] if self.client_address else "unknown"
retry_after = auth_rate_limiter.retry_after(client)
if retry_after:
self._json(
{"error": "too many failed authentication attempts"},
HTTPStatus.TOO_MANY_REQUESTS,
extra_headers={"Retry-After": str(retry_after)},
)
return False
header = self.headers.get("Authorization", "") header = self.headers.get("Authorization", "")
if hmac.compare_digest(header, f"Bearer {auth_token}"): # Compare as bytes: hmac.compare_digest raises TypeError on str inputs
# containing non-ASCII, and that exception escapes before any response
# is written — an unauthenticated remote client could hang the request
# by sending a single non-ASCII byte in the header.
if hmac.compare_digest(
header.encode("utf-8", "replace"), f"Bearer {auth_token}".encode("utf-8")
):
# First header-authenticated request mints an HttpOnly session # First header-authenticated request mints an HttpOnly session
# cookie so the browser can load <img>/media (which cannot send an # cookie so the browser can load <img>/media (which cannot send an
# Authorization header) without ever putting the token in a URL. # Authorization header) without ever putting the token in a URL.
self._issue_session_cookie = True self._issue_session_cookie = True
auth_rate_limiter.record_success(client)
return True return True
cookie_header = self.headers.get("Cookie", "") cookie_header = self.headers.get("Cookie", "")
presented_session_cookie = False
if cookie_header: if cookie_header:
jar = SimpleCookie() jar = SimpleCookie()
jar.load(cookie_header) jar.load(cookie_header)
morsel = jar.get(_SESSION_COOKIE) morsel = jar.get(_SESSION_COOKIE)
if morsel is not None and hmac.compare_digest(morsel.value, auth_token): presented_session_cookie = morsel is not None
if morsel is not None and hmac.compare_digest(
morsel.value.encode("utf-8", "replace"),
_session_tag(auth_token).encode("utf-8"),
):
# Cookie-authenticated state changes are CSRF-forgeable, so
# require a same-origin Origin/Referer for them.
if self.command in _STATE_CHANGING_METHODS and not self._origin_ok():
self._json({"error": "invalid origin"}, HTTPStatus.FORBIDDEN)
return False
auth_rate_limiter.record_success(client)
return True return True
# Count only attempts that actually presented a credential and got it
# wrong. A request carrying neither header nor cookie is the console
# bootstrapping before the operator has typed the token — the GUI
# fires several of those per load, and counting them would let two
# honest page loads lock the operator out. A rejected origin above is
# likewise not a guess: that is a valid session failing a CSRF check.
if header or presented_session_cookie:
auth_rate_limiter.record_failure(client)
self._json({"error": "unauthorized"}, HTTPStatus.UNAUTHORIZED) self._json({"error": "unauthorized"}, HTTPStatus.UNAUTHORIZED)
return False return False
def _origin_ok(self) -> bool:
# Accept only requests whose Origin (preferred) or Referer resolves to
# the same host we were reached on. If neither header is present we
# cannot verify, so allow (older/non-browser clients) rather than break
# the app — the classic cross-site POST always carries an Origin.
host = self.headers.get("Host", "")
origin = self.headers.get("Origin", "")
if origin:
return urlparse(origin).netloc == host
referer = self.headers.get("Referer", "")
if referer:
return urlparse(referer).netloc == host
return True
def _body(self) -> dict[str, object]: def _body(self) -> dict[str, object]:
# Parse defensively: a non-integer header must not leak Python's parse
# error, and a negative length must not slip past the size cap into
# rfile.read(-1) (an unbounded read-to-EOF that exhausts memory).
try:
length = int(self.headers.get("Content-Length", "0") or "0") length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError:
raise ValueError("invalid Content-Length")
if length < 0:
raise ValueError("invalid Content-Length")
if length > _MAX_BODY_BYTES: if length > _MAX_BODY_BYTES:
raise _PayloadTooLarge() raise _PayloadTooLarge()
if not length: if not length:
return {} return {}
try: try:
return json.loads(self.rfile.read(length).decode("utf-8")) parsed = json.loads(self.rfile.read(length).decode("utf-8"))
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise ValueError("invalid JSON body") from exc raise ValueError("invalid JSON body") from exc
except UnicodeDecodeError as exc:
raise ValueError("body must be UTF-8") from exc
# Every handler treats the body as a mapping (`payload.get(...)`). A
# valid-JSON list/string/null would sail past the decode and blow up
# with AttributeError inside the handler, which escapes the do_POST
# except-chain and closes the connection with no response at all.
if not isinstance(parsed, dict):
raise ValueError("JSON object body required")
return parsed
def _maybe_set_session_cookie(self) -> None: def _maybe_set_session_cookie(self) -> None:
if getattr(self, "_issue_session_cookie", False): if getattr(self, "_issue_session_cookie", False):
# Store the derived tag, never the raw token.
self.send_header( self.send_header(
"Set-Cookie", "Set-Cookie",
f"{_SESSION_COOKIE}={auth_token}; HttpOnly; SameSite=Strict; Path=/", f"{_SESSION_COOKIE}={_session_tag(auth_token)}; HttpOnly; SameSite=Strict; Path=/",
) )
def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None: def _json(
self,
payload: object,
status: HTTPStatus = HTTPStatus.OK,
extra_headers: dict[str, str] | None = None,
) -> None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data))) self.send_header("Content-Length", str(len(data)))
for name, value in (extra_headers or {}).items():
self.send_header(name, value)
self._maybe_set_session_cookie() self._maybe_set_session_cookie()
self.end_headers() self.end_headers()
self.wfile.write(data) self.wfile.write(data)
@ -315,7 +508,14 @@ def build_server(
if not path.exists() or path.is_dir(): if not path.exists() or path.is_dir():
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return return
# exists() -> read_bytes() is a TOCTOU window, and the file can also be
# locked (Windows), deleted mid-request, or unreadable. Answer with a
# real status instead of letting OSError close the connection silently.
try:
data = path.read_bytes() data = path.read_bytes()
except OSError:
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}: if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
content_type = f"{content_type}; charset=utf-8" content_type = f"{content_type}; charset=utf-8"
@ -340,6 +540,21 @@ def build_server(
# don't leak the URL via Referer. # don't leak the URL via Referer.
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Referrer-Policy", "no-referrer") self.send_header("Referrer-Policy", "no-referrer")
elif content_type.split(";", 1)[0] in _APP_SHELL_CONTENT_TYPES:
# Trusted app shell (HTML/JS/CSS). The GUI loads only same-origin
# scripts (no inline <script>, no CDN), so a script-src 'self'
# policy is compatible and blocks injected scripts. img-src stays
# permissive because evidence thumbnails are external result URLs.
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https: http:; "
"font-src 'self' data:; connect-src 'self'; "
"base-uri 'self'; object-src 'none'; frame-ancestors 'none'",
)
self._maybe_set_session_cookie() self._maybe_set_session_cookie()
self.end_headers() self.end_headers()
self.wfile.write(data) self.wfile.write(data)
@ -373,8 +588,7 @@ def _save_submission_upload(image_store: LocalSubmissionImageStore, raw_image: o
image_dir = image_store.root / "images" image_dir = image_store.root / "images"
image_dir.mkdir(parents=True, exist_ok=True) image_dir.mkdir(parents=True, exist_ok=True)
safe_name = _safe_upload_name(filename) safe_name = _safe_upload_name(filename)
target = _unique_upload_path(image_dir, safe_name) target = _write_unique_upload(image_dir, safe_name, content)
target.write_bytes(content)
return target.stem return target.stem
@ -384,15 +598,20 @@ def _safe_upload_name(filename: str) -> str:
return f"{stem}{path.suffix.lower()}" return f"{stem}{path.suffix.lower()}"
def _unique_upload_path(directory: Path, filename: str) -> Path: def _write_unique_upload(directory: Path, filename: str, content: bytes) -> Path:
candidate = directory / filename # Exclusive create ('xb') so two concurrent uploads of the same name can't
if not candidate.exists(): # both pass an exists() check and then overwrite each other — the loser gets
return candidate # FileExistsError and advances to the next -N suffix.
path = Path(filename) path = Path(filename)
for index in range(2, 1000): candidates = [directory / filename]
candidate = directory / f"{path.stem}-{index}{path.suffix}" candidates.extend(directory / f"{path.stem}-{index}{path.suffix}" for index in range(2, 1000))
if not candidate.exists(): for candidate in candidates:
try:
with open(candidate, "xb") as handle:
handle.write(content)
return candidate return candidate
except FileExistsError:
continue
raise ValueError("too many files with the same submission image name") raise ValueError("too many files with the same submission image name")

View file

@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from rights_filter.analysis.preprocessing import ImagePayload from rights_filter.analysis.preprocessing import ImagePayload
logger = logging.getLogger(__name__)
SUPPORTED_IMAGE_SUFFIXES = { SUPPORTED_IMAGE_SUFFIXES = {
".avif", ".avif",
".bmp", ".bmp",
@ -30,13 +33,23 @@ class LocalSubmissionImageStore:
manifest = self.root / "submissions.json" manifest = self.root / "submissions.json"
if manifest.exists(): if manifest.exists():
records = json.loads(manifest.read_text(encoding="utf-8")) records = json.loads(manifest.read_text(encoding="utf-8"))
normalized = [ normalized: list[dict[str, Any]] = []
self._normalize_manifest_record(record) for record in records:
for record in records if not isinstance(record, dict) or not record.get("file"):
if isinstance(record, dict) continue
and record.get("file") # Skip a malformed row (e.g. a path escaping the image root)
and self._safe_path(str(record["file"])).exists() # instead of letting its ValueError abort the whole listing —
] # one bad manifest line previously paralyzed loading and
# re-analysis for the entire queue.
try:
if not self._safe_path(str(record["file"])).exists():
continue
normalized.append(self._normalize_manifest_record(record))
except (ValueError, OSError):
logger.warning(
"Skipping malformed submissions.json record: file=%r",
record.get("file"),
)
return self._with_unlisted_files(normalized) return self._with_unlisted_files(normalized)
return self._scan_records() return self._scan_records()
@ -45,8 +58,8 @@ class LocalSubmissionImageStore:
path = self._safe_path(record["file"]) path = self._safe_path(record["file"])
return ImagePayload( return ImagePayload(
content=path.read_bytes(), content=path.read_bytes(),
width=int(record["width"]), width=_safe_dimension(record.get("width")),
height=int(record["height"]), height=_safe_dimension(record.get("height")),
metadata={ metadata={
"submission_id": submission_id, "submission_id": submission_id,
"title": str(record.get("title", submission_id)), "title": str(record.get("title", submission_id)),
@ -58,6 +71,24 @@ class LocalSubmissionImageStore:
def media_path(self, relative_path: str) -> Path: def media_path(self, relative_path: str) -> Path:
return self._safe_path(relative_path) return self._safe_path(relative_path)
def id_collisions(self) -> list[dict[str, Any]]:
"""Files that share a submission id (file stem) with an earlier file.
Only the first file per id is ever read (`_record_for` returns the first
match), so the others are never analysed - `지원자A/photo.jpg` and
`지원자B/photo.png` become one case and the second applicant is silently
unreviewed. Ids are not re-derived here (see `_scan_records`); this only
makes the loss visible so the operator renames the file.
"""
files_by_id: dict[str, list[str]] = {}
for record in self.submission_records():
files_by_id.setdefault(str(record["id"]), []).append(str(record["file"]))
return [
{"id": submission_id, "kept": files[0], "skipped": files[1:]}
for submission_id, files in files_by_id.items()
if len(files) > 1
]
def _record_for(self, submission_id: str) -> dict[str, Any]: def _record_for(self, submission_id: str) -> dict[str, Any]:
for record in self.submission_records(): for record in self.submission_records():
if record["id"] == submission_id: if record["id"] == submission_id:
@ -78,6 +109,14 @@ class LocalSubmissionImageStore:
return normalized return normalized
def _scan_records(self) -> list[dict[str, Any]]: def _scan_records(self) -> list[dict[str, Any]]:
# NOTE: a scanned file's id is its bare stem. Two same-stem files
# (a.jpg + a.png) collide and one silently merges into the other — a
# known, pre-existing limitation. A prior fix that appended the extension
# on collision was reverted because the id then depended on sibling files,
# so adding a same-stem sibling later re-derived ids and resurrected
# already-decided submissions as duplicates. A correct fix must preserve
# each incumbent id at seed time (where existing ids are known) — not in
# this pure scan — and carry a one-time asset-path migration.
if not self.root.exists(): if not self.root.exists():
return [] return []
records: list[dict[str, Any]] = [] records: list[dict[str, Any]] = []
@ -119,6 +158,15 @@ class LocalSubmissionImageStore:
return path return path
def _safe_dimension(value: Any) -> int:
# A manifest may carry a non-numeric or missing width/height; fall back to
# the same placeholder the normalizer uses rather than raising mid-analysis.
try:
return int(value)
except (TypeError, ValueError):
return 1
def _image_size(path: Path) -> tuple[int, int]: def _image_size(path: Path) -> tuple[int, int]:
try: try:
from PIL import Image from PIL import Image

View file

@ -0,0 +1,54 @@
"""Who is acting - the operator name that goes on every audit event.
Until 2026-09-26 every audit actor was the literal "rights.ops", so "who
rejected this?" had no answer (persona review, 법무 2-A). The console now sends
the operator's name in an `X-Operator` header; the HTTP layer scopes it to the
request with `operator_scope`, and the store reads it through `current_actor()`.
A ContextVar keeps the store's method signatures unchanged: each request runs in
its own thread (ThreadingHTTPServer), so the scope never leaks between requests,
and store calls made outside a request (seeding at startup, tests) keep the
shared default.
This is attribution, not authentication. The shared COPYRIGHTER_AUTH_TOKEN
decides who may act; this records which operator said they acted.
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
DEFAULT_ACTOR = "rights.ops"
OPERATOR_HEADER = "X-Operator"
MAX_OPERATOR_NAME = 40
_current: ContextVar[str] = ContextVar("copyrighter_operator", default=DEFAULT_ACTOR)
def current_actor() -> str:
return _current.get()
def clean_operator_name(raw: str | None) -> str:
"""The header value as an audit actor, or "" when it is unusable.
HTTP headers arrive latin-1 decoded; the console percent-encodes the name so
Korean survives, and this reverses it. Control characters are dropped so a
name cannot forge extra lines in an exported audit log.
"""
from urllib.parse import unquote
text = unquote(str(raw or "")).strip()
text = "".join(ch for ch in text if ch.isprintable())
return text[:MAX_OPERATOR_NAME]
@contextmanager
def operator_scope(name: str) -> Iterator[None]:
token = _current.set(f"operator:{name}" if name else DEFAULT_ACTOR)
try:
yield
finally:
_current.reset(token)

View file

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from rights_filter.server.operator_identity import current_actor
import json import json
import logging
import os import os
import shutil import shutil
import sqlite3 import sqlite3
@ -12,7 +14,7 @@ from typing import Any, Callable
from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector
from rights_filter.analysis.fingerprints import FingerprintService from rights_filter.analysis.fingerprints import FingerprintService
from rights_filter.analysis.internal_analyzer import InternalAnalyzer from rights_filter.analysis.internal_analyzer import InternalAnalyzer
from rights_filter.analysis.risk_scoring import RiskScorer from rights_filter.analysis.risk_scoring import RiskScorer, has_coverage_gap
from rights_filter.integrations.cloud_vision_web_detection import ( from rights_filter.integrations.cloud_vision_web_detection import (
CloudVisionWebDetectionAdapter, CloudVisionWebDetectionAdapter,
FakeWebDetectionClient, FakeWebDetectionClient,
@ -28,7 +30,9 @@ from rights_filter.server.store_constants import (
DEFAULT_QUERY_COVERAGE_GOOD_THRESHOLD, DEFAULT_QUERY_COVERAGE_GOOD_THRESHOLD,
DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD, DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD,
EVIDENCE_OPERATOR_STATUSES, EVIDENCE_OPERATOR_STATUSES,
HOLD_WAITING_MARKER,
MAX_COVERAGE_THRESHOLD, MAX_COVERAGE_THRESHOLD,
MAX_LLM_SUMMARIES_PER_SEED,
MIN_COVERAGE_THRESHOLD, MIN_COVERAGE_THRESHOLD,
NON_CONTRIBUTING_OPERATOR_STATUSES, NON_CONTRIBUTING_OPERATOR_STATUSES,
_bounded_int_env, _bounded_int_env,
@ -38,10 +42,13 @@ from rights_filter.server.store_remote_fetch import (
_fetch_stylesheet_url_bytes, _fetch_stylesheet_url_bytes,
_fetch_url_bytes, _fetch_url_bytes,
) )
from rights_filter.server.store_backup import StoreBackupMixin
from rights_filter.server.store_duplicates import StoreDuplicatesMixin
from rights_filter.server.store_enrichment import StoreEnrichmentMixin from rights_filter.server.store_enrichment import StoreEnrichmentMixin
from rights_filter.server.store_operations import StoreOperationsMixin from rights_filter.server.store_operations import StoreOperationsMixin
from rights_filter.server.store_persistence import StorePersistenceMixin from rights_filter.server.store_persistence import StorePersistenceMixin
from rights_filter.server.store_queue import StoreQueueMixin from rights_filter.server.store_queue import StoreQueueMixin
from rights_filter.server.store_workflow import StoreWorkflowMixin
from rights_filter.server.store_search_candidates import StoreSearchCandidatesMixin from rights_filter.server.store_search_candidates import StoreSearchCandidatesMixin
from rights_filter.server.store_schema import ( from rights_filter.server.store_schema import (
_ensure_constrained_schema, _ensure_constrained_schema,
@ -51,6 +58,7 @@ from rights_filter.server.store_schema import (
) )
from rights_filter.server.store_serialization import ( from rights_filter.server.store_serialization import (
_default_evidence_contribution, _default_evidence_contribution,
_domain_evidence_from_ui,
_evidence_id, _evidence_id,
_evidence_matches_provider, _evidence_matches_provider,
_evidence_payload, _evidence_payload,
@ -61,6 +69,20 @@ from rights_filter.server.store_serialization import (
_submission_search_hint_evidence, _submission_search_hint_evidence,
) )
logger = logging.getLogger(__name__)
# Audit event name for a cross-queue submission id collision (same file stem in
# two imported folders). Kept as a constant so the "log it once" lookup and the
# write cannot drift apart.
_QUEUE_ID_CONFLICT_EVENT = "Submission id conflict"
_STEM_COLLISION_EVENT = "Submission file name collision"
# 한 번의 증거 일괄 표시가 건드릴 수 있는 최대 행 수. 일괄 판정 상한(200)보다
# 큰 이유는 단위가 다르기 때문이다 — 그쪽은 케이스 수고 이쪽은 한 케이스 안의
# 증거 행 수다(실 DB 에 제공자 하나로 51행짜리 케이스가 있다).
MAX_EVIDENCE_STATUS_BATCH = 500
class CopyrighterStore( class CopyrighterStore(
StorePersistenceMixin, StorePersistenceMixin,
@ -68,6 +90,9 @@ class CopyrighterStore(
StoreSearchCandidatesMixin, StoreSearchCandidatesMixin,
StoreEnrichmentMixin, StoreEnrichmentMixin,
StoreOperationsMixin, StoreOperationsMixin,
StoreDuplicatesMixin,
StoreWorkflowMixin,
StoreBackupMixin,
): ):
def __init__( def __init__(
self, self,
@ -93,6 +118,10 @@ class CopyrighterStore(
else self.db_path.parent / "collections" / "images" else self.db_path.parent / "collections" / "images"
) )
self.collection_public_prefix = collection_public_prefix.rstrip("/") self.collection_public_prefix = collection_public_prefix.rstrip("/")
# Files the last seed could not import as their own case (same stem as
# another file, or an id owned by another queue). Recomputed on every
# seed - startup included - so it always describes the current folder.
self._intake_conflicts: list[dict[str, Any]] = []
self.face_crop_image_dir = self.db_path.parent / "face-crops" self.face_crop_image_dir = self.db_path.parent / "face-crops"
self.face_crop_public_prefix = "/face-crop-media" self.face_crop_public_prefix = "/face-crop-media"
# Biometric face crops are personal data: purge them after this many days. # Biometric face crops are personal data: purge them after this many days.
@ -207,6 +236,10 @@ class CopyrighterStore(
object_ref text not null default '', object_ref text not null default '',
payload text not null check (json_valid(payload)) payload text not null check (json_valid(payload))
); );
create table if not exists decision_memo_presets (
id text primary key,
payload text not null check (json_valid(payload))
);
""" """
) )
_ensure_typed_columns(conn) _ensure_typed_columns(conn)
@ -226,14 +259,113 @@ class CopyrighterStore(
""" """
) )
def _submission_queue_owners(self, *, exclude_queue_id: str) -> dict[str, str]:
"""Map submission id -> owning queue id, for queues other than the given one."""
with self._connect() as conn:
rows = conn.execute(
"""
select id, queue_id from submissions
where queue_id is not null and queue_id != '' and queue_id != ?
""",
(exclude_queue_id,),
).fetchall()
return {str(row["id"]): str(row["queue_id"]) for row in rows}
def _note_queue_id_conflict(
self, submission_id: str, queue_id: str, owner_queue_id: str
) -> None:
logger.warning(
"Submission id %r in queue %s is already owned by queue %s — skipping "
"import to avoid overwriting the existing decision.",
submission_id,
queue_id,
owner_queue_id,
)
# Seeding runs on every reload/import/upload, so only record the conflict
# the first time it is seen for this submission instead of spamming the
# audit log on each pass.
with self._connect() as conn:
already_logged = conn.execute(
"select 1 from audit_events where event = ? and object_ref = ? limit 1",
(_QUEUE_ID_CONFLICT_EVENT, submission_id),
).fetchone()
if already_logged is not None:
return
self.add_audit_event(
"rights.system",
_QUEUE_ID_CONFLICT_EVENT,
submission_id,
(
f"동일 id가 다른 큐({owner_queue_id})에 이미 존재해 이 큐({queue_id})"
" 임포트를 건너뜀 — 파일명을 변경한 뒤 다시 불러오세요."
),
)
def _note_stem_collisions(self, image_store: LocalSubmissionImageStore) -> list[dict[str, Any]]:
"""List same-stem files the seed will not import, auditing each once."""
conflicts: list[dict[str, Any]] = []
for collision in image_store.id_collisions():
for skipped in collision["skipped"]:
conflicts.append(
{"kind": "same_stem", "id": collision["id"], "file": skipped, "keptFile": collision["kept"]}
)
object_ref = f"{collision['id']}:{skipped}"
with self._connect() as conn:
already_logged = conn.execute(
"select 1 from audit_events where event = ? and object_ref = ? limit 1",
(_STEM_COLLISION_EVENT, object_ref),
).fetchone()
if already_logged is None:
self.add_audit_event(
"rights.system",
_STEM_COLLISION_EVENT,
object_ref,
f"'{skipped}' 은 '{collision['kept']}' 와 파일명이 같아 심사 건으로 들어가지 않음"
" — 파일명을 바꾼 뒤 다시 불러오세요.",
)
return conflicts
def seed_from_image_store(self, image_store: LocalSubmissionImageStore) -> int: def seed_from_image_store(self, image_store: LocalSubmissionImageStore) -> int:
# Serialize seeding so two concurrent reload/import/upload requests can't # Serialize seeding so two concurrent reload/import/upload requests can't
# both classify the same record as new and double-run analysis, external # both classify the same record as new and double-run analysis, external
# searches, and audit writes for it. # searches, and audit writes for it.
with self._write_lock: with self._write_lock:
return self._seed_from_image_store_locked(image_store) created, queue_id = self._seed_from_image_store_locked(image_store)
# LLM narration calls out to Ollama: seconds per case with a real model,
# and a 2s connect timeout per case even when Ollama is not running.
# Doing it under _write_lock meant a folder reload blocked every other
# write for the whole batch — including record_decision, which takes the
# same lock. So the operator's decision button stalled behind narration
# of images they were not even looking at. Narration is only-if-missing
# and therefore safe to finish on a later reload.
if self._narrate_seeded_submissions(queue_id):
# Provider coverage is derived from the evidence rows and is computed
# inside the locked section — i.e. before narration now runs. Without
# this refresh the llm provider reports "not_run" for summaries that
# exist, which is the coverage panel lying about work it did.
with self._write_lock:
self._sync_submission_provider_state(queue_id=queue_id)
return created
def _seed_from_image_store_locked(self, image_store: LocalSubmissionImageStore) -> int: def _narrate_seeded_submissions(self, queue_id: str) -> int:
if self.provider_runtime.llm_assistant is None:
return 0
narrated = 0
for submission in self._all("submissions", queue_id=queue_id):
if narrated >= MAX_LLM_SUMMARIES_PER_SEED:
logger.info(
"LLM narration capped at %s per seed; remaining submissions "
"will be narrated on the next reload",
MAX_LLM_SUMMARIES_PER_SEED,
)
break
if self._ensure_llm_summary(str(submission["id"]), only_if_missing=True):
narrated += 1
return narrated
def _seed_from_image_store_locked(
self, image_store: LocalSubmissionImageStore
) -> tuple[int, str]:
self.purge_expired_face_crops() self.purge_expired_face_crops()
queue = self.ensure_queue(image_store.root) queue = self.ensure_queue(image_store.root)
queue_id = str(queue["id"]) queue_id = str(queue["id"])
@ -244,22 +376,43 @@ class CopyrighterStore(
self._refresh_existing_submission_file_facts(image_store, queue_id=queue_id) self._refresh_existing_submission_file_facts(image_store, queue_id=queue_id)
self._refresh_existing_local_face_evidence(image_store, queue_id=queue_id) self._refresh_existing_local_face_evidence(image_store, queue_id=queue_id)
for submission in self._all("submissions", queue_id=queue_id): for submission in self._all("submissions", queue_id=queue_id):
if "faceCrops" not in submission: if self._face_crops_need_sync(submission):
self._sync_face_crops(str(submission["id"]), image_store) self._sync_face_crops(str(submission["id"]), image_store)
self._rescore_all_submissions(queue_id=queue_id) self._rescore_all_submissions(queue_id=queue_id)
self._ensure_llm_summaries_for_existing_source_evidence(queue_id=queue_id)
self._sync_submission_provider_state(queue_id=queue_id) self._sync_submission_provider_state(queue_id=queue_id)
existing_submission_ids = { existing_submission_ids = {
submission["id"] for submission in self._all("submissions", queue_id=queue_id) submission["id"] for submission in self._all("submissions", queue_id=queue_id)
} }
records = [ # The "is this new?" test above is queue-scoped, but the submissions PK is
record # global and _put() upserts on it. A record whose id (the file stem) is
for record in image_store.submission_records() # already owned by a DIFFERENT queue would therefore be classified as new
if record["id"] not in existing_submission_ids # and overwrite that row: the other queue's decision/memo/query history
] # resets to unreviewed, the row's queue_id moves, and the old evidence
# (linked by submission_id alone) stays attached and pollutes the new
# case's score. Skip the colliding record and make the conflict visible
# rather than destroying a decided submission.
foreign_queue_owners = self._submission_queue_owners(exclude_queue_id=queue_id)
self._intake_conflicts = self._note_stem_collisions(image_store)
records = []
seen_record_ids: set[str] = set()
for record in image_store.submission_records():
record_id = str(record["id"])
if record_id in existing_submission_ids or record_id in seen_record_ids:
continue
seen_record_ids.add(record_id)
owner_queue_id = foreign_queue_owners.get(record_id)
if owner_queue_id:
self._note_queue_id_conflict(record_id, queue_id, owner_queue_id)
self._intake_conflicts.append(
{"kind": "other_queue", "id": record_id, "file": str(record["file"]), "ownerQueueId": owner_queue_id}
)
continue
records.append(record)
if not records: if not records:
return 0 # Nothing new to analyze, but existing rows may still be missing a
# narration — the caller runs that outside the lock.
return 0, queue_id
repository = self._knowledge_repository() repository = self._knowledge_repository()
analyzer = InternalAnalyzer( analyzer = InternalAnalyzer(
@ -267,12 +420,19 @@ class CopyrighterStore(
FingerprintService(), FingerprintService(),
HeuristicFacePersonDetector(), HeuristicFacePersonDetector(),
) )
# An operator stop (per-provider toggle or the emergency switch) has to
# reach the seed path too — this is where the bulk of the outbound Vision
# traffic originates.
vision_allowed = (
self.provider_runtime.google_adapter is not None
and not self._external_calls_blocked("google")
)
batch = BatchAnalyzer( batch = BatchAnalyzer(
repository=repository, repository=repository,
internal_analyzer=analyzer, internal_analyzer=analyzer,
external_adapter=self.provider_runtime.google_adapter or CloudVisionWebDetectionAdapter(FakeWebDetectionClient()), external_adapter=self.provider_runtime.google_adapter or CloudVisionWebDetectionAdapter(FakeWebDetectionClient()),
external_policy=self.provider_runtime.external_policy if self.provider_runtime.google_adapter else ExternalApiPolicy(disabled=True), external_policy=self.provider_runtime.external_policy if vision_allowed else ExternalApiPolicy(disabled=True),
scorer=RiskScorer(), scorer=RiskScorer(self._trusted_hosts(), self._host_labels()),
) )
submissions = [ submissions = [
SubmissionImage(record["id"], image_store.image_payload(record["id"])) SubmissionImage(record["id"], image_store.image_payload(record["id"]))
@ -282,7 +442,30 @@ class CopyrighterStore(
batch.run(submissions) batch.run(submissions)
for record in records: for record in records:
run = repository.analysis_runs_for_submission(record["id"])[-1] runs = repository.analysis_runs_for_submission(record["id"])
if not runs:
# BatchAnalyzer saves no run when preprocessing fails (an
# unreadable / zero-byte / corrupt image). Persist a 'failed'
# submission with an audit trail and move on, instead of raising
# IndexError here — which previously aborted the whole seed after
# a partial commit and re-failed on every subsequent reload.
failed_submission = _submission_payload(
record,
0,
"failed",
["이미지 분석 실패 — 파일이 손상되었거나 비어 있습니다."],
self.provider_runtime.provider_payloads,
)
failed_submission["queueId"] = queue_id
self._put("submissions", failed_submission["id"], failed_submission)
self.add_audit_event(
"rights.system",
"Analysis run created",
str(record["id"]),
"이미지 분석 실패 — 손상되었거나 빈 파일",
)
continue
run = runs[-1]
score = run.score score = run.score
submission = _submission_payload( submission = _submission_payload(
record, record,
@ -290,6 +473,7 @@ class CopyrighterStore(
score.band if score else "low", score.band if score else "low",
score.reasons if score else [], score.reasons if score else [],
self.provider_runtime.provider_payloads, self.provider_runtime.provider_payloads,
score.breakdown if score else {},
) )
submission["queueId"] = queue_id submission["queueId"] = queue_id
self._put("submissions", submission["id"], submission) self._put("submissions", submission["id"], submission)
@ -304,7 +488,6 @@ class CopyrighterStore(
self._sync_face_crops(record["id"], image_store) self._sync_face_crops(record["id"], image_store)
self._auto_naver_search(record["id"], query_source_evidence, image_store) self._auto_naver_search(record["id"], query_source_evidence, image_store)
self._auto_google_custom_search(record["id"], query_source_evidence, image_store) self._auto_google_custom_search(record["id"], query_source_evidence, image_store)
self._ensure_llm_summary(record["id"])
self._increment_knowledge_contribution_counts(record["id"], run.evidence) self._increment_knowledge_contribution_counts(record["id"], run.evidence)
self._rescore_submission(record["id"]) self._rescore_submission(record["id"])
self.add_audit_event( self.add_audit_event(
@ -315,6 +498,10 @@ class CopyrighterStore(
) )
self._sync_submission_provider_state(queue_id=queue_id) self._sync_submission_provider_state(queue_id=queue_id)
self._normalize_saved_google_weak_labels_and_rescore(queue_id=queue_id) self._normalize_saved_google_weak_labels_and_rescore(queue_id=queue_id)
# Persist submission fingerprints so duplicate / prior-case grouping can
# run from stored data. Idempotent: only submissions missing the field
# read their image again.
self.backfill_submission_fingerprints(image_store, queue_id=queue_id)
with self._connect() as conn: with self._connect() as conn:
conn.execute( conn.execute(
""" """
@ -324,7 +511,7 @@ class CopyrighterStore(
""", """,
(int(datetime.now().timestamp()), now, queue_id), (int(datetime.now().timestamp()), now, queue_id),
) )
return len(records) return len(records), queue_id
def _prune_missing_submission_files( def _prune_missing_submission_files(
self, self,
@ -334,14 +521,62 @@ class CopyrighterStore(
) -> int: ) -> int:
public_prefix = image_store.public_prefix.rstrip("/") + "/" public_prefix = image_store.public_prefix.rstrip("/") + "/"
missing_ids: list[str] = [] missing_ids: list[str] = []
retained: list[dict[str, Any]] = []
recovered: list[dict[str, Any]] = []
for submission in self._all("submissions", queue_id=queue_id): for submission in self._all("submissions", queue_id=queue_id):
asset = str(submission.get("asset", "")) asset = str(submission.get("asset", ""))
if not asset.startswith(public_prefix): if not asset.startswith(public_prefix):
continue continue
relative_path = asset.removeprefix(public_prefix) relative_path = asset.removeprefix(public_prefix)
if not image_store.media_path(relative_path).exists(): if image_store.media_path(relative_path).exists():
# The file came back - ordinary housekeeping is reversible. The
# marker has to come off with it: left set it becomes a permanent
# lie in the console, and the mark-once guard below would then
# suppress the audit event for a later, genuine disappearance.
if submission.get("assetMissing"):
recovered.append(submission)
continue
# A decided case IS the record of that decision, and evidence rows
# cascade with the submission. Moving a reviewed image out of the
# queue folder is ordinary operator housekeeping — it must not erase
# the approval / rejection from the DB and from decision exports.
if str(submission.get("decisionStatus", "unreviewed")) != "unreviewed":
if not submission.get("assetMissing"):
retained.append(submission)
continue
missing_ids.append(str(submission["id"])) missing_ids.append(str(submission["id"]))
for submission in recovered:
with self._transaction() as conn:
submission["assetMissing"] = False
self._put("submissions", submission["id"], submission, conn=conn)
self.add_audit_event(
actor="system",
event="Submission asset restored",
object_id=str(submission["id"]),
change="source image file present again",
conn=conn,
)
for submission in retained:
# Mark once. This sweep runs on every reload, so an unmarked retain
# would re-emit the same audit event until it filled the log. Marker
# and event go in ONE transaction: a crash between two separate
# commits would persist the flag without the event, and the guard
# above then means no later reload ever writes it - leaving the
# retention of a decided case with no audit trail at all, in a
# delivery whose audit log is the only record.
with self._transaction() as conn:
submission["assetMissing"] = True
self._put("submissions", submission["id"], submission, conn=conn)
self.add_audit_event(
actor="system",
event="Submission retained",
object_id=str(submission["id"]),
change="source image file missing; decided case kept",
conn=conn,
)
if not missing_ids: if not missing_ids:
return 0 return 0
@ -370,8 +605,14 @@ class CopyrighterStore(
queue_id = str(queue["id"]) if queue else None queue_id = str(queue["id"]) if queue else None
submissions = self._all("submissions", queue_id=queue_id) submissions = self._all("submissions", queue_id=queue_id)
evidence = self._evidence_by_submission(queue_id=queue_id) evidence = self._evidence_by_submission(queue_id=queue_id)
duplicates = self.duplicate_groups(queue_id=queue_id)
for submission in submissions: for submission in submissions:
submission["evidence"] = evidence.get(submission["id"], []) submission["evidence"] = evidence.get(submission["id"], [])
# Only present when the case actually has a duplicate peer, so the
# GUI can treat presence of the key as "show the badge".
group = duplicates.get(str(submission["id"]))
if group:
submission["duplicateGroup"] = group
return { return {
"submissions": submissions, "submissions": submissions,
"submissionQueue": queue, "submissionQueue": queue,
@ -382,6 +623,8 @@ class CopyrighterStore(
"auditEvents": self.audit_events(), "auditEvents": self.audit_events(),
"coverageThresholds": dict(self.coverage_thresholds), "coverageThresholds": dict(self.coverage_thresholds),
"searchCoverage": self._search_coverage(submissions, evidence), "searchCoverage": self._search_coverage(submissions, evidence),
"decisionMemoPresets": self.decision_memo_presets(),
"intakeConflicts": list(self._intake_conflicts),
} }
def _search_coverage(self, submissions: list[dict[str, Any]], evidence_by_submission: dict[str, list[dict[str, Any]]]) -> dict[str, Any]: def _search_coverage(self, submissions: list[dict[str, Any]], evidence_by_submission: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
@ -436,8 +679,16 @@ class CopyrighterStore(
# Attribute via _evidence_matches_provider (domain-aware) so Google # Attribute via _evidence_matches_provider (domain-aware) so Google
# Custom Search (source "google", domain "google_custom_search") is # Custom Search (source "google", domain "google_custom_search") is
# counted under google_search, not the Vision "google" bucket. # counted under google_search, not the Vision "google" bucket.
# Require an actual result, not merely a matching row: a submission
# whose only search evidence is a failure/skip has no coverage, and
# counting it inflated the coverage ratio into the "good" band while
# the operator still had nothing to review (mirrors the llm bucket).
for search_provider in ("naver", "google", "google_search"): for search_provider in ("naver", "google", "google_search"):
if any(_evidence_matches_provider(item, search_provider) for item in evidence): if any(
_evidence_matches_provider(item, search_provider)
and _provider_item_has_result(item)
for item in evidence
):
has_search_evidence = True has_search_evidence = True
provider_stats[search_provider]["evidenceSubmissions"] += 1 provider_stats[search_provider]["evidenceSubmissions"] += 1
if has_search_evidence: if has_search_evidence:
@ -566,22 +817,54 @@ class CopyrighterStore(
if decision == "rejected" and not memo.strip(): if decision == "rejected" and not memo.strip():
raise ValueError("reject decision requires memo") raise ValueError("reject decision requires memo")
# Serialize with the other submission writers (rescore, provider sweeps)
# 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: with self._transaction() as conn:
submission = self._get("submissions", submission_id, conn=conn) submission = self._get("submissions", submission_id, conn=conn)
submission["decisionStatus"] = decision submission["decisionStatus"] = decision
submission["applicantStatus"] = "승인 대기" if decision == "approved" else "반려 예정" if decision == "rejected" else "검토 중" submission["applicantStatus"] = "승인 대기" if decision == "approved" else "반려 예정" if decision == "rejected" else "검토 중"
submission["decisionSnapshot"] = snapshot
self._put("submissions", submission_id, submission, conn=conn) self._put("submissions", submission_id, submission, conn=conn)
if decision in {"held", "rejected"}: # A hold taken while an external check had not run is "look again
# after the search is back", not "this image is suspect". Turning
# it into a watchlist entry told every later submission of the
# same image the latter (codex F1, 2026-09-23). A rejection is a
# judgement about the image regardless of coverage.
waiting_for_search = decision == "held" and has_coverage_gap(
[_domain_evidence_from_ui(item) for item in self._evidence_for_submission(submission_id)]
)
if decision == "rejected" or (decision == "held" and not waiting_for_search):
self._create_or_update_watchlist_entry(submission_id, decision, memo, image_store, conn=conn) self._create_or_update_watchlist_entry(submission_id, decision, memo, image_store, conn=conn)
# Reversing a hold/rejection into an approval withdraws the entry
# that decision derived - otherwise the current approval and the
# old "suspect" criterion coexist, and every later match of this
# image still scores it (codex F4 / 4.2).
rescore_ids = (
self._withdraw_auto_watchlist_entry(submission_id, conn=conn) if decision == "approved" else []
)
self.add_audit_event( self.add_audit_event(
actor="rights.ops", actor=current_actor(),
event="Operator decision created", event="Operator decision created",
object_id=submission_id, object_id=submission_id,
change=f"{decision} · {memo or 'memo optional'}", change=(
f"{decision} · {memo or 'memo optional'}"
+ (f" · {HOLD_WAITING_MARKER}" if waiting_for_search else "")
),
conn=conn, conn=conn,
) )
# Derived state, after the commit but still under the write lock.
for other_id in rescore_ids:
self._rescore_submission(other_id)
return self.review(submission_id) return self.review(submission_id)
def mark_evidence_status( def mark_evidence_status(
@ -607,14 +890,14 @@ class CopyrighterStore(
payload["operatorStatusLabel"] = EVIDENCE_OPERATOR_STATUSES[status] payload["operatorStatusLabel"] = EVIDENCE_OPERATOR_STATUSES[status]
payload["operatorStatusNote"] = note.strip() payload["operatorStatusNote"] = note.strip()
payload["operatorStatusAt"] = _now_label() payload["operatorStatusAt"] = _now_label()
payload["operatorStatusActor"] = "rights.ops" payload["operatorStatusActor"] = current_actor()
if status in NON_CONTRIBUTING_OPERATOR_STATUSES: if status in NON_CONTRIBUTING_OPERATOR_STATUSES:
payload["contributed"] = False payload["contributed"] = False
else: else:
payload["contributed"] = _default_evidence_contribution(payload) payload["contributed"] = _default_evidence_contribution(payload)
self._put("evidence", evidence_id, payload, conn=conn) self._put("evidence", evidence_id, payload, conn=conn)
self.add_audit_event( self.add_audit_event(
actor="rights.ops", actor=current_actor(),
event="Evidence status changed", event="Evidence status changed",
object_id=evidence_id, object_id=evidence_id,
change=f"{EVIDENCE_OPERATOR_STATUSES[status]} · {note or 'note optional'}", change=f"{EVIDENCE_OPERATOR_STATUSES[status]} · {note or 'note optional'}",
@ -623,10 +906,76 @@ class CopyrighterStore(
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
return self.review(submission_id) return self.review(submission_id)
def mark_evidence_status_batch(
self,
submission_id: str,
evidence_ids: list[str],
status: str,
note: str = "",
) -> dict[str, Any]:
"""Mark many evidence rows in one action.
The per-row endpoint made operator feedback cost more labour than it
saved: dropping one real case from 100 to 35 meant marking 51 google
rows one at a time, because the scorer only sheds points when the rows
carrying them are marked. That is the opposite of the tool's purpose.
One transaction, one rescore, one audit event. The audit part matters on
its own: this was ONE operator action, and writing 51 events for it would
both misrepresent it and spend the 500-row audit cap on a single click.
The per-row who/when/note still lands on each evidence payload, so
nothing about the individual rows becomes unanswerable.
"""
if status not in EVIDENCE_OPERATOR_STATUSES:
raise ValueError("unsupported evidence status")
unique_ids = list(dict.fromkeys(str(item).strip() for item in evidence_ids if str(item).strip()))
if not unique_ids:
raise ValueError("evidence ids required")
if len(unique_ids) > MAX_EVIDENCE_STATUS_BATCH:
raise ValueError(f"batch too large: {len(unique_ids)} > {MAX_EVIDENCE_STATUS_BATCH}")
label = EVIDENCE_OPERATOR_STATUSES[status]
stamped_at = _now_label()
with self._write_lock:
with self._transaction() as conn:
# Read and validate every row before writing any of them: a batch
# that names someone else's evidence fails whole rather than
# leaving half the rows marked.
payloads = []
for evidence_id in unique_ids:
payload = self._get("evidence", evidence_id, conn=conn)
if str(payload.get("submission_id", "")) != submission_id:
raise ValueError("evidence does not belong to submission")
payloads.append((evidence_id, payload))
for evidence_id, payload in payloads:
payload["operatorStatus"] = status
payload["operatorStatusLabel"] = label
payload["operatorStatusNote"] = note.strip()
payload["operatorStatusAt"] = stamped_at
payload["operatorStatusActor"] = current_actor()
if status in NON_CONTRIBUTING_OPERATOR_STATUSES:
payload["contributed"] = False
else:
payload["contributed"] = _default_evidence_contribution(payload)
self._put("evidence", evidence_id, payload, conn=conn)
self.add_audit_event(
actor=current_actor(),
event="Evidence status changed in batch",
object_id=submission_id,
change=f"{label} · {len(payloads)}건 · {note or 'note optional'}",
conn=conn,
)
self._rescore_submission(submission_id)
return self.review(submission_id)
def providers(self) -> list[dict[str, Any]]: def providers(self) -> list[dict[str, Any]]:
return self._all("providers") return self._all("providers")
def set_provider_enabled(self, provider_id: str, enabled: bool) -> list[dict[str, Any]]: def set_provider_enabled(self, provider_id: str, enabled: bool) -> list[dict[str, Any]]:
with self._write_lock:
provider = self._get("providers", provider_id) provider = self._get("providers", provider_id)
if enabled and provider_id != "internal": if enabled and provider_id != "internal":
runtime_provider = self.provider_runtime.provider_payloads.get(provider_id, {}) runtime_provider = self.provider_runtime.provider_payloads.get(provider_id, {})
@ -634,28 +983,47 @@ class CopyrighterStore(
reason = str(runtime_provider.get("lastFailure", "")) or f"{provider_id} provider not connected" reason = str(runtime_provider.get("lastFailure", "")) or f"{provider_id} provider not connected"
raise ValueError(reason) raise ValueError(reason)
provider["enabled"] = bool(enabled) provider["enabled"] = bool(enabled)
self._put("providers", provider_id, provider) # Record that this on/off was an explicit operator action so a later
self._sync_submission_provider_state() # reload does not silently flip it back to the connectivity default.
provider["operatorDisabled"] = not bool(enabled)
with self._transaction() as conn:
self._put("providers", provider_id, provider, conn=conn)
self.add_audit_event( self.add_audit_event(
actor="admin.ops", actor="admin.ops",
event="Provider setting changed", event="Provider setting changed",
object_id=provider["name"], object_id=provider["name"],
change="enabled" if enabled else "disabled", change="enabled" if enabled else "disabled",
conn=conn,
) )
self._sync_submission_provider_state()
return self.providers() return self.providers()
def emergency_disable_external_providers(self) -> list[dict[str, Any]]: def emergency_disable_external_providers(self) -> list[dict[str, Any]]:
for provider in self.providers(): with self._write_lock:
providers = self.providers()
external_provider_ids = [
provider["id"] for provider in providers if provider["id"] != "internal"
]
submissions = self._all("submissions")
with self._transaction() as conn:
for provider in providers:
if provider["id"] != "internal": if provider["id"] != "internal":
provider["enabled"] = False provider["enabled"] = False
self._put("providers", provider["id"], provider) # Deliberate admin stop — must persist across reloads.
external_provider_ids = [provider["id"] for provider in self.providers() if provider["id"] != "internal"] provider["operatorDisabled"] = True
for submission in self._all("submissions"): self._put("providers", provider["id"], provider, conn=conn)
for submission in submissions:
provider_state = submission.setdefault("providerState", {}) provider_state = submission.setdefault("providerState", {})
for provider_id in external_provider_ids: for provider_id in external_provider_ids:
provider_state[provider_id] = "disabled" provider_state[provider_id] = "disabled"
self._put("submissions", submission["id"], submission) self._put("submissions", submission["id"], submission, conn=conn)
self.add_audit_event("admin.ops", "Provider setting changed", "external providers", "emergency disabled") self.add_audit_event(
"admin.ops",
"Provider setting changed",
"external providers",
"emergency disabled",
conn=conn,
)
return self.providers() return self.providers()
def audit_events(self, limit: int = 500) -> list[dict[str, Any]]: def audit_events(self, limit: int = 500) -> list[dict[str, Any]]:

View file

@ -0,0 +1,243 @@
"""Online snapshot backup for CopyrighterStore, as a mixin.
The whole operational record — decisions, knowledge base, audit trail — lives in
one SQLite file plus a few media directories, and an air-gapped deployment has no
cloud backup to fall back on. This produces a consistent snapshot while the
server keeps serving.
WAL note: copying the .sqlite3 file while the server runs yields a torn snapshot
because recent commits still live in the -wal sidecar. sqlite3's online backup
API checkpoints into the destination instead, which is why the DB is copied
through `Connection.backup` rather than shutil.
"""
from __future__ import annotations
from rights_filter.server.operator_identity import current_actor
import shutil
import sqlite3
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any
# Store-owned media roots included in a snapshot, as (archive folder, attribute).
# Submission source images are handled separately: they live in whatever folder
# the active queue points at, which may be outside the data root.
#
# Face crops are deliberately NOT here. They are biometric derivatives under a
# retention deadline (purge_expired_face_crops, default 90 days) and snapshots
# are pruned by count, not by age — so an archived crop outlived the deadline
# inside an unencrypted zip. Restoring one was worse still: the files are
# rewritten, which reset every mtime and started the retention clock over. They
# are regenerable from the source image by re-analysis, so dropping them from
# the snapshot costs nothing that a reload does not rebuild.
_MEDIA_SOURCES = (
("knowledge", "knowledge_image_dir"),
("collections", "collection_image_dir"),
)
DEFAULT_BACKUP_RETENTION = 10
class StoreBackupMixin:
def create_backup(
self, *, retention: int = DEFAULT_BACKUP_RETENTION
) -> dict[str, Any]:
"""Write a timestamped snapshot zip and prune older ones."""
backup_dir = Path(self.db_path).resolve().parent / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
# Two snapshots inside the same second would otherwise overwrite each
# other, silently destroying the earlier restore point.
archive_path = backup_dir / f"copyrighter-snapshot-{stamp}.zip"
suffix = 1
while archive_path.exists():
suffix += 1
archive_path = backup_dir / f"copyrighter-snapshot-{stamp}-{suffix}.zip"
staged_db = backup_dir / f".snapshot-{archive_path.stem}.sqlite3"
try:
self._snapshot_database(staged_db)
with zipfile.ZipFile(
archive_path, "w", compression=zipfile.ZIP_DEFLATED
) as archive:
archive.write(staged_db, arcname="copyrighter.sqlite3")
media_counts = self._archive_media(archive)
finally:
staged_db.unlink(missing_ok=True)
pruned = _prune_old_backups(backup_dir, retention)
size_bytes = archive_path.stat().st_size
self.add_audit_event(
current_actor(),
"Backup snapshot created",
archive_path.name,
f"{size_bytes} bytes · media {media_counts} · pruned {pruned}",
)
return {
"file": archive_path.name,
"path": str(archive_path),
"sizeBytes": size_bytes,
"createdAt": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"mediaFiles": media_counts,
"prunedFiles": pruned,
}
def backups(self) -> list[dict[str, Any]]:
backup_dir = Path(self.db_path).resolve().parent / "backups"
if not backup_dir.exists():
return []
entries = []
for path in sorted(backup_dir.glob("copyrighter-snapshot-*.zip"), reverse=True):
stat = path.stat()
entries.append(
{
"file": path.name,
"sizeBytes": stat.st_size,
"createdAt": datetime.fromtimestamp(stat.st_mtime).strftime(
"%Y-%m-%d %H:%M:%S"
),
}
)
return entries
def _snapshot_database(self, destination: Path) -> None:
# Connection.backup copies a transactionally consistent image, including
# anything still sitting in the WAL, without blocking writers.
#
# sqlite3's context manager only commits/rolls back — it does NOT close.
# Both handles must be closed explicitly or the staged file stays locked
# and cannot be zipped/removed on Windows.
source = self._connect()
try:
target = sqlite3.connect(destination)
try:
source.backup(target)
finally:
target.close()
finally:
source.close()
def _archive_media(self, archive: zipfile.ZipFile) -> int:
roots: list[tuple[str, Path]] = []
for arc_folder, attribute in _MEDIA_SOURCES:
root = getattr(self, attribute, None)
if root is not None:
roots.append((arc_folder, Path(root)))
submission_root = self._active_submission_root()
if submission_root is not None:
roots.append(("submissions", submission_root))
written = 0
for arc_folder, root_path in roots:
if not root_path.exists():
continue
for path in sorted(root_path.rglob("*")):
if not path.is_file():
continue
archive.write(
path,
arcname=f"media/{arc_folder}/{path.relative_to(root_path).as_posix()}",
)
written += 1
return written
def _active_submission_root(self) -> Path | None:
# Best-effort: a store with no configured queue still produces a valid
# database snapshot, just without submission source images.
try:
queue = self.bootstrap_active_queue()
except Exception:
return None
if not queue or not queue.get("folderPath"):
return None
return Path(str(queue["folderPath"]))
def restore_backup(archive_path: Path | str, target_db: Path | str) -> dict[str, Any]:
"""Restore a snapshot over a STOPPED server's data directory.
Offline on purpose: replacing the database under a live server would leave
open connections reading a file that no longer exists.
"""
archive = Path(archive_path)
db_path = Path(target_db)
if not archive.exists():
raise ValueError(f"backup archive not found: {archive}")
data_root = db_path.resolve().parent
with zipfile.ZipFile(archive) as bundle:
names = set(bundle.namelist())
if "copyrighter.sqlite3" not in names:
raise ValueError("archive does not contain copyrighter.sqlite3")
if db_path.exists():
# Keep the pre-restore database next to it; an operator who restores
# the wrong snapshot would otherwise have no way back.
rescue = db_path.with_suffix(
f".sqlite3.pre-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
)
shutil.copy2(db_path, rescue)
# Stale sidecars would be replayed on top of the restored file.
for sidecar in (f"{db_path}-wal", f"{db_path}-shm"):
Path(sidecar).unlink(missing_ok=True)
db_path.parent.mkdir(parents=True, exist_ok=True)
with bundle.open("copyrighter.sqlite3") as source, open(db_path, "wb") as target:
shutil.copyfileobj(source, target)
restored_media = 0
for name in sorted(names):
if not name.startswith("media/") or name.endswith("/"):
continue
relative = Path(name).relative_to("media")
if relative.parts[0] in _RETENTION_BOUND_MEDIA:
# Archives written before face crops were excluded still carry
# them. Restoring would re-mint their mtime and hand expired
# biometric data a fresh retention window; re-analysis rebuilds
# the crops if they are still wanted.
continue
destination = data_root / _MEDIA_RESTORE_DIRS.get(
relative.parts[0], relative.parts[0]
)
destination = destination.joinpath(*relative.parts[1:])
destination.parent.mkdir(parents=True, exist_ok=True)
with bundle.open(name) as source, open(destination, "wb") as target:
shutil.copyfileobj(source, target)
restored_media += 1
return {"database": str(db_path), "mediaFiles": restored_media}
# Archive folders whose contents must never be restored: their retention clock
# is file mtime, which a restore would reset. See _MEDIA_SOURCES.
_RETENTION_BOUND_MEDIA = frozenset({"face-crops"})
# Archive folder -> path relative to the data root, so a restore lands where the
# running server expects to find each media kind.
_MEDIA_RESTORE_DIRS = {
"knowledge": "knowledge/images",
"collections": "collections/images",
# Submission images restore under the data root's default submissions folder.
# If the queue pointed elsewhere, re-import that folder after restoring.
"submissions": "submissions",
}
def _prune_old_backups(backup_dir: Path, retention: int) -> int:
keep = max(1, int(retention))
snapshots = sorted(
backup_dir.glob("copyrighter-snapshot-*.zip"),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
removed = 0
for path in snapshots[keep:]:
try:
path.unlink()
removed += 1
except OSError:
continue
return removed

View file

@ -5,6 +5,10 @@ depend on it without import cycles. Extracted from sqlite_store.py unchanged.
from __future__ import annotations from __future__ import annotations
# Appended to a hold's audit change text when the hold was taken while an
# external check had not run. The export's memo lookup strips it back off.
HOLD_WAITING_MARKER = "검색 대기 (주의 후보 미생성)"
EVIDENCE_OPERATOR_STATUSES = { EVIDENCE_OPERATOR_STATUSES = {
"used_for_judgment": "판단에 사용", "used_for_judgment": "판단에 사용",
"irrelevant": "무관", "irrelevant": "무관",
@ -23,6 +27,7 @@ STORE_TABLES = {
"corrections", "corrections",
"audit_events", "audit_events",
"submission_queues", "submission_queues",
"decision_memo_presets",
} }
RISK_BANDS = ("low", "medium", "high", "failed", "pending") RISK_BANDS = ("low", "medium", "high", "failed", "pending")
DECISION_STATUSES = ("unreviewed", "held", "rejected", "approved", "corrected") DECISION_STATUSES = ("unreviewed", "held", "rejected", "approved", "corrected")
@ -47,6 +52,11 @@ DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD = 40
MIN_COVERAGE_THRESHOLD = 0 MIN_COVERAGE_THRESHOLD = 0
MAX_COVERAGE_THRESHOLD = 100 MAX_COVERAGE_THRESHOLD = 100
DEFAULT_FACE_CROP_RETENTION_DAYS = 90 DEFAULT_FACE_CROP_RETENTION_DAYS = 90
# Upper bound on LLM narrations generated by one seed/reload. A folder import can
# bring in hundreds of submissions; narrating all of them in one request makes
# the reload take as long as the model does, times the batch. The rest are picked
# up by the next reload (narration is only-if-missing and therefore resumable).
MAX_LLM_SUMMARIES_PER_SEED = 20
PAYLOAD_REQUIRED_FIELDS = { PAYLOAD_REQUIRED_FIELDS = {
"submissions": { "submissions": {
"id", "id",

View file

@ -0,0 +1,161 @@
"""Submission-to-submission duplicate / prior-case index, as a mixin.
pHash was only ever compared against the knowledge base and against search-result
images — never between submissions — so the same picture arriving twice was
reviewed twice from scratch, and a resubmission of an already-rejected image was
invisible unless a watchlist entry happened to exist.
The fingerprints are already computed during analysis but the evidence payload
whitelist drops them, so this module backfills `fingerprints` onto the submission
record (idempotent, image read only when missing) and groups from there.
"""
from __future__ import annotations
import logging
from typing import Any
from rights_filter.analysis.fingerprints import FingerprintService
from rights_filter.server.image_store import LocalSubmissionImageStore
logger = logging.getLogger(__name__)
# Hamming-distance similarity at/above which two submissions are treated as the
# same picture. Deliberately stricter than the knowledge-base match threshold
# (0.9): this drives "these are duplicates, handle them together", so a false
# grouping is more costly than a missed one.
DUPLICATE_SIMILARITY_THRESHOLD = 0.97
class StoreDuplicatesMixin:
def backfill_submission_fingerprints(
self, image_store: LocalSubmissionImageStore, queue_id: str | None = None
) -> int:
"""Persist exact/perceptual fingerprints on submissions missing them."""
service = FingerprintService()
updated = 0
for submission in self._all("submissions", queue_id=queue_id):
if submission.get("fingerprints"):
continue
submission_id = str(submission["id"])
fingerprints = _fingerprints_for_submission(
service, image_store, submission_id
)
if fingerprints is None:
continue
submission["fingerprints"] = fingerprints
self._put("submissions", submission_id, submission)
updated += 1
return updated
def duplicate_groups(self, queue_id: str | None = None) -> dict[str, dict[str, Any]]:
"""Map submission id -> duplicate-group summary for the given queue.
Only submissions that have at least one peer appear in the result.
"""
submissions = [
item for item in self._all("submissions", queue_id=queue_id)
if _perceptual_of(item) or _exact_of(item)
]
groups = _cluster(submissions)
summary: dict[str, dict[str, Any]] = {}
for group in groups:
if len(group) < 2:
continue
for submission in group:
peers = [other for other in group if other["id"] != submission["id"]]
decided_peers = [
{
"submissionId": str(peer["id"]),
"title": str(peer.get("title", "")),
"decision": str(peer.get("decisionStatus", "unreviewed")),
}
for peer in peers
if str(peer.get("decisionStatus", "unreviewed")) != "unreviewed"
]
summary[str(submission["id"])] = {
"size": len(group),
"peerIds": [str(peer["id"]) for peer in peers],
"priorDecisions": decided_peers,
"conflicting": _has_conflicting_decisions(group),
}
return summary
def _fingerprints_for_submission(
service: FingerprintService,
image_store: LocalSubmissionImageStore,
submission_id: str,
) -> dict[str, str] | None:
try:
payload = image_store.image_payload(submission_id)
except (KeyError, OSError, ValueError):
# A submission whose file is gone simply has no fingerprint; the queue
# prune handles its removal separately.
return None
try:
fingerprints = service.fingerprints_for(payload.content)
except Exception:
logger.warning("Could not fingerprint submission %s", submission_id)
return None
return {"exact": fingerprints.exact, "perceptual": fingerprints.perceptual}
def _exact_of(submission: dict[str, Any]) -> str:
return str((submission.get("fingerprints") or {}).get("exact", ""))
def _perceptual_of(submission: dict[str, Any]) -> str:
return str((submission.get("fingerprints") or {}).get("perceptual", ""))
def _cluster(submissions: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
"""Group submissions by exact hash, then merge near-identical pHash groups.
Queues hold hundreds of cases, so the O(n^2) perceptual pass is cheap enough
and avoids the accuracy loss of hash bucketing.
"""
service = FingerprintService()
groups: list[list[dict[str, Any]]] = []
by_exact: dict[str, list[dict[str, Any]]] = {}
for submission in submissions:
exact = _exact_of(submission)
if exact:
by_exact.setdefault(exact, []).append(submission)
else:
groups.append([submission])
groups.extend(by_exact.values())
merged: list[list[dict[str, Any]]] = []
for group in groups:
representative = _perceptual_of(group[0])
target = None
if representative:
for candidate in merged:
candidate_hash = _perceptual_of(candidate[0])
if not candidate_hash:
continue
if (
service.similarity(representative, candidate_hash)
>= DUPLICATE_SIMILARITY_THRESHOLD
):
target = candidate
break
if target is None:
merged.append(list(group))
else:
target.extend(group)
return merged
def _has_conflicting_decisions(group: list[dict[str, Any]]) -> bool:
decisions = {
str(item.get("decisionStatus", "unreviewed"))
for item in group
if str(item.get("decisionStatus", "unreviewed")) != "unreviewed"
}
# The same image approved in one case and rejected in another is a review
# consistency problem the operator should see before deciding again.
return "approved" in decisions and bool(decisions & {"rejected", "held"})

View file

@ -9,12 +9,19 @@ rescoring provided by the host class. Behavior unchanged.
from __future__ import annotations from __future__ import annotations
from rights_filter.server.operator_identity import current_actor
import json import json
import logging
from dataclasses import replace from dataclasses import replace
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Any from typing import Any
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.fingerprints import FingerprintService
from rights_filter.analysis.internal_analyzer import InternalAnalyzer from rights_filter.analysis.internal_analyzer import InternalAnalyzer
from rights_filter.analysis.preprocessing import build_external_derivative, build_face_crop_derivatives from rights_filter.analysis.preprocessing import build_external_derivative, build_face_crop_derivatives
@ -41,19 +48,64 @@ from rights_filter.server.store_serialization import (
_query_history_status, _query_history_status,
) )
logger = logging.getLogger(__name__)
class StoreEnrichmentMixin: class StoreEnrichmentMixin:
def _external_calls_blocked(self, provider_id: str) -> bool:
"""True when an operator deliberately stopped this provider.
Gate on `operatorDisabled`, not `enabled`: the latter also tracks
connectivity/health, so gating on it would suppress traffic the operator
never asked to stop. `operatorDisabled` is set only by the per-provider
toggle and by the emergency switch, and it persists across reloads.
Every automatic outbound path must consult this. The emergency switch
used to write the flag and nothing else read it, so pressing it stopped
manual search only while folder reload / upload / rerun-enrichment kept
calling out — a stop button that reported success without stopping
anything is worse than no button at all.
"""
try:
provider = self._get("providers", provider_id)
except KeyError:
return False
return bool(provider.get("operatorDisabled"))
def _sync_provider_payloads(self) -> None: def _sync_provider_payloads(self) -> None:
with self._write_lock:
existing = {provider["id"]: provider for provider in self._all("providers")} existing = {provider["id"]: provider for provider in self._all("providers")}
for provider_id, payload in self.provider_runtime.provider_payloads.items(): for provider_id, payload in self.provider_runtime.provider_payloads.items():
prior = existing.get(provider_id)
runtime_enabled = bool(payload.get("enabled", False))
# `operatorDisabled` distinguishes a deliberate operator/admin
# disable from a connectivity disable. Only the former survives a
# reconnect; a provider that was merely missing credentials is
# re-enabled once the adapter connects (e.g. env change adds keys).
operator_disabled = bool(prior.get("operatorDisabled", False)) if prior else False
if not runtime_enabled:
# Adapter not connected — cannot be enabled regardless.
enabled = False
elif operator_disabled:
# Operator deliberately disabled a connected provider; do not
# let reload/collection silently re-enable it (no audit trail).
enabled = False
else:
enabled = runtime_enabled
merged = { merged = {
**payload, **payload,
"usage": existing.get(provider_id, {}).get("usage", payload["usage"]), "usage": existing.get(provider_id, {}).get("usage", payload["usage"]),
"enabled": enabled,
"operatorDisabled": operator_disabled,
} }
self._put("providers", provider_id, merged) self._put("providers", provider_id, merged)
self._sync_submission_provider_state() self._sync_submission_provider_state()
def _sync_submission_provider_state(self, queue_id: str | None = None) -> None: def _sync_submission_provider_state(self, queue_id: str | None = None) -> None:
# Full-table read-modify-write sweep; serialize it so a concurrent
# operator write to an unrelated submission is not reverted by a stale
# copy read at the top of this loop.
with self._write_lock:
provider_payloads = {provider["id"]: provider for provider in self._all("providers")} provider_payloads = {provider["id"]: provider for provider in self._all("providers")}
evidence_by_submission = self._evidence_by_submission(queue_id=queue_id) evidence_by_submission = self._evidence_by_submission(queue_id=queue_id)
for submission in self._all("submissions", queue_id=queue_id): for submission in self._all("submissions", queue_id=queue_id):
@ -126,12 +178,9 @@ class StoreEnrichmentMixin:
evidence = Evidence( evidence = Evidence(
source=EvidenceSource.FACE_PERSON, source=EvidenceSource.FACE_PERSON,
reason="Face/person detected", reason=face_person_reason(signal),
confidence=0.8, confidence=signal.confidence,
data={ data=face_person_evidence_data(signal),
"face_count": signal.face_count,
"person_count": signal.person_count,
},
) )
self._put( self._put(
"evidence", "evidence",
@ -140,6 +189,56 @@ class StoreEnrichmentMixin:
) )
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
def _face_crop_path(self, submission_id: str, crop: dict[str, Any]) -> Path:
return self.face_crop_image_dir / submission_id / f"crop-{crop.get('index')}.jpg"
def _face_crops_need_sync(self, submission: dict[str, Any]) -> bool:
"""Whether a reload has to rebuild (or clear) this row's face crops.
A missing key means the row predates the feature. A key whose files are
gone means the crops were taken out from under the DB: archive restore
skips retention-bound media on purpose (store_backup.py), and nothing
reconciled the references it left behind - the rebuild used to be gated
on the key's absence alone, so a restored row kept advertising
thumbnails the server would 404 forever.
"""
if "faceCrops" not in submission:
return True
submission_id = str(submission.get("id", ""))
return any(
not self._face_crop_path(submission_id, crop).exists()
for crop in submission.get("faceCrops", [])
if isinstance(crop, dict)
)
def _prune_missing_face_crop_refs(self, submission_id: str) -> None:
"""Drop crop references whose file is gone when no rebuild is possible.
Reached when the original image is unavailable too (a restore that
skipped both, or a deleted upload). purge_expired_face_crops only walks
files that still exist, so without this the row is stuck advertising
biometric thumbnails that cannot be served or regenerated.
"""
try:
submission = self._get("submissions", submission_id)
except KeyError:
return
crops = [crop for crop in submission.get("faceCrops", []) if isinstance(crop, dict)]
remaining = [crop for crop in crops if self._face_crop_path(submission_id, crop).exists()]
if len(remaining) == len(crops):
return
dropped = len(crops) - len(remaining)
with self._transaction() as conn:
submission["faceCrops"] = remaining
self._put("submissions", submission_id, submission, conn=conn)
self.add_audit_event(
actor="system",
event="Face crop reference cleared",
object_id=submission_id,
change=f"{dropped} crop reference(s) had no file and no original image to rebuild from",
conn=conn,
)
def _sync_face_crops( def _sync_face_crops(
self, self,
submission_id: str, submission_id: str,
@ -150,6 +249,10 @@ class StoreEnrichmentMixin:
try: try:
original = image_store.image_payload(submission_id) original = image_store.image_payload(submission_id)
except Exception: except Exception:
# A missing/unreadable image is expected for some rows, but log it so
# a real disk/permission failure is not invisible.
logger.warning("face-crop sync skipped: could not load image for %s", submission_id, exc_info=True)
self._prune_missing_face_crop_refs(submission_id)
return return
signal = HeuristicFacePersonDetector().detect(original) signal = HeuristicFacePersonDetector().detect(original)
@ -277,6 +380,8 @@ class StoreEnrichmentMixin:
) -> list[Evidence]: ) -> list[Evidence]:
if self.provider_runtime.google_adapter is None: if self.provider_runtime.google_adapter is None:
return [] return []
if self._external_calls_blocked("google"):
return []
original_image = image_store.image_payload(submission_id) original_image = image_store.image_payload(submission_id)
derivative = build_external_derivative(original_image) derivative = build_external_derivative(original_image)
@ -308,13 +413,20 @@ class StoreEnrichmentMixin:
item.source in {EvidenceSource.FAILURE, EvidenceSource.EXTERNAL_SKIPPED} item.source in {EvidenceSource.FAILURE, EvidenceSource.EXTERNAL_SKIPPED}
for item in domain_evidence for item in domain_evidence
): ):
google_provider["lastFailure"] = domain_evidence[0].reason if domain_evidence else "Google image search failed" google_provider["lastFailure"] = next(
(
item.reason
for item in domain_evidence
if item.source in {EvidenceSource.FAILURE, EvidenceSource.EXTERNAL_SKIPPED}
),
"Google image search failed",
)
else: else:
google_provider["lastSuccess"] = _now_label() google_provider["lastSuccess"] = _now_label()
google_provider["lastFailure"] = "없음" google_provider["lastFailure"] = "없음"
self._apply_provider_usage_delta("google", call_count, google_provider) self._apply_provider_usage_delta("google", call_count, google_provider)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Provider called", "Provider called",
f"google / {submission_id}", f"google / {submission_id}",
"image web detection rerun", "image web detection rerun",
@ -329,6 +441,10 @@ class StoreEnrichmentMixin:
) -> tuple[list[Evidence], int]: ) -> tuple[list[Evidence], int]:
if self.provider_runtime.google_adapter is None: if self.provider_runtime.google_adapter is None:
return [], 0 return [], 0
# Gated again on its own: this path transmits biometric derivatives, so
# it must never depend on a caller upstream having checked.
if self._external_calls_blocked("google"):
return [], 0
if not self.provider_runtime.face_crop_web_detection_enabled: if not self.provider_runtime.face_crop_web_detection_enabled:
return [], 0 return [], 0
@ -359,12 +475,21 @@ class StoreEnrichmentMixin:
if not crops: if not crops:
continue continue
crop = crops[0] crop = crops[0]
call_count += 1
crop_evidence = self.provider_runtime.google_adapter.detect( crop_evidence = self.provider_runtime.google_adapter.detect(
f"{submission_id}:face-crop-{crop_index}", f"{submission_id}:face-crop-{crop_index}",
crop, crop,
self.provider_runtime.external_policy, self.provider_runtime.external_policy,
) )
# Only count a crop as transmitted when the adapter actually issued a
# request. A policy refusal returns EXTERNAL_SKIPPED without sending
# anything, so counting it inflated provider usage and — worse —
# produced a false "personal image sent to external provider" audit
# record for biometric data that never left the system. A FAILURE
# still counts: the request may have been sent before it failed.
if any(
item.source != EvidenceSource.EXTERNAL_SKIPPED for item in crop_evidence
):
call_count += 1
for item in crop_evidence: for item in crop_evidence:
face_web_evidence = _face_crop_web_evidence(submission_id, crop_index, item) face_web_evidence = _face_crop_web_evidence(submission_id, crop_index, item)
evidence.append(face_web_evidence) evidence.append(face_web_evidence)
@ -396,6 +521,8 @@ class StoreEnrichmentMixin:
) -> None: ) -> None:
if self.provider_runtime.google_custom_search_adapter is None: if self.provider_runtime.google_custom_search_adapter is None:
return return
if self._external_calls_blocked("google_search"):
return
if self.provider_runtime.auto_google_custom_query_limit <= 0: if self.provider_runtime.auto_google_custom_query_limit <= 0:
return return
@ -408,7 +535,7 @@ class StoreEnrichmentMixin:
return return
existing_signatures = _existing_google_custom_query_signatures( existing_signatures = _existing_google_custom_query_signatures(
self._evidence_by_submission().get(submission_id, []) self._evidence_for_submission(submission_id)
) )
can_compare_search_images = self._can_compare_search_result_images( can_compare_search_images = self._can_compare_search_result_images(
submission_id, submission_id,
@ -542,7 +669,14 @@ class StoreEnrichmentMixin:
item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE} item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
for item in all_domain_evidence for item in all_domain_evidence
): ):
google_search_provider["lastFailure"] = all_domain_evidence[0].reason if all_domain_evidence else "Google custom search failed" google_search_provider["lastFailure"] = next(
(
item.reason
for item in all_domain_evidence
if item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
),
"Google custom search failed",
)
else: else:
google_search_provider["lastSuccess"] = _now_label() google_search_provider["lastSuccess"] = _now_label()
google_search_provider["lastFailure"] = "없음" google_search_provider["lastFailure"] = "없음"
@ -562,6 +696,8 @@ class StoreEnrichmentMixin:
) -> None: ) -> None:
if self.provider_runtime.naver_adapter is None: if self.provider_runtime.naver_adapter is None:
return return
if self._external_calls_blocked("naver"):
return
if self.provider_runtime.auto_naver_query_limit <= 0: if self.provider_runtime.auto_naver_query_limit <= 0:
return return
@ -574,7 +710,7 @@ class StoreEnrichmentMixin:
return return
existing_signatures = _existing_naver_query_signatures( existing_signatures = _existing_naver_query_signatures(
self._evidence_by_submission().get(submission_id, []) self._evidence_for_submission(submission_id)
) )
can_compare_search_images = self._can_compare_search_result_images( can_compare_search_images = self._can_compare_search_result_images(
submission_id, submission_id,
@ -777,7 +913,14 @@ class StoreEnrichmentMixin:
item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE} item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
for item in all_domain_evidence for item in all_domain_evidence
): ):
naver_provider["lastFailure"] = all_domain_evidence[0].reason if all_domain_evidence else "Naver search failed" naver_provider["lastFailure"] = next(
(
item.reason
for item in all_domain_evidence
if item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
),
"Naver search failed",
)
else: else:
naver_provider["lastSuccess"] = _now_label() naver_provider["lastSuccess"] = _now_label()
naver_provider["lastFailure"] = "없음" naver_provider["lastFailure"] = "없음"

View file

@ -9,13 +9,16 @@ and self.* attributes provided by the host class. Behavior unchanged.
from __future__ import annotations from __future__ import annotations
from rights_filter.server.operator_identity import current_actor
import json import json
import logging
import sqlite3 import sqlite3
from typing import Any from typing import Any
from rights_filter.analysis.fingerprints import FingerprintService from rights_filter.analysis.fingerprints import FingerprintService
from rights_filter.analysis.search_result_promoter import SearchResultPromoter from rights_filter.analysis.search_result_promoter import SearchResultPromoter
from rights_filter.domain.records import Evidence, EvidenceSource from rights_filter.domain.records import DataClass, Evidence, EvidenceSource
from rights_filter.governance.policies import assert_operator_evidence_payload_allowed
from rights_filter.server.image_store import LocalSubmissionImageStore from rights_filter.server.image_store import LocalSubmissionImageStore
from rights_filter.server.store_serialization import ( from rights_filter.server.store_serialization import (
_domain_evidence_from_ui, _domain_evidence_from_ui,
@ -32,6 +35,8 @@ from rights_filter.server.store_serialization import (
) )
from rights_filter.server.store_text import _text_list, _unique_texts from rights_filter.server.store_text import _text_list, _unique_texts
logger = logging.getLogger(__name__)
class StoreOperationsMixin: class StoreOperationsMixin:
def promote_watchlist_entry(self, entry_id: str) -> dict[str, Any]: def promote_watchlist_entry(self, entry_id: str) -> dict[str, Any]:
@ -42,10 +47,10 @@ class StoreOperationsMixin:
entry["active"] = True entry["active"] = True
entry["excludedReason"] = "" entry["excludedReason"] = ""
entry["confirmedAt"] = _now_label() entry["confirmedAt"] = _now_label()
entry["confirmedBy"] = "rights.ops" entry["confirmedBy"] = current_actor()
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Watchlist candidate promoted", "Watchlist candidate promoted",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
"promoted into confirmed reference DB", "promoted into confirmed reference DB",
@ -60,14 +65,18 @@ class StoreOperationsMixin:
entry["active"] = False entry["active"] = False
entry["excludedReason"] = reason.strip() or "오탐 또는 무관 후보" entry["excludedReason"] = reason.strip() or "오탐 또는 무관 후보"
entry["excludedAt"] = _now_label() entry["excludedAt"] = _now_label()
entry["excludedBy"] = "rights.ops" entry["excludedBy"] = current_actor()
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Watchlist candidate excluded", "Watchlist candidate excluded",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
entry["excludedReason"], entry["excludedReason"],
) )
# Cases that matched this candidate keep scoring it until their stored
# match rows are retired too (codex 4.5).
for submission_id in self._invalidate_matches_for_entry(entry_id):
self._rescore_submission(submission_id)
return self.bootstrap() return self.bootstrap()
def update_knowledge_entry(self, entry_id: str, payload: dict[str, Any]) -> dict[str, Any]: def update_knowledge_entry(self, entry_id: str, payload: dict[str, Any]) -> dict[str, Any]:
@ -86,7 +95,7 @@ class StoreOperationsMixin:
entry.update(updates) entry.update(updates)
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Knowledge entry updated", "Knowledge entry updated",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
f"{json.dumps(before, ensure_ascii=False)} -> {json.dumps(updates, ensure_ascii=False)}", f"{json.dumps(before, ensure_ascii=False)} -> {json.dumps(updates, ensure_ascii=False)}",
@ -101,11 +110,11 @@ class StoreOperationsMixin:
raise ValueError("이미 비활성 상태입니다") raise ValueError("이미 비활성 상태입니다")
entry["active"] = False entry["active"] = False
entry["deactivatedAt"] = _now_label() entry["deactivatedAt"] = _now_label()
entry["deactivatedBy"] = "rights.ops" entry["deactivatedBy"] = current_actor()
entry["deactivatedReason"] = reason.strip() entry["deactivatedReason"] = reason.strip()
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Knowledge entry deactivated", "Knowledge entry deactivated",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
reason.strip() or "운영자 비활성화", reason.strip() or "운영자 비활성화",
@ -122,17 +131,82 @@ class StoreOperationsMixin:
raise ValueError("이미 활성 상태입니다") raise ValueError("이미 활성 상태입니다")
entry["active"] = True entry["active"] = True
entry["reactivatedAt"] = _now_label() entry["reactivatedAt"] = _now_label()
entry["reactivatedBy"] = "rights.ops" entry["reactivatedBy"] = current_actor()
entry["reactivatedReason"] = reason.strip() entry["reactivatedReason"] = reason.strip()
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Knowledge entry reactivated", "Knowledge entry reactivated",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
reason.strip(), reason.strip(),
) )
return self.bootstrap() return self.bootstrap()
def _withdraw_auto_watchlist_entry(self, submission_id: str, conn: Any = None) -> list[str]:
"""Retire the watchlist entry this submission's hold/rejection created.
Returns the ids of OTHER submissions whose stored match rows pointed at
the entry; the caller rescores them once the write has committed. A
candidate the operator already promoted to confirmed is left alone - that
is their judgement, not the reversed decision's - and only noted.
"""
entry_id = _stable_id("kb-watchlist", submission_id)
try:
entry = self._get("knowledge_entries", entry_id, conn=conn)
except KeyError:
return []
if entry.get("entryStatus") == "confirmed":
self.add_audit_event(
current_actor(),
"Watchlist candidate kept despite approval",
str(entry.get("name", entry_id)),
"확정 항목이라 자동 철회하지 않음 - 수동 검토 필요",
conn=conn,
)
return []
if entry.get("entryStatus") != "watchlist" or not entry.get("active", False):
return []
entry["entryStatus"] = "excluded"
entry["active"] = False
entry["excludedReason"] = "승인으로 판정 변경 - 자동 철회"
entry["excludedAt"] = _now_label()
entry["excludedBy"] = current_actor()
self._put("knowledge_entries", entry_id, entry, conn=conn)
self.add_audit_event(
current_actor(),
"Watchlist candidate withdrawn",
str(entry.get("name", entry_id)),
entry["excludedReason"],
conn=conn,
)
return self._invalidate_matches_for_entry(entry_id, conn=conn)
def _invalidate_matches_for_entry(self, entry_id: str, conn: Any = None) -> list[str]:
"""Stop stored match rows against a retired entry from scoring.
The scorer reads persisted evidence, not the entry's current state, so
retiring an entry alone leaves every case it matched at its old score.
Rows are kept (the operator can still see what was matched) but marked
non-contributing; the caller rescores the returned submissions.
"""
touched: list[str] = []
for payload in self._all("evidence"):
if str(payload.get("knowledgeEntryId", "")) != entry_id:
continue
if payload.get("withdrawnAt"):
continue
# `status` is CHECK-constrained to the analysis/operator vocabulary,
# so the retirement rides on `contributed` (what the scorer reads)
# plus its own stamp.
payload["contributed"] = False
payload["withdrawnAt"] = _now_label()
payload["withdrawnReason"] = "기준 항목 철회"
self._put("evidence", str(payload["id"]), payload, conn=conn)
submission_id = str(payload.get("submission_id", ""))
if submission_id and submission_id not in touched:
touched.append(submission_id)
return touched
def _create_or_update_watchlist_entry( def _create_or_update_watchlist_entry(
self, self,
submission_id: str, submission_id: str,
@ -169,8 +243,11 @@ class StoreOperationsMixin:
"keywords": keywords, "keywords": keywords,
"memo": memo.strip() or ("보류 판정으로 자동 생성" if decision == "held" else "반려 판정으로 자동 생성"), "memo": memo.strip() or ("보류 판정으로 자동 생성" if decision == "held" else "반려 판정으로 자동 생성"),
"provenance": "automatic", "provenance": "automatic",
"active": True, # A candidate the operator already confirmed or excluded keeps that
"entryStatus": "watchlist", # state: a repeated hold must not resurrect an excluded candidate or
# demote a confirmed one back to "unconfirmed" (codex 4.3).
"active": existing.get("active", True) if existing.get("entryStatus") in {"confirmed", "excluded"} else True,
"entryStatus": existing.get("entryStatus") if existing.get("entryStatus") in {"confirmed", "excluded"} else "watchlist",
"originDecisionStatus": decision, "originDecisionStatus": decision,
"sourceDecision": f"DEC-{submission_id}", "sourceDecision": f"DEC-{submission_id}",
"sourceSubmissionId": submission_id, "sourceSubmissionId": submission_id,
@ -199,6 +276,66 @@ class StoreOperationsMixin:
return [] return []
return [fingerprints.perceptual] 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(
current_actor(),
"Cleared reference registered",
submission_id,
entry["name"],
conn=conn,
)
return self.bootstrap()
def rerun_enrichment( def rerun_enrichment(
self, self,
submission_id: str, submission_id: str,
@ -228,6 +365,8 @@ class StoreOperationsMixin:
} }
self._put("evidence", evidence["id"], {**evidence, "submission_id": submission_id}) self._put("evidence", evidence["id"], {**evidence, "submission_id": submission_id})
rerun_error = ""
try:
if image_store is not None: if image_store is not None:
self._rerun_internal_analysis(submission_id, image_store) self._rerun_internal_analysis(submission_id, image_store)
google_evidence = self._rerun_google_image_search(submission_id, image_store) google_evidence = self._rerun_google_image_search(submission_id, image_store)
@ -237,10 +376,24 @@ class StoreOperationsMixin:
] ]
self._auto_naver_search(submission_id, query_source_evidence, image_store) self._auto_naver_search(submission_id, query_source_evidence, image_store)
self._auto_google_custom_search(submission_id, query_source_evidence, image_store) self._auto_google_custom_search(submission_id, query_source_evidence, image_store)
except Exception as exc:
# A submission whose image file went missing (or any other enrichment
# failure) must not escape as an unhandled exception: the handler
# would close the connection with no response while this method has
# already written lastAnalysis and the "queued" marker, leaving the
# case permanently marked as re-analyzing. Record the failure, keep
# the rescore/return path intact, and let the marker cleanup below run.
rerun_error = str(exc) or exc.__class__.__name__
logger.exception("Rerun enrichment failed for submission %s", submission_id)
finally:
# The rerun is synchronous, so by the time we return there is nothing
# "queued" — drop the marker instead of accumulating one dead row per
# rerun (previously they piled up in the operator evidence list).
self._delete_evidence_by_id(evidence["id"])
self._ensure_llm_summary(submission_id) self._ensure_llm_summary(submission_id)
self.add_audit_event("rights.ops", "Analysis run created", submission_id, "operator rerun") self.add_audit_event(current_actor(), "Analysis run created", submission_id, "operator rerun")
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
self._sync_submission_provider_state() self._sync_submission_provider_state()
@ -268,6 +421,7 @@ class StoreOperationsMixin:
refreshed = self._get("submissions", submission_id) refreshed = self._get("submissions", submission_id)
refreshed["lastRerunDiff"] = { refreshed["lastRerunDiff"] = {
"at": _now_label(), "at": _now_label(),
"error": rerun_error,
"scoreBefore": score_before, "scoreBefore": score_before,
"scoreAfter": int(refreshed.get("riskScore", 0) or 0), "scoreAfter": int(refreshed.get("riskScore", 0) or 0),
"addedEvidenceIds": added_ids, "addedEvidenceIds": added_ids,
@ -304,7 +458,7 @@ class StoreOperationsMixin:
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
self._sync_submission_provider_state() self._sync_submission_provider_state()
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Provider called", "Provider called",
f"auto-search / {submission_id}", f"auto-search / {submission_id}",
"operator request for auto text search", "operator request for auto text search",
@ -318,22 +472,29 @@ class StoreOperationsMixin:
query: str, query: str,
image_store: LocalSubmissionImageStore | None = None, image_store: LocalSubmissionImageStore | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
submission = self._get("submissions", submission_id)
provider_payload = self._get("providers", provider) provider_payload = self._get("providers", provider)
if not provider_payload["enabled"]: if not provider_payload["enabled"]:
raise ValueError(f"{provider} provider disabled") raise ValueError(f"{provider} provider disabled")
# External search runs OUTSIDE the write lock (it can take seconds); only
# the read-modify-write of the submission is serialized below.
domain_evidence, provider_call_count = self._manual_search_evidence( domain_evidence, provider_call_count = self._manual_search_evidence(
submission_id, submission_id,
provider, provider,
query, query,
image_store, image_store,
) )
with self._write_lock:
for item in domain_evidence: for item in domain_evidence:
evidence = _evidence_payload(submission_id, item) evidence = _evidence_payload(submission_id, item)
evidence["status"] = "manual" evidence["status"] = "manual"
self._put("evidence", evidence["id"], evidence) self._put("evidence", evidence["id"], evidence)
# Re-read the submission AFTER the search so an operator decision or
# other update made while the search was in flight is not clobbered
# by a stale snapshot.
submission = self._get("submissions", submission_id)
submission.setdefault("queryHistory", []).insert( submission.setdefault("queryHistory", []).insert(
0, 0,
{ {
@ -349,16 +510,31 @@ class StoreOperationsMixin:
item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE} item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
for item in domain_evidence for item in domain_evidence
): ):
provider_payload["lastFailure"] = domain_evidence[0].reason if domain_evidence else f"{provider} search failed" # Report the reason of the item that actually failed. Indexing
# [0] picked whatever came first — usually a successful result —
# so the operator saw a success string labelled as the failure.
provider_payload["lastFailure"] = next(
(
item.reason
for item in domain_evidence
if item.source
in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
),
f"{provider} search failed",
)
else: else:
provider_payload["lastSuccess"] = _now_label() provider_payload["lastSuccess"] = _now_label()
provider_payload["lastFailure"] = "없음" provider_payload["lastFailure"] = "없음"
self._apply_provider_usage_delta(provider, provider_call_count, provider_payload) self._apply_provider_usage_delta(provider, provider_call_count, provider_payload)
# LLM narration may call out to Ollama — keep it outside the lock.
self._ensure_llm_summary(submission_id) self._ensure_llm_summary(submission_id)
with self._write_lock:
if image_store is not None: if image_store is not None:
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
self._sync_submission_provider_state() self._sync_submission_provider_state()
self.add_audit_event("rights.ops", "Provider called", f"{provider} / {submission_id}", f"manual text query: {query}") self.add_audit_event(current_actor(), "Provider called", f"{provider} / {submission_id}", f"manual text query: {query}")
return self.review(submission_id) return self.review(submission_id)
def _ensure_llm_summaries_for_existing_source_evidence(self, queue_id: str | None = None) -> None: def _ensure_llm_summaries_for_existing_source_evidence(self, queue_id: str | None = None) -> None:
@ -392,6 +568,25 @@ class StoreOperationsMixin:
submission_id, submission_id,
source_evidence, source_evidence,
) )
# Enforce the LLM-summary governance rule on the real persistence path.
# The policy existed but no production caller invoked it, so an
# ungrounded summary (no source URLs and no source evidence ids) could be
# stored and shown as if it were source-linked. A summary that fails the
# check is dropped rather than persisted.
if llm_evidence.source == EvidenceSource.LLM_SUMMARY:
try:
assert_operator_evidence_payload_allowed(
DataClass.LLM_SUMMARY, llm_evidence.data
)
except ValueError as exc:
logger.warning(
"Discarding ungrounded LLM summary for submission %s: %s",
submission_id,
exc,
)
llm_provider["lastFailure"] = f"LLM assistance failed: {exc}"
self._apply_provider_usage_delta("llm", 1, llm_provider)
return False
self._delete_llm_summary_evidence(submission_id) self._delete_llm_summary_evidence(submission_id)
self._put( self._put(
"evidence", "evidence",
@ -406,6 +601,10 @@ class StoreOperationsMixin:
self._apply_provider_usage_delta("llm", 1, llm_provider) self._apply_provider_usage_delta("llm", 1, llm_provider)
return True return True
def _delete_evidence_by_id(self, evidence_id: str) -> None:
with self._connect() as conn:
conn.execute("delete from evidence where id = ?", (evidence_id,))
def _delete_llm_summary_evidence(self, submission_id: str) -> None: def _delete_llm_summary_evidence(self, submission_id: str) -> None:
with self._connect() as conn: with self._connect() as conn:
conn.execute( conn.execute(
@ -558,7 +757,7 @@ class StoreOperationsMixin:
} }
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Knowledge entry manually created", "Knowledge entry manually created",
name, name,
"manual image reference" if image_record else "manual text reference", "manual image reference" if image_record else "manual text reference",
@ -581,7 +780,11 @@ class StoreOperationsMixin:
if provider == "google_search" and self.provider_runtime.google_custom_search_adapter is None: if provider == "google_search" and self.provider_runtime.google_custom_search_adapter is None:
raise ValueError("google_search provider not connected") raise ValueError("google_search provider not connected")
self._clear_collection_candidates() # Run the search cascade OUTSIDE the write lock and accumulate the
# candidates in memory. The clear + repopulate is then committed under the
# lock in one transaction, so it can't interleave with a concurrent
# promote (which would 404 on cleared rows) or a second collect (which
# would resurrect the previous query's rows).
if provider == "google_search": if provider == "google_search":
domain_evidence = self.provider_runtime.google_custom_search_adapter.search_images( domain_evidence = self.provider_runtime.google_custom_search_adapter.search_images(
"candidate-collection", "candidate-collection",
@ -595,9 +798,10 @@ class StoreOperationsMixin:
self.provider_runtime.search_policy, self.provider_runtime.search_policy,
) )
collected = 0 collected = 0
pending: list[dict[str, Any]] = []
provider_call_count = 1 if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in domain_evidence) else 0 provider_call_count = 1 if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in domain_evidence) else 0
for candidate in self._collection_candidates_from_evidence(query, domain_evidence, provider): for candidate in self._collection_candidates_from_evidence(query, domain_evidence, provider):
self._put("collection_candidates", candidate["id"], candidate) pending.append(candidate)
collected += 1 collected += 1
if provider == "google_search" and collected == 0: if provider == "google_search" and collected == 0:
@ -610,7 +814,7 @@ class StoreOperationsMixin:
provider_call_count += 1 provider_call_count += 1
domain_evidence.extend(web_evidence) domain_evidence.extend(web_evidence)
for candidate in self._collection_candidates_from_evidence(query, web_evidence, provider): for candidate in self._collection_candidates_from_evidence(query, web_evidence, provider):
self._put("collection_candidates", candidate["id"], candidate) pending.append(candidate)
collected += 1 collected += 1
if provider == "naver" and collected == 0: if provider == "naver" and collected == 0:
page_evidence = self.provider_runtime.naver_adapter.search_pages( page_evidence = self.provider_runtime.naver_adapter.search_pages(
@ -622,7 +826,7 @@ class StoreOperationsMixin:
provider_call_count += 1 provider_call_count += 1
domain_evidence.extend(page_evidence) domain_evidence.extend(page_evidence)
for candidate in self._collection_candidates_from_evidence(query, page_evidence, provider): for candidate in self._collection_candidates_from_evidence(query, page_evidence, provider):
self._put("collection_candidates", candidate["id"], candidate) pending.append(candidate)
collected += 1 collected += 1
if provider == "naver" and collected == 0: if provider == "naver" and collected == 0:
web_evidence = self.provider_runtime.naver_adapter.search_web_pages( web_evidence = self.provider_runtime.naver_adapter.search_web_pages(
@ -634,19 +838,58 @@ class StoreOperationsMixin:
provider_call_count += 1 provider_call_count += 1
domain_evidence.extend(web_evidence) domain_evidence.extend(web_evidence)
for candidate in self._collection_candidates_from_evidence(query, web_evidence, provider): for candidate in self._collection_candidates_from_evidence(query, web_evidence, provider):
self._put("collection_candidates", candidate["id"], candidate) pending.append(candidate)
collected += 1 collected += 1
if any( if any(
item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE} item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
for item in domain_evidence for item in domain_evidence
): ):
provider_payload["lastFailure"] = domain_evidence[0].reason if domain_evidence else f"{provider} collection failed" provider_payload["lastFailure"] = next(
(
item.reason
for item in domain_evidence
if item.source
in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
),
f"{provider} collection failed",
)
else: else:
provider_payload["lastSuccess"] = _now_label() provider_payload["lastSuccess"] = _now_label()
provider_payload["lastFailure"] = "없음" provider_payload["lastFailure"] = "없음"
# The cascade stages can surface the same candidate id more than once
# (e.g. an image result that also appears in the page sweep). Persisting
# is keyed by id, so counting every append reported more "collected"
# candidates than rows actually stored.
deduped_candidates = list({candidate["id"]: candidate for candidate in pending}.values())
collected = len(deduped_candidates)
# Every search was refused before it ran (quota/disabled provider), so we
# learned nothing. Wiping the previous collection here would destroy the
# operator's working set as punishment for exhausted quota.
if not deduped_candidates and provider_call_count == 0:
# Still persist why it was refused (zero delta merges lastFailure
# without charging quota) so the provider panel explains the no-op.
self._apply_provider_usage_delta(provider, 0, provider_payload)
payload = self.bootstrap()
payload["collected"] = 0
payload["collectionSkipped"] = True
return payload
with self._write_lock:
with self._transaction() as conn:
conn.execute("delete from collection_candidates")
for candidate in deduped_candidates:
self._put("collection_candidates", candidate["id"], candidate, conn=conn)
self.add_audit_event(
current_actor(),
"Keyword candidates collected",
provider,
f"{query} · {collected} candidates",
conn=conn,
)
self._apply_provider_usage_delta(provider, provider_call_count, provider_payload) self._apply_provider_usage_delta(provider, provider_call_count, provider_payload)
self.add_audit_event("rights.ops", "Keyword candidates collected", provider, f"{query} · {collected} candidates")
payload = self.bootstrap() payload = self.bootstrap()
payload["collected"] = collected payload["collected"] = collected
return payload return payload
@ -686,11 +929,36 @@ class StoreOperationsMixin:
candidate["promotedKnowledgeId"] = entry_id candidate["promotedKnowledgeId"] = entry_id
self._put("collection_candidates", candidate_id, candidate, conn=conn) self._put("collection_candidates", candidate_id, candidate, conn=conn)
self.add_audit_event( self.add_audit_event(
"rights.ops", "Knowledge entry manually created", name, current_actor(), "Knowledge entry manually created", name,
f"promoted candidate {candidate_id}", conn=conn, f"promoted candidate {candidate_id}", conn=conn,
) )
return self.bootstrap() return self.bootstrap()
def dismiss_collection_candidate(self, candidate_id: str) -> dict[str, Any]:
# Persist the dismissal so the candidate stays gone across refreshes
# instead of resurrecting on the next bootstrap. Idempotent: a repeat /
# double-click on an already-removed candidate is a no-op.
with self._write_lock:
try:
candidate = self._get("collection_candidates", candidate_id)
except KeyError:
return self.bootstrap()
if candidate.get("status") == "promoted":
raise ValueError("promoted candidate cannot be dismissed")
query = str(candidate.get("query", ""))
with self._transaction() as conn:
conn.execute(
"delete from collection_candidates where id = ?", (candidate_id,)
)
self.add_audit_event(
current_actor(),
"Keyword candidate dismissed",
candidate_id,
f"dismissed candidate for query: {query}",
conn=conn,
)
return self.bootstrap()
def promote_collection_candidates(self, payload: dict[str, Any]) -> dict[str, Any]: def promote_collection_candidates(self, payload: dict[str, Any]) -> dict[str, Any]:
candidate_ids = _unique_texts(_text_list(payload.get("candidate_ids", payload.get("candidateIds")))) candidate_ids = _unique_texts(_text_list(payload.get("candidate_ids", payload.get("candidateIds"))))
if not candidate_ids: if not candidate_ids:
@ -763,7 +1031,7 @@ class StoreOperationsMixin:
candidate["promotedKnowledgeId"] = entry_id candidate["promotedKnowledgeId"] = entry_id
self._put("collection_candidates", str(candidate["id"]), candidate, conn=conn) self._put("collection_candidates", str(candidate["id"]), candidate, conn=conn)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Knowledge entry manually created", "Knowledge entry manually created",
name, name,
f"promoted {len(candidates)} collected candidates", f"promoted {len(candidates)} collected candidates",

View file

@ -231,18 +231,19 @@ class StorePersistenceMixin:
def _apply_provider_usage_delta( def _apply_provider_usage_delta(
self, provider_id: str, count: int, payload: dict[str, Any] self, provider_id: str, count: int, payload: dict[str, Any]
) -> None: ) -> None:
# Re-read current usage under the write lock and apply the delta so # Start from the freshly-read row and merge only the fields this call
# concurrent provider calls don't lose increments. The rest of `payload` # actually produced (usage delta + call status). The caller's `payload`
# (lastSuccess / lastFailure) is last-writer-wins, which is acceptable for # was snapshotted before a multi-second external search, so writing it
# status fields. # wholesale would revert any operator change made meanwhile — in
# particular an emergency disable (`enabled`). Merging keeps that change.
with self._write_lock: with self._write_lock:
current = self._get("providers", provider_id) current = self._get("providers", provider_id)
payload["usage"] = int(current.get("usage", 0) or 0) + int(count) merged = dict(current)
self._put("providers", provider_id, payload) for key in ("lastSuccess", "lastFailure", "lastRun"):
if key in payload:
def _clear_collection_candidates(self) -> None: merged[key] = payload[key]
with self._connect() as conn: merged["usage"] = int(current.get("usage", 0) or 0) + int(count)
conn.execute("delete from collection_candidates") self._put("providers", provider_id, merged)
def _all(self, table: str, queue_id: str | None = None) -> list[dict[str, Any]]: def _all(self, table: str, queue_id: str | None = None) -> list[dict[str, Any]]:
_validate_table(table) _validate_table(table)

View file

@ -109,14 +109,31 @@ class StoreQueueMixin:
""", """,
(queue_id, str(folder), queue_label, now.isoformat(" ", "seconds"), int(now.timestamp())), (queue_id, str(folder), queue_label, now.isoformat(" ", "seconds"), int(now.timestamp())),
) )
effective_id = queue_id
else: else:
# A row matched by folder_path may carry a different (older) id
# than the freshly-computed queue_id — keep the existing row's id
# as the effective key so the UPDATE, the deactivate-others sweep,
# and the final SELECT all target the real row instead of silently
# matching zero rows and returning None.
effective_id = existing["id"]
conn.execute( conn.execute(
"update submission_queues set is_active = 1, folder_path = ?, label = ? where id = ?", "update submission_queues set is_active = 1, folder_path = ?, label = ? where id = ?",
(str(folder), queue_label, queue_id), (str(folder), queue_label, effective_id),
)
conn.execute("update submission_queues set is_active = 0 where id != ?", (effective_id,))
migrated_legacy = self._has_queueless_submissions(conn=conn)
if migrated_legacy:
self._migrate_legacy_submissions_to_queue(effective_id, conn=conn)
# Audit only meaningful transitions (a brand-new queue or a legacy
# migration), not every reload, so the audit trail isn't spammed.
if existing is None or migrated_legacy:
change = f"folder {folder}" + (
"; legacy queueless submissions migrated" if migrated_legacy else "; new queue"
)
self.add_audit_event(
"rights.system", "Submission queue activated", effective_id, change, conn=conn
) )
conn.execute("update submission_queues set is_active = 0 where id != ?", (queue_id,))
if self._has_queueless_submissions(conn=conn):
self._migrate_legacy_submissions_to_queue(queue_id, conn=conn)
queue_row = conn.execute( queue_row = conn.execute(
""" """
select id, folder_path, label, is_active, created_at, created_epoch, select id, folder_path, label, is_active, created_at, created_epoch,
@ -124,10 +141,10 @@ class StoreQueueMixin:
from submission_queues from submission_queues
where id = ? where id = ?
""", """,
(queue_id,), (effective_id,),
).fetchone() ).fetchone()
if queue_row is None: if queue_row is None:
return self._queue_row_by_id(queue_id) return self._queue_row_by_id(effective_id)
return { return {
"id": queue_row["id"], "id": queue_row["id"],
"folderPath": queue_row["folder_path"], "folderPath": queue_row["folder_path"],

View file

@ -9,8 +9,9 @@ from __future__ import annotations
import ipaddress import ipaddress
import socket import socket
from http.client import HTTPConnection, HTTPSConnection
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib.request import HTTPRedirectHandler, Request, build_opener from urllib.request import HTTPHandler, HTTPRedirectHandler, HTTPSHandler, Request, build_opener
from rights_filter.server.store_url_utils import _is_http_url from rights_filter.server.store_url_utils import _is_http_url
@ -63,11 +64,23 @@ def _fetch_stylesheet_url_bytes(url: str, timeout: int = 10, limit: int = CANDID
return _fetch_url_bytes_with_headers(url, STYLESHEET_FETCH_HEADERS, timeout, limit) return _fetch_url_bytes_with_headers(url, STYLESHEET_FETCH_HEADERS, timeout, limit)
def _assert_public_ip(raw_ip: str) -> None:
# `not is_global` rejects every special-purpose range at once — including
# CGNAT 100.64.0.0/10, which an explicit is_private/is_loopback/... list
# missed — and only lets genuinely globally-routable addresses through.
if not ipaddress.ip_address(raw_ip).is_global:
raise ValueError("refusing to fetch internal address")
def _assert_public_http_url(url: str) -> None: def _assert_public_http_url(url: str) -> None:
# SSRF guard: candidate image / page / stylesheet URLs come from external # SSRF guard: candidate image / page / stylesheet URLs come from external
# search-result content (attacker-influenceable), so refuse to fetch # search-result content (attacker-influenceable), so refuse to fetch
# anything that resolves to a non-public address (loopback, RFC1918, # anything that resolves to a non-public address (loopback, RFC1918,
# link-local cloud-metadata 169.254.169.254, etc.). # link-local cloud-metadata 169.254.169.254, etc.).
#
# This is the pre-flight rejection. It is NOT what makes the fetch safe on
# its own — see _guarded_create_connection for the check that binds to the
# address actually connected to.
parsed = urlparse(url) parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname: if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("only public http(s) URLs may be fetched") raise ValueError("only public http(s) URLs may be fetched")
@ -77,16 +90,76 @@ def _assert_public_http_url(url: str) -> None:
except OSError as exc: except OSError as exc:
raise ValueError("could not resolve fetch host") from exc raise ValueError("could not resolve fetch host") from exc
for *_, sockaddr in infos: for *_, sockaddr in infos:
ip = ipaddress.ip_address(sockaddr[0]) _assert_public_ip(sockaddr[0])
if (
ip.is_private
or ip.is_loopback def _guarded_create_connection(address, timeout=None, source_address=None):
or ip.is_link_local """``socket.create_connection`` that vets the address it actually dials.
or ip.is_reserved
or ip.is_multicast Without this, the guard is a TOCTOU: ``_assert_public_http_url`` resolves the
or ip.is_unspecified host to decide whether the fetch is allowed, and then http.client resolves it
): a *second* time to open the socket. A DNS-rebinding host answers the first
raise ValueError("refusing to fetch internal address") lookup with a public address and the second with 127.0.0.1 — the guard passes
and the connection still lands inside the network. Resolving once here and
dialing only an address from that same answer closes the window.
Every address in the answer must be public, not merely the one we dial: a
host resolving to both a routable and an internal address is the rebinding
pattern itself, and which entry ``connect`` reaches is not ours to predict.
"""
host, port = address[0], address[1]
try:
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
except OSError as exc:
raise ValueError("could not resolve fetch host") from exc
if not infos:
raise ValueError("could not resolve fetch host")
for *_, sockaddr in infos:
_assert_public_ip(sockaddr[0])
last_error: Exception | None = None
for family, socktype, proto, _canonname, sockaddr in infos:
sock = socket.socket(family, socktype, proto)
try:
# http.client passes socket._GLOBAL_DEFAULT_TIMEOUT (a sentinel
# object, not a number) when no timeout was set; only a real number
# is meaningful to settimeout.
if isinstance(timeout, (int, float)):
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
return sock
except OSError as exc:
last_error = exc
sock.close()
raise last_error if last_error is not None else ValueError("could not connect to fetch host")
class _GuardedHTTPConnection(HTTPConnection):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
# `_create_connection` is the seam HTTPConnection.connect dials through,
# so overriding it keeps the rest of connect() (TCP_NODELAY, tunnelling)
# and, for HTTPS, the TLS wrap that still verifies against self.host.
self._create_connection = _guarded_create_connection
class _GuardedHTTPSConnection(HTTPSConnection):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._create_connection = _guarded_create_connection
class _GuardedHTTPHandler(HTTPHandler):
def http_open(self, req):
return self.do_open(_GuardedHTTPConnection, req)
class _GuardedHTTPSHandler(HTTPSHandler):
def https_open(self, req):
return self.do_open(_GuardedHTTPSConnection, req, context=self._context)
class _ValidatingRedirectHandler(HTTPRedirectHandler): class _ValidatingRedirectHandler(HTTPRedirectHandler):
@ -97,7 +170,11 @@ class _ValidatingRedirectHandler(HTTPRedirectHandler):
return super().redirect_request(req, fp, code, msg, headers, newurl) return super().redirect_request(req, fp, code, msg, headers, newurl)
_SSRF_SAFE_OPENER = build_opener(_ValidatingRedirectHandler()) _SSRF_SAFE_OPENER = build_opener(
_GuardedHTTPHandler(),
_GuardedHTTPSHandler(),
_ValidatingRedirectHandler(),
)
def _open_url(request: Request, timeout: int): def _open_url(request: Request, timeout: int):

View file

@ -13,7 +13,7 @@ from pathlib import Path
from typing import Any from typing import Any
from rights_filter.analysis.fingerprints import FingerprintService from rights_filter.analysis.fingerprints import FingerprintService
from rights_filter.analysis.risk_scoring import RiskScorer from rights_filter.analysis.risk_scoring import SCORER_VERSION, RiskScorer
from rights_filter.domain.records import ( from rights_filter.domain.records import (
Evidence, Evidence,
EvidenceSource, EvidenceSource,
@ -718,17 +718,93 @@ class StoreSearchCandidatesMixin:
return self._custom_candidate_image_fetcher(url) return self._custom_candidate_image_fetcher(url)
return _fetch_url_bytes(url, referer_url=referer_url) return _fetch_url_bytes(url, referer_url=referer_url)
def _trusted_hosts(self) -> frozenset[str]:
"""Publishing hosts the team owns or licenses, from the knowledge DB.
Registered as `trusted_host` entries: the entry name is the host and its
aliases carry the same party's other hosts (a partner's CDN, a regional
domain). Deactivating the entry stops the exclusion, which is the same
lifecycle every other knowledge entry already has - no second switch.
Malformed values are dropped by RiskScorer, so validity lives in one
place rather than being re-checked at every call site.
"""
return frozenset(self._hosts_of_type("trusted_host"))
def _host_labels(self) -> dict[str, str]:
"""Host -> operator-facing category, for the headline only (no score)."""
labels: dict[str, str] = {}
for entry_type, label in (("news_host", "언론·공식"), ("scraper_host", "무단 게시처")):
for host in self._hosts_of_type(entry_type):
labels.setdefault(host, label)
return labels
def _hosts_of_type(self, entry_type: str) -> set[str]:
hosts: set[str] = set()
for entry in self._all("knowledge_entries"):
if str(entry.get("type", "")) != entry_type:
continue
if not entry.get("active", True):
continue
for value in [entry.get("name", ""), *(entry.get("aliases") or [])]:
text = str(value).strip().lower().removeprefix("www.")
if text:
hosts.add(text)
return hosts
def _rescore_submission(self, submission_id: str) -> None: def _rescore_submission(self, submission_id: str) -> None:
submission = self._get("submissions", submission_id) submission = self._get("submissions", submission_id)
evidence = [ evidence = [
_domain_evidence_from_ui(item) _domain_evidence_from_ui(item)
for item in self._evidence_for_submission(submission_id) for item in self._evidence_for_submission(submission_id)
] ]
score = RiskScorer().score(evidence) score = RiskScorer(self._trusted_hosts(), self._host_labels()).score(evidence)
previous_band = str(submission.get("riskBand", ""))
previous_score = submission.get("riskScore", "")
# The version the LIVE score was last produced by, as opposed to the
# as-created stamp below. A weight change rescores every row on the next
# folder reload, so without this a case an operator rejected as "high"
# could display as "medium" with different reasons and no trace that a
# scorer migration - rather than new evidence - moved it.
scored_version = str(submission.get("scoredVersion", ""))
# 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["riskScore"] = score.score
submission["riskBand"] = score.band submission["riskBand"] = score.band
submission["reasons"] = score.reasons or ["분석 근거 없음"] submission["reasons"] = score.reasons or ["분석 근거 없음"]
submission["riskBreakdown"] = score.breakdown
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"
submission["scoredVersion"] = SCORER_VERSION
migrated_band = (
scored_version != SCORER_VERSION
and bool(previous_band)
and previous_band != score.band
)
if not migrated_band:
self._put("submissions", submission_id, submission) self._put("submissions", submission_id, submission)
return
# One event per row per migration: scoredVersion is updated in the same
# write, so an ordinary rescore on the same version stays silent and the
# 500-row audit cap is not spent on repeat reloads.
with self._transaction() as conn:
self._put("submissions", submission_id, submission, conn=conn)
self.add_audit_event(
actor="system",
event="Risk rescored",
object_id=submission_id,
change=(
f"scorer {scored_version or 'pre-stamp'} -> {SCORER_VERSION}: "
f"{previous_band} {previous_score} -> {score.band} {score.score}"
),
conn=conn,
)
def _rescore_all_submissions(self, queue_id: str | None = None) -> None: def _rescore_all_submissions(self, queue_id: str | None = None) -> None:
for submission in self._all("submissions", queue_id=queue_id): for submission in self._all("submissions", queue_id=queue_id):

View file

@ -18,6 +18,7 @@ from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
from rights_filter.analysis.risk_scoring import SCORER_VERSION
from rights_filter.domain.records import ( from rights_filter.domain.records import (
Evidence, Evidence,
EvidenceSource, EvidenceSource,
@ -92,6 +93,7 @@ def _submission_payload(
band: str, band: str,
reasons: list[str], reasons: list[str],
provider_payloads: dict[str, dict[str, Any]] | None = None, provider_payloads: dict[str, dict[str, Any]] | None = None,
breakdown: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
submission_id = record["id"] submission_id = record["id"]
title = record.get("title", submission_id) title = record.get("title", submission_id)
@ -109,6 +111,19 @@ def _submission_payload(
"asset": record["asset"], "asset": record["asset"],
"riskScore": score, "riskScore": score,
"riskBand": band, "riskBand": band,
# 점수가 어떻게 나왔는지. reasons 는 어떤 증거가 말했는지를 말하고,
# 이것은 각 종류가 얼마를 냈고 어디서 풀 상한이나 신뢰 출처 제외가
# 점수를 삼켰는지를 말한다.
"riskBreakdown": breakdown or {},
# 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, "submittedAt": submitted_at,
"submittedEpoch": submitted_epoch, "submittedEpoch": submitted_epoch,
"lastAnalysis": _now_label(), "lastAnalysis": _now_label(),
@ -186,6 +201,13 @@ def _evidence_matches_provider(evidence: dict[str, Any], provider_id: str) -> bo
if provider_id == "naver": if provider_id == "naver":
return source == "naver" or domain in {"naver", "naver_blog", "naver_web"} return source == "naver" or domain in {"naver", "naver_blog", "naver_web"}
if provider_id == "google": if provider_id == "google":
# Vision failure/skip rows serialize with source "failure", so matching on
# source == "google" alone left them unattributed: the badge showed "not
# run" for a provider that had actually failed, while the score still
# carried the failure reason. Rows written before the adapter tagged the
# provider fall back to domain "internal" and are matched by title.
if source == "failure" and domain == "google":
return True
return source == "google" and domain != "google_custom_search" return source == "google" and domain != "google_custom_search"
if provider_id == "google_search": if provider_id == "google_search":
return domain == "google_custom_search" return domain == "google_custom_search"
@ -246,6 +268,61 @@ def _face_crop_web_evidence(
) )
def _result_host(evidence: Evidence) -> str:
"""Where this result is actually published, as a bare hostname.
The payload's "domain" field cannot carry it: that field identifies the
PROVIDER (provider-coverage sync and the google_custom_search query-signature
match both key off it), and it is written provider-first, which overwrote the
real host that Google custom search returns as displayLink. So the host gets
its own key.
It is the single most decision-relevant fact in a rights review — a copy on
the licensor's own site and a copy on a scraper blog are not the same finding
— and until now neither the operator's evidence card nor the LLM prompt could
see it: every row read "domain=google".
"""
# A row that already carries a resolved host keeps it. Re-serializing a
# restored row used to walk straight past this: the round trip drops
# `provider` but keeps `domain` (which holds the provider NAME), so
# `explicit != provider` held and the host reverted to "google_custom_search"
# / "google" / "internal" - the exact bug this function was written to fix,
# silently, with every test still green.
restored = _hostname_of(str(evidence.data.get("host", "")))
if restored:
return restored
provider = str(evidence.data.get("provider", "")).strip()
explicit = str(evidence.data.get("domain", "")).strip()
if explicit and explicit != provider:
# Google custom search: a bare host ("biz.chosun.com"), no scheme.
return _hostname_of(explicit)
for key in ("url", "result_url", "page_url", "source_page_url", "blogger_link", "image_url"):
host = _hostname_of(str(evidence.data.get(key, "")))
if host:
return host
return ""
def _hostname_of(value: str) -> str:
text = value.strip()
if not text:
return ""
if "://" not in text:
# urlparse("//google") happily reports "google" as a hostname, so a bare
# token with no dot would pass an API name off as a publishing host. A
# hostname we can show an operator has a label separator; anything else
# is "no host", which is what the caller already handles.
if "." not in text.split("/")[0]:
return ""
text = f"//{text}"
try:
host = urlparse(text).hostname or ""
except ValueError:
# A malformed URL is "no host", not a reason to fail persistence.
return ""
return host.lower().removeprefix("www.")
def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]: def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]:
source = _ui_source(evidence.source) source = _ui_source(evidence.source)
result_url = str(evidence.data.get("url", evidence.data.get("result_url", ""))) result_url = str(evidence.data.get("url", evidence.data.get("result_url", "")))
@ -261,6 +338,16 @@ def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]:
"id": _evidence_id(submission_id, evidence), "id": _evidence_id(submission_id, evidence),
"group": "watchlist" if knowledge_entry_status == "watchlist" else "face_web" if face_crop_search else _ui_group(evidence.source), "group": "watchlist" if knowledge_entry_status == "watchlist" else "face_web" if face_crop_search else _ui_group(evidence.source),
"source": source, "source": source,
# Persist the precise domain source and the scoring-relevant data fields
# so the production round-trip (_domain_evidence_from_ui -> RiskScorer in
# _rescore_submission) reproduces the original score. Without these the
# coarse "source" string collapses SEARCH_SKIPPED/FAILURE together and the
# promoted/entity/category signals vanish, silently zeroing risk points.
"domainSource": evidence.source.value,
"promoted": bool(evidence.data.get("promoted", False)),
"promotionReason": str(evidence.data.get("promotion_reason", "")),
"entity": str(evidence.data.get("entity", "")),
"category": str(evidence.data.get("category", "")),
"title": evidence.reason, "title": evidence.reason,
"confidence": evidence.confidence, "confidence": evidence.confidence,
"query": str(evidence.data.get("query", "")), "query": str(evidence.data.get("query", "")),
@ -269,6 +356,7 @@ def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]:
"querySource": str(evidence.data.get("query_source", "")), "querySource": str(evidence.data.get("query_source", "")),
"searchType": str(evidence.data.get("search_type", "")), "searchType": str(evidence.data.get("search_type", "")),
"domain": str(evidence.data.get("provider", evidence.data.get("domain", "internal"))), "domain": str(evidence.data.get("provider", evidence.data.get("domain", "internal"))),
"host": _result_host(evidence),
"url": result_url, "url": result_url,
"imageUrl": image_url, "imageUrl": image_url,
"thumbnailUrl": thumbnail_url, "thumbnailUrl": thumbnail_url,
@ -289,11 +377,42 @@ def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]:
"knowledgeEntryId": str(evidence.data.get("knowledge_entry_id", "")), "knowledgeEntryId": str(evidence.data.get("knowledge_entry_id", "")),
"knowledgeEntryName": str(evidence.data.get("knowledge_name", "")), "knowledgeEntryName": str(evidence.data.get("knowledge_name", "")),
"knowledgeEntryStatus": knowledge_entry_status, "knowledgeEntryStatus": knowledge_entry_status,
"knowledgeEntryType": str(evidence.data.get("knowledge_entry_type", "")),
"sourceSubmissionId": str(evidence.data.get("source_submission_id", "")), "sourceSubmissionId": str(evidence.data.get("source_submission_id", "")),
"similarity": evidence.data.get("similarity", ""), "similarity": evidence.data.get("similarity", ""),
"retrievedAt": _now_label(), "retrievedAt": _now_label(),
"contributed": source not in {"llm", "failure"} and not evidence.data.get("weak_hint", False), "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", []), "sourceEvidenceIds": evidence.data.get("source_evidence_ids", []),
# Provenance signal (provenance rows only; empty elsewhere).
"provenanceKind": str(evidence.data.get("provenance_kind", "")),
"rightsFields": dict(evidence.data.get("rights_fields", {}) or {}),
"captureCategories": list(evidence.data.get("capture_categories", []) or []),
# 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", ""),
# Fabrication counters. Without these on the payload the metric exists
# only in memory and the operator/admin never sees a model that has
# started inventing citations.
"groundedClaims": evidence.data.get("grounded_claims", ""),
"droppedClaims": evidence.data.get("dropped_claims", ""),
"verified": evidence.data.get("verified", ""),
"status": "active", "status": "active",
"submission_id": submission_id, "submission_id": submission_id,
} }
@ -313,6 +432,9 @@ def _domain_evidence_from_ui(payload: dict[str, Any]) -> Evidence:
"query_source": payload.get("querySource", ""), "query_source": payload.get("querySource", ""),
"search_type": payload.get("searchType", ""), "search_type": payload.get("searchType", ""),
"domain": payload.get("domain", ""), "domain": payload.get("domain", ""),
# Rows written before "host" existed fall back to deriving it from
# the stored URL, so legacy evidence is not blind to its own source.
"host": payload.get("host", "") or _hostname_of(str(payload.get("url", ""))),
"url": payload.get("url", ""), "url": payload.get("url", ""),
"result_url": payload.get("url", ""), "result_url": payload.get("url", ""),
"image_url": payload.get("imageUrl", ""), "image_url": payload.get("imageUrl", ""),
@ -327,6 +449,10 @@ def _domain_evidence_from_ui(payload: dict[str, Any]) -> Evidence:
"match": payload.get("matchType", ""), "match": payload.get("matchType", ""),
"rank": payload.get("rank", ""), "rank": payload.get("rank", ""),
"score": payload.get("providerScore", ""), "score": payload.get("providerScore", ""),
"promoted": bool(payload.get("promoted", False)),
"promotion_reason": payload.get("promotionReason", ""),
"entity": payload.get("entity", ""),
"category": payload.get("category", ""),
"contributed": payload.get("contributed", True), "contributed": payload.get("contributed", True),
"status": payload.get("status", ""), "status": payload.get("status", ""),
"weak_hint": bool(payload.get("faceCropSearch", False)) or _is_google_weak_label_payload(payload), "weak_hint": bool(payload.get("faceCropSearch", False)) or _is_google_weak_label_payload(payload),
@ -337,13 +463,43 @@ def _domain_evidence_from_ui(payload: dict[str, Any]) -> Evidence:
"knowledge_entry_id": payload.get("knowledgeEntryId", ""), "knowledge_entry_id": payload.get("knowledgeEntryId", ""),
"knowledge_name": payload.get("knowledgeEntryName", ""), "knowledge_name": payload.get("knowledgeEntryName", ""),
"knowledge_entry_status": payload.get("knowledgeEntryStatus", ""), "knowledge_entry_status": payload.get("knowledgeEntryStatus", ""),
"knowledge_entry_type": payload.get("knowledgeEntryType", ""),
"source_submission_id": payload.get("sourceSubmissionId", ""), "source_submission_id": payload.get("sourceSubmissionId", ""),
"similarity": payload.get("similarity", ""), "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", ""),
"grounded_claims": payload.get("groundedClaims", ""),
"dropped_claims": payload.get("droppedClaims", ""),
"verified": payload.get("verified", ""),
"provenance_kind": payload.get("provenanceKind", ""),
"rights_fields": payload.get("rightsFields", {}),
"capture_categories": payload.get("captureCategories", []),
"tier": payload.get("faceTier", ""),
"detector": payload.get("faceDetector", ""),
"face_area_ratio": payload.get("faceAreaRatio", ""),
"face_count": payload.get("faceCount", ""),
"person_count": payload.get("personCount", ""),
}, },
) )
def _domain_source_from_ui_payload(payload: dict[str, Any]) -> EvidenceSource: def _domain_source_from_ui_payload(payload: dict[str, Any]) -> EvidenceSource:
# Prefer the persisted precise source. This distinguishes SEARCH_SKIPPED
# (0 points) from FAILURE (+30) instead of guessing from title keywords,
# which previously misclassified skip evidence as failure on every rescore.
domain_source = str(payload.get("domainSource", ""))
if domain_source:
try:
return EvidenceSource(domain_source)
except ValueError:
pass
# Legacy fallback for rows written before domainSource was persisted.
source = str(payload.get("source", "")) source = str(payload.get("source", ""))
title = str(payload.get("title", "")).lower() title = str(payload.get("title", "")).lower()
if source == "failure" and ( if source == "failure" and (
@ -354,6 +510,8 @@ def _domain_source_from_ui_payload(payload: dict[str, Any]) -> EvidenceSource:
def _domain_source_from_ui(source: str) -> EvidenceSource: def _domain_source_from_ui(source: str) -> EvidenceSource:
if source == "provenance":
return EvidenceSource.PROVENANCE
if source == "google": if source == "google":
return EvidenceSource.WEB_DETECTION return EvidenceSource.WEB_DETECTION
if source == "naver": if source == "naver":
@ -382,8 +540,20 @@ def _google_weak_label_title(title: str) -> str:
return title 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: 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] return "ev-" + hashlib.sha256(base.encode("utf-8")).hexdigest()[:24]
@ -398,6 +568,11 @@ def _ui_source(source: EvidenceSource) -> str:
return "failure" return "failure"
if source == EvidenceSource.FACE_PERSON: if source == EvidenceSource.FACE_PERSON:
return "face" return "face"
if source == EvidenceSource.PROVENANCE:
return "provenance"
# NOTE: this fallback is silent. A new EvidenceSource that forgets a branch
# above lands here and is mislabelled as a fingerprint rather than failing
# loudly — add the branch when adding the source.
return "fingerprint" return "fingerprint"
@ -453,11 +628,29 @@ def _validate_payload(table: str, id_value: str, payload: dict[str, Any]) -> Non
raise ValueError(f"{table} payload id does not match storage id") raise ValueError(f"{table} payload id does not match storage id")
# A quota-skip (SEARCH_SKIPPED) or transient failure (ENRICHMENT_FAILURE) means
# the query never actually executed / produced no evidence, so it must NOT enter
# the dedup signature set — otherwise auto-search treats it as "already done" and
# never retries it, even after quota resets or the network recovers. Genuine
# "returned no results" rows keep their real provider source (naver/google) and
# stay deduped correctly (they did run).
_RETRYABLE_SKIP_DOMAIN_SOURCES = {
EvidenceSource.SEARCH_SKIPPED.value,
EvidenceSource.ENRICHMENT_FAILURE.value,
}
def _is_retryable_skip_evidence(item: dict[str, Any]) -> bool:
return str(item.get("domainSource", "")) in _RETRYABLE_SKIP_DOMAIN_SOURCES
def _existing_naver_query_signatures(evidence: list[dict[str, Any]]) -> set[str]: def _existing_naver_query_signatures(evidence: list[dict[str, Any]]) -> set[str]:
return { return {
str(item.get("querySignature") or _naver_query_signature(str(item.get("query", "")))) str(item.get("querySignature") or _naver_query_signature(str(item.get("query", ""))))
for item in evidence for item in evidence
if item.get("query") and item.get("source") in {"naver", "failure"} if item.get("query")
and item.get("source") in {"naver", "failure"}
and not _is_retryable_skip_evidence(item)
} }
@ -469,6 +662,7 @@ def _existing_google_custom_query_signatures(evidence: list[dict[str, Any]]) ->
and item.get("source") in {"google", "failure"} and item.get("source") in {"google", "failure"}
and str(item.get("domain", "")) == "google_custom_search" and str(item.get("domain", "")) == "google_custom_search"
and item.get("querySignature") and item.get("querySignature")
and not _is_retryable_skip_evidence(item)
} }

View file

@ -0,0 +1,437 @@
"""Operator throughput features for CopyrighterStore, as a mixin.
Batch decisions, reusable decision-memo presets, and decision export. Mixed into
CopyrighterStore; relies on persistence + operations methods and self.*
attributes provided by the host class.
"""
from __future__ import annotations
from rights_filter.server.operator_identity import current_actor
import csv
import io
from typing import Any
from rights_filter.server.image_store import LocalSubmissionImageStore
from rights_filter.server.store_constants import HOLD_WAITING_MARKER
from rights_filter.server.store_serialization import _now_label, _stable_id
# A batch is an operator convenience, not an import path: keep it small enough
# that one accidental "select all" cannot rewrite the whole queue in one click.
MAX_BATCH_DECISIONS = 200
# Fields the applicant-facing export may contain. Anything not listed here —
# risk score, evidence, provider state, internal memos beyond the decision
# reason — must never cross this boundary.
APPLICANT_EXPORT_FIELDS = ("submissionId", "title", "decision", "applicantStatus", "memo")
INTERNAL_EXPORT_FIELDS = (
"submissionId",
"title",
"decision",
"applicantStatus",
"riskScore",
"riskBand",
"scoredVersion",
"currentRiskScore",
"memo",
"decidedAt",
"evidenceUsed",
"evidenceUrls",
)
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"),
# The version that produced the score shown above. It diverges from
# scorerVersion (the as-created stamp) after a weight change, which
# is exactly when "why is this case medium now?" gets asked.
"scoredVersion": submission.get("scoredVersion", ""),
"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(
self,
submission_ids: list[str],
decision: str,
memo: str = "",
image_store: LocalSubmissionImageStore | None = None,
) -> dict[str, Any]:
"""Apply one decision to many submissions, reporting per-case outcomes.
Each case goes through record_decision so the per-case transaction, audit
event and watchlist side effects stay identical to a single decision.
One failure does not abort the rest — the caller gets an explicit list of
what succeeded and what did not.
"""
if decision not in {"approved", "held", "rejected"}:
raise ValueError("unsupported decision")
unique_ids = list(dict.fromkeys(str(value) for value in submission_ids if str(value)))
if not unique_ids:
raise ValueError("no submissions selected")
if len(unique_ids) > MAX_BATCH_DECISIONS:
raise ValueError(
f"batch too large: {len(unique_ids)} > {MAX_BATCH_DECISIONS}"
)
if decision == "rejected" and not memo.strip():
raise ValueError("reject decision requires memo")
succeeded: list[str] = []
failed: list[dict[str, str]] = []
for submission_id in unique_ids:
try:
self.record_decision(submission_id, decision, memo, image_store)
succeeded.append(submission_id)
except KeyError:
# str(KeyError) is just the repr'd key — useless in the UI.
failed.append(
{"submissionId": submission_id, "error": "제출물을 찾을 수 없습니다"}
)
except ValueError as exc:
failed.append({"submissionId": submission_id, "error": str(exc)})
self.add_audit_event(
current_actor(),
"Operator batch decision",
f"{decision} x{len(succeeded)}",
f"{len(succeeded)} applied, {len(failed)} failed · {memo or 'memo optional'}",
)
payload = self.bootstrap()
payload["batchDecision"] = {
"decision": decision,
"applied": succeeded,
"failed": failed,
}
return payload
# ---- decision memo presets ------------------------------------------
def decision_memo_presets(self) -> list[dict[str, Any]]:
presets = self._all("decision_memo_presets")
return sorted(presets, key=lambda item: str(item.get("label", "")))
def save_decision_memo_preset(self, label: str, body: str) -> list[dict[str, Any]]:
clean_label = str(label or "").strip()
clean_body = str(body or "").strip()
if not clean_label:
raise ValueError("preset label is required")
if not clean_body:
raise ValueError("preset body is required")
if len(clean_label) > 40:
raise ValueError("preset label must be 40 characters or fewer")
if len(clean_body) > 500:
raise ValueError("preset body must be 500 characters or fewer")
# Key by label so re-saving the same label edits it instead of stacking
# near-duplicate chips in the operator's decision pane.
preset_id = _stable_id("preset", clean_label)
self._put(
"decision_memo_presets",
preset_id,
{
"id": preset_id,
"label": clean_label,
"body": clean_body,
"updatedAt": _now_label(),
},
)
self.add_audit_event(
current_actor(), "Decision memo preset saved", preset_id, clean_label
)
return self.decision_memo_presets()
def delete_decision_memo_preset(self, preset_id: str) -> list[dict[str, Any]]:
with self._connect() as conn:
cursor = conn.execute(
"delete from decision_memo_presets where id = ?", (str(preset_id),)
)
if not cursor.rowcount:
raise KeyError(preset_id)
self.add_audit_event(
current_actor(), "Decision memo preset deleted", str(preset_id), "removed"
)
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(
self,
*,
audience: str = "internal",
status: str = "",
since: str = "",
) -> dict[str, Any]:
"""Collect decided cases for hand-off.
`audience="applicant"` restricts the payload to the fields that may leave
the review system: no risk score, no evidence, no provider state.
"""
if audience not in {"internal", "applicant"}:
raise ValueError("audience must be 'internal' or 'applicant'")
wanted_status = str(status or "").strip()
if wanted_status and wanted_status not in {"approved", "held", "rejected", "corrected"}:
raise ValueError("unsupported decision status filter")
decided_at = self._decision_timestamps()
since_text = str(since or "").strip()
rows: list[dict[str, Any]] = []
for submission in self._all("submissions"):
decision = str(submission.get("decisionStatus", "unreviewed"))
if decision == "unreviewed":
continue
if wanted_status and decision != wanted_status:
continue
submission_id = str(submission.get("id", ""))
timestamp = decided_at.get(submission_id, "")
if since_text and timestamp and timestamp < since_text:
continue
rows.append(
self._export_row(submission, decision, timestamp, audience=audience)
)
rows.sort(key=lambda row: str(row.get("decidedAt", "")), reverse=True)
fields = (
APPLICANT_EXPORT_FIELDS if audience == "applicant" else INTERNAL_EXPORT_FIELDS
)
self.add_audit_event(
current_actor(),
"Decisions exported",
audience,
f"{len(rows)} case(s) · status={wanted_status or 'all'} · since={since_text or 'all'}",
)
return {
"audience": audience,
"fields": list(fields),
"rows": rows,
"csv": _rows_to_csv(rows, fields),
}
def _export_row(
self,
submission: dict[str, Any],
decision: str,
decided_at: str,
*,
audience: str,
) -> dict[str, Any]:
submission_id = str(submission.get("id", ""))
memo = self._latest_decision_memo(submission_id)
row = {
"submissionId": submission_id,
"title": str(submission.get("title", "")),
"decision": decision,
"applicantStatus": str(submission.get("applicantStatus", "")),
"memo": memo,
}
if audience == "applicant":
return row
# What the operator saw WHEN THEY DECIDED, not what the case says now.
# Reading the live score and live "사용" marks meant a rescore or a mark
# changed after the decision rewrote the record of why it was made
# (persona review 2026-09-25, 법무 2-A). The snapshot has been written
# since the first decision for exactly this; rows decided before it
# existed fall back to the live values and say so via scoredVersion.
snapshot = submission.get("decisionSnapshot") or {}
evidence = self._evidence_for_submission(submission_id)
if snapshot.get("evidence") is not None:
used_ids = {
str(item.get("id", ""))
for item in snapshot["evidence"]
if str(item.get("operatorStatus", "")) == "used_for_judgment"
}
used = [item for item in evidence if str(item.get("id", "")) in used_ids]
score, band = snapshot.get("riskScore", 0), str(snapshot.get("riskBand", ""))
scored_version = str(snapshot.get("scoredVersion") or snapshot.get("scorerVersion") or "")
else:
used = [item for item in evidence if str(item.get("operatorStatus", "")) == "used_for_judgment"]
score, band = submission.get("riskScore", 0), str(submission.get("riskBand", ""))
scored_version = "no-snapshot"
row.update(
{
"riskScore": score,
"riskBand": band,
"scoredVersion": scored_version,
# Kept beside the decision-time score so drift since the
# decision is visible rather than silently substituted.
"currentRiskScore": submission.get("riskScore", 0),
"decidedAt": decided_at,
"evidenceUsed": " | ".join(str(item.get("title", "")) for item in used),
"evidenceUrls": " | ".join(
str(item.get("url", "")) for item in used if item.get("url")
),
}
)
return row
def _decision_events(self) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
"""
select object_ref, timestamp, payload from audit_events
where event = 'Operator decision created'
order by id asc
"""
).fetchall()
return [
{
"object_ref": str(row["object_ref"]),
"timestamp": str(row["timestamp"]),
"payload": row["payload"],
}
for row in rows
]
def _decision_timestamps(self) -> dict[str, str]:
# Submissions carry no decided-at field; the audit trail is the only
# record of when the operator decided, so read the latest event per case.
return {event["object_ref"]: event["timestamp"] for event in self._decision_events()}
def _latest_decision_memo(self, submission_id: str) -> str:
import json
# The memo of the LATEST decision only. Taking the last non-empty memo
# across all events meant a rejection's memo outlived the approval that
# reversed it and went out to the applicant (codex F4).
memo = ""
for event in self._decision_events():
if event["object_ref"] != submission_id:
continue
try:
change = str(json.loads(event["payload"]).get("change", ""))
except (ValueError, TypeError):
continue
# Audit change text is "<decision> · <memo>[ · <hold marker>]".
_, separator, tail = change.partition(" · ")
tail = tail.removesuffix(f" · {HOLD_WAITING_MARKER}")
memo = tail if separator and tail and tail != "memo optional" else ""
return memo
def _ratio(numerator: int, denominator: int) -> float | None:
# None, not 0.0, when nothing was decided in that band: "미탐 0%" on an empty
# denominator read as a measured result when it was no measurement at all.
return round(numerator / denominator, 3) if denominator else None
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")
writer.writeheader()
for row in rows:
writer.writerow(row)
return buffer.getvalue()

View file

@ -0,0 +1,21 @@
"""Release version, read from the single source: the repository-root VERSION file.
Hard-coding a version anywhere else is how a delivered bundle ends up reporting a
number nobody can match to a build. A missing file is an error, not "unknown" -
an unversioned deliverable must not be produced or run silently.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
VERSION_FILE = Path(__file__).resolve().parents[2] / "VERSION"
@lru_cache(maxsize=1)
def release_version() -> str:
text = VERSION_FILE.read_text(encoding="utf-8").strip()
if not text:
raise ValueError(f"{VERSION_FILE} is empty")
return text

20
tests/conftest.py Normal file
View file

@ -0,0 +1,20 @@
"""Suite-wide isolation from services that happen to run on the test host.
A store built without an explicit provider_runtime reads os.environ, and the
LLM client defaults to Ollama on 127.0.0.1:11434. With a local Ollama running,
those tests made real calls and three test_http_app specs failed on socket
timeouts - an air-gapped project's suite depended on the *absence* of a local
service. Point the default at a port nothing listens on; tests that exercise the
LLM pass their own env and fake transport and are unaffected.
"""
from __future__ import annotations
import pytest
_DEAD_OLLAMA = "http://127.0.0.1:1"
@pytest.fixture(autouse=True)
def _no_local_ollama(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OLLAMA_BASE_URL", _DEAD_OLLAMA)

View file

@ -91,6 +91,9 @@ def browser() -> Iterator[Any]:
def operator_page(browser: Any, request: pytest.FixtureRequest) -> Iterator[Any]: def operator_page(browser: Any, request: pytest.FixtureRequest) -> Iterator[Any]:
"""A fresh page at the default viewport; screenshots to artifacts on failure.""" """A fresh page at the default viewport; screenshots to artifacts on failure."""
page = browser.new_page(viewport=DEFAULT_VIEWPORT) page = browser.new_page(viewport=DEFAULT_VIEWPORT)
# The console refuses decisions until an operator name is set (audit "who").
# Specs drive decisions as a named operator, the way a real session would.
page.add_init_script("window.localStorage.setItem('copyrighter.operatorName', 'e2e 운영자')")
try: try:
yield page yield page
finally: finally:

View file

@ -20,6 +20,12 @@ SUGGESTED_QUERY_LOADED_STATUS = "추천 쿼리를 입력했습니다. 실행 버
# Client-side guard message when a reject/correct decision is missing its memo. # Client-side guard message when a reject/correct decision is missing its memo.
MEMO_REQUIRED_ERROR = "반려 또는 보정 결정에는 메모가 필요합니다." MEMO_REQUIRED_ERROR = "반려 또는 보정 결정에는 메모가 필요합니다."
# Uploading runs the real analysis pipeline server-side (decode, fingerprints,
# YuNet face detection) before the case appears in the queue, so this path needs
# far more headroom than a mocked-bootstrap wait. At 10s it passed standalone but
# failed inside the full suite, where the other specs contend for the same CPU.
UPLOAD_ANALYSIS_TIMEOUT_MS = 30000
class OperatorWorkbench: class OperatorWorkbench:
"""Drives the operator GUI for browser-level (E2E) assertions.""" """Drives the operator GUI for browser-level (E2E) assertions."""
@ -123,7 +129,9 @@ class OperatorWorkbench:
self.page.get_by_role("button", name="사진 넣기").click() self.page.get_by_role("button", name="사진 넣기").click()
return self return self
def upload_and_open(self, file_path: str, case_id: str, timeout: int = 10000) -> "OperatorWorkbench": def upload_and_open(
self, file_path: str, case_id: str, timeout: int = UPLOAD_ANALYSIS_TIMEOUT_MS
) -> "OperatorWorkbench":
"""Upload an image and wait until its new case is queued and the workbench opens.""" """Upload an image and wait until its new case is queued and the workbench opens."""
self.upload_submission(file_path) self.upload_submission(file_path)
self.confirm_upload() self.confirm_upload()

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import pytest import pytest
from .pages import OperatorWorkbench from .pages import OperatorWorkbench
from .pages.workbench_page import SUGGESTED_QUERY_LOADED_STATUS from .pages.workbench_page import SUGGESTED_QUERY_LOADED_STATUS, UPLOAD_ANALYSIS_TIMEOUT_MS
def _bootstrap_payload(): def _bootstrap_payload():
@ -104,8 +104,8 @@ def test_browser_uploads_image_and_selects_new_submission(operator_server, opera
assert workbench.submission_image_name.inner_text() == "smoke upload.svg" assert workbench.submission_image_name.inner_text() == "smoke upload.svg"
workbench.confirm_upload() workbench.confirm_upload()
workbench.wait_for_case("smoke-upload", state="attached", timeout=10000) workbench.wait_for_case("smoke-upload", state="attached", timeout=UPLOAD_ANALYSIS_TIMEOUT_MS)
workbench.workbench_view.wait_for(state="visible", timeout=10000) workbench.workbench_view.wait_for(state="visible", timeout=UPLOAD_ANALYSIS_TIMEOUT_MS)
assert (operator_server.image_root / "images" / "smoke-upload.svg").exists() assert (operator_server.image_root / "images" / "smoke-upload.svg").exists()
assert ( assert (

View file

@ -8,7 +8,27 @@ ROOT = Path(__file__).resolve().parents[2]
APP_DIR = ROOT / "web" / "operator-gui" APP_DIR = ROOT / "web" / "operator-gui"
INDEX = APP_DIR / "index.html" INDEX = APP_DIR / "index.html"
STYLES = APP_DIR / "styles.css" STYLES = APP_DIR / "styles.css"
APP_JS = APP_DIR / "app.js" # The former single app.js, split by concern. Order is load-bearing (classic
# scripts sharing one global scope), so this list doubles as the expectation the
# index.html ordering test asserts against.
APP_SCRIPT_NAMES = [
"app-state.js",
"app-api.js",
"app-format.js",
"app-selectors.js",
"app-render-queue.js",
"app-render-evidence.js",
"app-render-panels.js",
"app-navigation.js",
"app-keyboard-batch.js",
"app-decision-tools.js",
"app-stats.js",
"app-submissions.js",
"app-collections.js",
"app-admin.js",
"app-bootstrap.js",
]
APP_SCRIPTS = [APP_DIR / name for name in APP_SCRIPT_NAMES]
OPERATOR_LABELS_JS = APP_DIR / "operator-labels.js" OPERATOR_LABELS_JS = APP_DIR / "operator-labels.js"
SUBMISSION_IMPORT_JS = APP_DIR / "submission-import.js" SUBMISSION_IMPORT_JS = APP_DIR / "submission-import.js"
EVIDENCE_GUIDANCE_JS = APP_DIR / "evidence-guidance.js" EVIDENCE_GUIDANCE_JS = APP_DIR / "evidence-guidance.js"
@ -33,10 +53,20 @@ def _read(path: Path) -> str:
return path.read_text(encoding="utf-8") return path.read_text(encoding="utf-8")
def _read_app() -> str:
"""The whole operator app script, whichever part a given snippet now lives in.
These specs assert that a behavior is present in the console, not which file
holds it, so they read the parts as one text in load order.
"""
return "\n".join(_read(path) for path in APP_SCRIPTS)
def test_operator_workbench_files_exist(): def test_operator_workbench_files_exist():
assert INDEX.exists() assert INDEX.exists()
assert STYLES.exists() assert STYLES.exists()
assert APP_JS.exists() for path in APP_SCRIPTS:
assert path.exists(), path.name
assert OPERATOR_LABELS_JS.exists() assert OPERATOR_LABELS_JS.exists()
assert SUBMISSION_IMPORT_JS.exists() assert SUBMISSION_IMPORT_JS.exists()
assert EVIDENCE_GUIDANCE_JS.exists() assert EVIDENCE_GUIDANCE_JS.exists()
@ -63,7 +93,7 @@ def test_operator_gui_text_does_not_include_mojibake_fragments():
"?", "?",
] ]
for path in [INDEX, APP_JS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]: for path in [INDEX, *APP_SCRIPTS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]:
text = _read(path) text = _read(path)
for fragment in suspect_fragments: for fragment in suspect_fragments:
assert fragment not in text, f"{path.name} contains mojibake fragment {fragment!r}" assert fragment not in text, f"{path.name} contains mojibake fragment {fragment!r}"
@ -128,8 +158,14 @@ def test_workbench_shell_exposes_all_internal_operator_views():
assert "추천 쿼리로 외부 검색 결과를 보강합니다." in html assert "추천 쿼리로 외부 검색 결과를 보강합니다." in html
assert "기준 DB에 반영합니다." in html assert "기준 DB에 반영합니다." in html
assert ".operator-workflow" in styles assert ".operator-workflow" in styles
assert "bulk-approve" not in html.lower() # 2026-07-24: the v1 "no bulk approve/reject" restriction was lifted (see
assert "bulk-reject" not in html.lower() # docs/superpowers/specs/2026-05-25-image-rights-operator-gui-design.md).
# Batch decisions are allowed, but only with the guardrails that made lifting
# it acceptable — assert those are present rather than that the controls are.
assert 'id="bulk-approve"' in html
assert 'id="bulk-reject"' in html
assert 'id="bulk-decision-count"' in html # selection size is always visible
assert 'id="bulk-decision-status"' in html # per-case outcome is reported back
assert 'data-view="evidence"' not in html assert 'data-view="evidence"' not in html
assert 'id="evidence-view"' not in html assert 'id="evidence-view"' not in html
@ -148,7 +184,7 @@ def test_workbench_shell_exposes_all_internal_operator_views():
def test_static_app_models_core_review_operations(): def test_static_app_models_core_review_operations():
script = _read(APP_JS) script = _read_app()
for state_name in [ for state_name in [
"submissions", "submissions",
@ -187,18 +223,56 @@ def test_static_app_models_core_review_operations():
assert f"function {operation}" in script assert f"function {operation}" in script
def test_index_loads_every_app_part_exactly_once_in_declared_order():
"""The split is only safe while the load order holds.
The parts are classic scripts sharing one global scope: app-state.js declares
the state the rest read, and app-bootstrap.js is the only part that executes
anything at load. Reordering, dropping, or adding a part without updating
index.html would break the console at runtime but leave every content-level
assertion in this module green, so pin the sequence itself.
"""
html = _read(INDEX)
positions = []
for name in APP_SCRIPT_NAMES:
tag = f'<script src="{name}" defer></script>'
assert html.count(tag) == 1, f"{name} must be loaded exactly once"
positions.append(html.index(tag))
assert positions == sorted(positions), "app-*.js tags are out of declared order"
assert APP_SCRIPT_NAMES[-1] == "app-bootstrap.js", "the part that starts the app must load last"
# No part may be orphaned: every app-*.js on disk has to be wired into the page.
on_disk = sorted(path.name for path in APP_DIR.glob("app-*.js"))
assert on_disk == sorted(APP_SCRIPT_NAMES)
# The pre-split single file must be gone, or a stale copy would silently
# double-define everything if anyone re-added a tag for it.
assert not (APP_DIR / "app.js").exists()
def test_app_parts_stay_within_the_file_size_budget():
"""800 lines per file is the project's stated ceiling (coding-style rules)."""
oversized = {
path.name: len(_read(path).splitlines()) for path in APP_SCRIPTS if len(_read(path).splitlines()) > 800
}
assert not oversized, f"app parts over the 800-line budget: {oversized}"
def test_operator_gui_extracts_submission_import_helpers_from_main_app(): def test_operator_gui_extracts_submission_import_helpers_from_main_app():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
labels = _read(OPERATOR_LABELS_JS) labels = _read(OPERATOR_LABELS_JS)
helpers = _read(SUBMISSION_IMPORT_JS) helpers = _read(SUBMISSION_IMPORT_JS)
guidance = _read(EVIDENCE_GUIDANCE_JS) guidance = _read(EVIDENCE_GUIDANCE_JS)
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
first_app_part = f'src="{APP_SCRIPT_NAMES[0]}"'
assert html.index('src="operator-labels.js"') < html.index('src="submission-import.js"') assert html.index('src="operator-labels.js"') < html.index('src="submission-import.js"')
assert html.index('src="submission-import.js"') < html.index('src="app.js"') assert html.index('src="submission-import.js"') < html.index(first_app_part)
assert html.index('src="evidence-guidance.js"') < html.index('src="app.js"') assert html.index('src="evidence-guidance.js"') < html.index(first_app_part)
assert html.index('src="operator-search.js"') < html.index('src="app.js"') assert html.index('src="operator-search.js"') < html.index(first_app_part)
assert "OperatorLabels" in labels assert "OperatorLabels" in labels
assert "riskLabels" in labels assert "riskLabels" in labels
assert "providerLabels" in labels assert "providerLabels" in labels
@ -225,7 +299,7 @@ def test_operator_gui_extracts_submission_import_helpers_from_main_app():
def test_operator_gui_does_not_boot_with_demo_review_data(): def test_operator_gui_does_not_boot_with_demo_review_data():
script = _read(APP_JS) script = _read_app()
for collection in [ for collection in [
"submissions", "submissions",
@ -297,7 +371,7 @@ def test_ui_overhaul_final_audit_has_no_overflow_and_required_screenshots():
def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel(): def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
html = _read(INDEX) html = _read(INDEX)
styles = _read(STYLES) styles = _read(STYLES)
script = _read(APP_JS) script = _read_app()
assert 'id="coverage-tabs"' in html assert 'id="coverage-tabs"' in html
assert 'id="search-coverage"' not in html assert 'id="search-coverage"' not in html
@ -312,7 +386,7 @@ def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
def test_queue_uses_grid_row_contract_for_dense_evidence_and_provider_columns(): def test_queue_uses_grid_row_contract_for_dense_evidence_and_provider_columns():
html = _read(INDEX) html = _read(INDEX)
styles = _read(STYLES) styles = _read(STYLES)
script = _read(APP_JS) script = _read_app()
assert "queue-grid" in html assert "queue-grid" in html
assert "queue-row" in script assert "queue-row" in script
@ -343,7 +417,7 @@ def test_visual_overhaul_removes_obsolete_header_cards_and_fixed_table_widths():
def test_case_workbench_owns_evidence_and_query_history_as_internal_tabs(): def test_case_workbench_owns_evidence_and_query_history_as_internal_tabs():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert 'id="workbench-view"' in html assert 'id="workbench-view"' in html
@ -381,7 +455,7 @@ def test_case_workbench_tabs_do_not_share_the_same_review_layout():
def test_case_decision_controls_float_while_reviewing_evidence(): def test_case_decision_controls_float_while_reviewing_evidence():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "floating-decision-panel" in html assert "floating-decision-panel" in html
@ -398,7 +472,7 @@ def test_case_decision_controls_float_while_reviewing_evidence():
def test_knowledge_base_rows_separate_title_metadata_chips_and_actions(): def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "knowledge-main" in script assert "knowledge-main" in script
@ -417,7 +491,7 @@ def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
def test_correction_history_lives_inside_knowledge_database_panel(): def test_correction_history_lives_inside_knowledge_database_panel():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert 'data-knowledge-tab="collect"' in html assert 'data-knowledge-tab="collect"' in html
@ -454,7 +528,7 @@ def test_knowledge_collection_and_registered_entries_are_separate_tabs():
def test_safety_rules_are_visible_in_ui_contract(): def test_safety_rules_are_visible_in_ui_contract():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
assert 'id="decision-memo"' in html assert 'id="decision-memo"' in html
assert 'id="manual-query-provider"' in html assert 'id="manual-query-provider"' in html
@ -470,7 +544,7 @@ def test_safety_rules_are_visible_in_ui_contract():
def test_provider_quota_zero_does_not_render_nan_meter(): def test_provider_quota_zero_does_not_render_nan_meter():
script = _read(APP_JS) script = _read_app()
assert "provider.quota ? Math.min" in script assert "provider.quota ? Math.min" in script
assert "configuredEnv" in script assert "configuredEnv" in script
@ -478,7 +552,7 @@ def test_provider_quota_zero_does_not_render_nan_meter():
def test_query_history_rerun_preserves_original_search_provider(): def test_query_history_rerun_preserves_original_search_provider():
script = _read(APP_JS) script = _read_app()
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
assert "data-rerun-provider" in script assert "data-rerun-provider" in script
@ -491,7 +565,7 @@ def test_query_history_rerun_preserves_original_search_provider():
def test_operator_gui_exposes_submission_reload_action(): def test_operator_gui_exposes_submission_reload_action():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
assert 'id="submission-folder"' in html assert 'id="submission-folder"' in html
assert 'id="submission-image"' in html assert 'id="submission-image"' in html
@ -513,7 +587,7 @@ def test_operator_gui_exposes_submission_reload_action():
def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports(): def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
script = _read(APP_JS) + _read(SUBMISSION_IMPORT_JS) script = _read_app() + _read(SUBMISSION_IMPORT_JS)
for required_copy in [ for required_copy in [
"제출 폴더를 읽는 중입니다.", "제출 폴더를 읽는 중입니다.",
@ -532,7 +606,7 @@ def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
def test_operator_gui_bulk_rerun_uses_checked_queue_rows(): def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
script = _read(APP_JS) script = _read_app()
assert "function selectedBulkSubmissionIds" in script assert "function selectedBulkSubmissionIds" in script
assert 'input[data-bulk-id]:checked' in script assert 'input[data-bulk-id]:checked' in script
@ -542,7 +616,7 @@ def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
def test_operator_gui_manual_knowledge_entry_accepts_reference_image(): def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
assert 'id="knowledge-image"' in html assert 'id="knowledge-image"' in html
assert 'id="knowledge-image-name"' in html assert 'id="knowledge-image-name"' in html
@ -557,7 +631,7 @@ def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
def test_operator_gui_exposes_keyword_candidate_collection_workflow(): def test_operator_gui_exposes_keyword_candidate_collection_workflow():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
assert 'id="candidate-collection-form"' in html assert 'id="candidate-collection-form"' in html
@ -606,7 +680,7 @@ def test_operator_gui_exposes_keyword_candidate_collection_workflow():
def test_google_custom_search_is_exposed_as_operator_text_query_choice(): def test_google_custom_search_is_exposed_as_operator_text_query_choice():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
labels = _read(OPERATOR_LABELS_JS) labels = _read(OPERATOR_LABELS_JS)
@ -632,7 +706,7 @@ def test_audit_target_and_change_columns_have_equal_widths():
def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column(): def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column():
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "provider-strip" in script assert "provider-strip" in script
@ -648,20 +722,26 @@ def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column
assert "grid-template-columns: 28px 64px minmax(104px, 0.68fr) 72px minmax(126px, 0.58fr) minmax(360px, 1.5fr) 82px 76px 90px" in styles assert "grid-template-columns: 28px 64px minmax(104px, 0.68fr) 72px minmax(126px, 0.58fr) minmax(360px, 1.5fr) 82px 76px 90px" in styles
def test_evidence_operator_status_actions_are_binary_use_or_ignore(): def test_evidence_operator_status_actions_are_binary_cite_or_irrelevant():
script = _read(APP_JS) # codex F2 (2026-09-23): the second button read "미사용" (not cited) but sent
# false_positive, so picking one representative source wiped the score of
# every other valid row. Two buttons stay; the second now says what it does.
script = _read_app()
assert '["used_for_judgment", "사용"]' in script assert '["used_for_judgment", "사용"]' in script
assert '["ignored", "미사용"]' in script assert '["irrelevant", "무관"]' in script
assert '["irrelevant",' not in script assert '["ignored", "미사용"]' not in script
assert '["false_positive",' not in script assert '["false_positive",' not in script
assert '["pending",' not in script assert '["pending",' not in script
assert "normalizeEvidenceOperatorStatus" in script assert "normalizeEvidenceOperatorStatus" in script
# Un-citing is a toggle back to pending (score kept), not a removal.
assert '? "pending" : status' in script
assert 'status === "ignored" ? "irrelevant" : status' in script
def test_operator_gui_uses_korean_operator_copy_for_visible_labels(): def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
for english_label in [ for english_label in [
"Image Rights Operator Console", "Image Rights Operator Console",
@ -695,8 +775,29 @@ def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
assert "샘플 지문" in script assert "샘플 지문" in script
def test_operator_gui_shows_the_publishing_host_on_every_evidence_row():
# "Where is this published?" is the fact an operator scans an evidence list
# for. It was unavailable: the only host-ish field on the row rendered the
# API name ("google") for every result.
script = _read_app()
styles = _read(APP_DIR / "styles.css")
assert "evidenceHost" in script
assert "host-chip" in script
assert ".host-chip" in styles
# The chip is capped at 20ch, so the full host has to be legible somewhere:
# two CDN hosts sharing a 20-character prefix are identical on screen.
assert 'title="게시 호스트 ${escapeHtml(hostName)}"' in script
# The same candidate list as _result_host in store_serialization.py, so a
# legacy row whose only host-bearing field is bloggerLink still resolves.
assert "evidence.bloggerLink," in script
# The chips beside the title do not shrink, so the row wraps instead of
# breaking the headline one character per line at phone width.
assert "min-width: 14ch;" in styles
def test_operator_gui_renders_search_result_links_and_thumbnails(): def test_operator_gui_renders_search_result_links_and_thumbnails():
script = _read(APP_JS) script = _read_app()
assert "renderEvidenceLink" in script assert "renderEvidenceLink" in script
assert "thumbnailUrl" in script assert "thumbnailUrl" in script
@ -719,7 +820,7 @@ def test_operator_gui_renders_search_result_links_and_thumbnails():
def test_operator_gui_prioritizes_evidence_and_hides_overflow_details(): def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "renderEvidenceSummary" in script assert "renderEvidenceSummary" in script
@ -734,7 +835,7 @@ def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
def test_operator_gui_suggests_followup_queries_for_insufficient_evidence(): def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
guidance = _read(EVIDENCE_GUIDANCE_JS) guidance = _read(EVIDENCE_GUIDANCE_JS)
combined_script = script + guidance combined_script = script + guidance
styles = _read(STYLES) styles = _read(STYLES)
@ -760,7 +861,7 @@ def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching(): def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
script = _read(APP_JS) script = _read_app()
assert "faceCropSearch" in script assert "faceCropSearch" in script
assert "face_web" in script assert "face_web" in script
@ -769,7 +870,7 @@ def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
def test_operator_gui_exposes_evidence_status_and_watchlist_controls(): def test_operator_gui_exposes_evidence_status_and_watchlist_controls():
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "/api/evidence/" in script assert "/api/evidence/" in script
@ -790,7 +891,7 @@ def test_visual_assets_are_referenced_for_review_images():
assets_dir = APP_DIR / "assets" assets_dir = APP_DIR / "assets"
assert (assets_dir / "case-portrait.svg").exists() assert (assets_dir / "case-portrait.svg").exists()
assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read(APP_JS) assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read_app()
for orphan_asset in [ for orphan_asset in [
"case-character.svg", "case-character.svg",
@ -801,7 +902,7 @@ def test_visual_assets_are_referenced_for_review_images():
def test_suggested_queries_support_one_click_and_batch_execution(): def test_suggested_queries_support_one_click_and_batch_execution():
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "executeSuggestedQueries" in script assert "executeSuggestedQueries" in script
@ -817,7 +918,7 @@ def test_suggested_queries_support_one_click_and_batch_execution():
def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle(): def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0] registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0]
assert 'id="knowledge-search"' in registered_panel assert 'id="knowledge-search"' in registered_panel
@ -837,7 +938,7 @@ def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
def test_workbench_shows_detected_face_crop_strip(): def test_workbench_shows_detected_face_crop_strip():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
assert 'id="face-crop-strip"' in html assert 'id="face-crop-strip"' in html
assert "faceCrops" in script assert "faceCrops" in script
@ -850,7 +951,7 @@ def test_workbench_shows_detected_face_crop_strip():
def test_rerun_diff_summary_and_new_evidence_badges_are_rendered(): def test_rerun_diff_summary_and_new_evidence_badges_are_rendered():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert 'id="rerun-diff-summary"' in html assert 'id="rerun-diff-summary"' in html
@ -860,3 +961,153 @@ def test_rerun_diff_summary_and_new_evidence_badges_are_rendered():
assert "이번 재분석에서 제거됨" in script assert "이번 재분석에서 제거됨" in script
assert ".evidence-new-chip" in styles assert ".evidence-new-chip" in styles
assert ".evidence-row-new" in styles assert ".evidence-row-new" in styles
def test_operator_gui_explains_a_retained_case_whose_source_file_is_gone():
# The reload sweep keeps a decided case after its image has been archived out
# of the queue folder. Nothing told the operator: the console rendered a
# broken <img> and "file archived after the decision" was indistinguishable
# from "upload corrupted".
script = _read_app()
styles = _read(STYLES)
assert "assetMissing" in script
assert "MISSING_ASSET_NOTE" in script
assert "원본 파일 없음" in script
assert ".thumb-missing" in styles
def test_operator_gui_surfaces_a_prior_decision_on_the_same_image():
# 중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능인데, 그 결과인
# priorDecisions 는 큐 배지의 title 속성에만 있었다 — 마우스를 올린 사람만
# 볼 수 있는 자리라 재제출을 처음부터 다시 심사하는 것을 막지 못했다.
script = _read_app()
markup = _read(INDEX)
styles = _read(STYLES)
assert "priorDecisionSummary" in script
assert "setPriorDecisionBox" in script
assert 'id="prior-decision-box"' in markup
assert ".prior-decision" in styles
# 배지 본문에도 선례가 실려야 한다(title 에만 있으면 훑는 동안 안 보인다).
assert "priorNote" in script
def test_operator_gui_inherits_a_prior_decision_through_the_normal_decision_path():
# 승계가 별도 판정 경로를 만들면 반려 메모 필수 검사·판정 스냅샷·감사 이벤트를
# 우회하게 된다. setDecision 을 그대로 타야 한다.
script = _read_app()
assert "async function inheritPriorDecision" in script
assert "data-inherit-decision" in script
assert "await setDecision(decision" in script
assert "선례 승계" in script
def test_operator_gui_does_not_offer_inheritance_when_prior_decisions_conflict():
# 같은 이미지에 서로 다른 판정이 있으면 자동 제안 대상이 아니다 —
# 그때가 바로 사람이 봐야 하는 상황이다.
script = _read_app()
assert "conflicting" in script
assert "prior-decision-conflict" in script
# 만장일치일 때만 inheritable 이 채워진다.
assert "unanimous ? priors[0].decision : null" in script
def test_operator_gui_can_mark_a_whole_evidence_group_at_once():
# 실 DB 제출물 하나를 100 에서 35 로 내리려면 google 증거 51행을 개별로
# 눌러야 했다. 되먹임이 노동보다 비싸면 아무도 되먹이지 않는다.
script = _read_app()
styles = _read(STYLES)
assert "setEvidenceGroupStatus" in script
assert "/api/evidence/status-batch" in script
assert "data-evidence-group-status" in script
assert "전부 무관" in script
assert ".evidence-group-actions" in styles
# 그룹 전체를 "오탐"으로 단정하지 않는다 - 무관이 정직한 주장이다.
assert 'data-evidence-group-status="irrelevant"' in script
def test_operator_gui_explains_how_the_score_was_reached():
# 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 갖게 되면서 증거 개수와
# 점수의 간극이 벌어졌다. 증거 51행이 35점이 되는 이유가 화면에 없으면
# 운영자는 숫자를 믿을 수 없고, 믿지 못하는 우선순위는 우선순위가 아니다.
script = _read_app()
markup = _read(INDEX)
styles = _read(STYLES)
assert "setScoreBreakdown" in script
assert "riskBreakdown" in script
assert 'id="score-breakdown"' in markup
assert ".score-breakdown" in styles
# 상한에 걸린 항목은 원래 몇 점이었는지가 핵심 정보다.
assert "원래" in script and "상한" in script
# 100 포화는 그 자체로 알려야 한다 - 그 구간은 서로 구별되지 않는다.
assert "score-breakdown-clamped" in script
def test_operator_gui_leads_with_the_scorer_headline_instead_of_raw_reason_fragments():
# 실 코퍼스 콘솔에서 큐의 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...`
# 였다 - 사유 원문 둘을 잘라 붙인 것. 스코어러가 주는 한 줄 요약(riskBreakdown.headline,
# "웹 완전일치 사본 3곳 · 식별 가능 인물")이 큐와 케이스 요약의 첫 줄이 되고, 요약이
# 없는 옛 케이스만 예전 배치로 떨어진다.
script = _read_app()
assert "riskBreakdown.headline" in script
assert script.count("riskBreakdown.headline") >= 2 # 큐 행 + 케이스 요약 보드
assert ".slice(0, 2)" in script # 옛 케이스 폴백은 남아 있어야 한다
# 증거 제목도 절반 영어(`구글 full image match found`)로 남지 않는다.
for korean in ("구글 동일 이미지 발견", "구글 게시 페이지 발견", "검색 결과 사본 유사도"):
assert korean in script
def test_operator_gui_completes_the_rejection_path_from_the_keyboard():
# 승인·보류는 1타인데 반려만 메모 필수라 손이 마우스로 갔다 — 법적으로 가장
# 중요한 경로가 가장 느렸다. R 로 사유를 고르면 두 키로 끝난다.
script = _read_app()
markup = _read(INDEX)
styles = _read(STYLES)
assert "openRejectPrompt" in script
assert "rejectWithPreset" in script
assert "closeRejectPrompt" in script
assert 'id="reject-prompt"' in markup
assert ".reject-prompt" in styles
# 자유 서술은 메모 안에서 끝나야 한다 — 입력 중에는 전역 단축키가 막힌다.
assert 'event.key !== "Enter"' in script
assert "R : 반려 사유 선택" in script
def test_operator_gui_only_treats_digits_as_reject_reasons_inside_the_prompt():
# 모드 밖에서 숫자에 의미를 주면 잘못 누른 키 하나로 반려가 나간다.
script = _read_app()
assert "isRejectPromptOpen" in script
prompt_guard = script.index("if (isRejectPromptOpen) {")
digit_branch = script.index('if (key >= "1" && key <= "9") {')
assert prompt_guard < digit_branch, "숫자 처리는 반드시 모드 가드 안에 있어야 한다"
def test_operator_gui_numbers_the_memo_presets_to_match_the_shortcuts():
# 화면의 번호와 눌러야 할 키가 다르면 단축키는 외워야 하는 것이 된다.
script = _read_app()
styles = _read(STYLES)
assert "memo-preset-key" in script
assert ".memo-preset-key" in styles
assert "index + 1" in script
def test_operator_gui_shows_files_the_intake_could_not_import():
# codex F6 (2026-09-23): two applicants' photo.jpg / photo.png became one case.
script = _read_app()
styles = _read(STYLES)
assert "intakeConflicts" in script
assert "심사 건으로 들어가지 못한 파일" in script
assert ".intake-conflicts" in styles
def test_operator_gui_renders_an_empty_band_rate_as_no_measurement():
script = _read_app()
assert '"측정 불가"' in script

View file

@ -65,7 +65,10 @@ def test_detailed_review_groups_operator_evidence_and_actions():
assert len(review["evidence_groups"]["naver"]) == 1 assert len(review["evidence_groups"]["naver"]) == 1
assert len(review["evidence_groups"]["google"]) == 1 assert len(review["evidence_groups"]["google"]) == 1
assert len(review["evidence_groups"]["llm"]) == 1 assert len(review["evidence_groups"]["llm"]) == 1
assert len(review["evidence_groups"]["failures"]) == 1 # A provider that was skipped by policy is operational status, not an error:
# it must not be presented alongside genuine failures.
assert len(review["evidence_groups"]["skipped"]) == 1
assert "failures" not in review["evidence_groups"]
def test_missing_analysis_returns_unavailable_review_model(): def test_missing_analysis_returns_unavailable_review_model():

View file

@ -56,7 +56,9 @@ def test_enrichment_adds_naver_evidence_llm_summary_and_score():
assert summary.executed_searches == 3 assert summary.executed_searches == 3
assert any(item.source == EvidenceSource.NAVER_SEARCH for item in latest.evidence) assert any(item.source == EvidenceSource.NAVER_SEARCH for item in latest.evidence)
assert any(item.source == EvidenceSource.LLM_SUMMARY for item in latest.evidence) assert any(item.source == EvidenceSource.LLM_SUMMARY for item in latest.evidence)
assert latest.score.score >= 35 # 리스코어가 돌았고 네이버 축이 점수에 들어갔다 (라벨 9 + 승격 상한 15).
assert any(item["key"] == "naver" for item in latest.score.breakdown["components"])
assert latest.score.score == 24
def test_enrichment_is_idempotent_for_same_query_signature(): def test_enrichment_is_idempotent_for_same_query_signature():

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.domain.records import Evidence, EvidenceSource
from rights_filter.server.store_serialization import _evidence_id
def test_llm_summary_cites_source_urls_and_evidence_ids(): def test_llm_summary_cites_source_urls_and_evidence_ids():
@ -45,3 +52,95 @@ def test_llm_failure_becomes_enrichment_failure_evidence():
assert evidence.source == EvidenceSource.ENRICHMENT_FAILURE assert evidence.source == EvidenceSource.ENRICHMENT_FAILURE
assert "LLM assistance failed" in evidence.reason 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
def test_evidence_prompt_lines_name_the_publishing_host_not_the_api():
# The whitelist is part of the prompt even though it lives in another module.
# Every line used to read domain=google, so the model could not say where an
# image was found - the one thing a rights reviewer most needs from it. A
# membership assertion on PROMPT_DATA_FIELDS would not have caught a rendered
# line that still said "domain".
from rights_filter.analysis.llm_assistance import _evidence_prompt
prompt = _evidence_prompt(
[
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Google custom web search result found",
confidence=0.35,
data={
"provider": "google_custom_search",
"domain": "google_custom_search",
"host": "blog.example.test",
"match": "page",
},
)
]
)
assert "host=blog.example.test" in prompt
assert "domain=" not in prompt

View file

@ -0,0 +1,265 @@
"""Structured evidence reading: the model cites, and the citation is checked.
The assistant no longer returns free prose. It returns claims that each carry
the evidence labels they rest on, and a claim whose labels were invented is
dropped before it can reach the operator. These tests pin that boundary, the
prompt input cap, and the field whitelist.
"""
import json
import pytest
from rights_filter.analysis.llm_assistance import (
MAX_PROMPT_EVIDENCE,
InternalLlmAssistant,
OllamaGenerateLlmClient,
_evidence_prompt,
_select_prompt_evidence,
)
from rights_filter.analysis.llm_prompts import (
CLAIM_TEXT_PLACEHOLDER,
NEEDS_CHECK_PLACEHOLDER,
)
from rights_filter.domain.records import Evidence, EvidenceSource
class _ScriptedTransport:
"""Returns a canned Ollama response and records the request payload."""
def __init__(self, document):
self.sent = {}
self._body = document if isinstance(document, str) else json.dumps(document)
def request_json(self, method, url, payload=None, timeout=None):
self.sent = dict(payload or {})
return {"response": self._body, "model": "qwen2.5:3b-instruct"}
def _client(document) -> tuple[OllamaGenerateLlmClient, _ScriptedTransport]:
transport = _ScriptedTransport(document)
return OllamaGenerateLlmClient(transport=transport), transport
def _web(match="full", url="https://example.test/a", **data):
return Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Web detection match",
confidence=0.9,
data={"match": match, "result_url": url, "evidence_id": "ev-1", **data},
)
# --- citation verification ---------------------------------------------------
def test_claim_citing_real_evidence_survives():
client, _ = _client(
{"claims": [{"text": "동일 이미지가 외부 페이지에 있습니다.", "evidence": ["E1"]}],
"needs_check": ["출처 페이지의 라이선스 표기"]}
)
result = client.summarize_evidence_detailed([_web()])
assert result.dropped_claims == 0
assert len(result.claims) == 1
assert result.claims[0]["evidence"] == ["E1"]
assert "동일 이미지가 외부 페이지에 있습니다." in result.text
assert "출처 페이지의 라이선스 표기" in result.text
def test_claim_citing_an_invented_label_is_dropped():
# The failure mode that matters: a confident sentence attached to evidence
# that was never shown to the model.
client, _ = _client(
{"claims": [
{"text": "실제 근거.", "evidence": ["E1"]},
{"text": "지어낸 근거에 붙은 단정.", "evidence": ["E9"]},
]}
)
result = client.summarize_evidence_detailed([_web()])
assert result.dropped_claims == 1
assert [claim["text"] for claim in result.claims] == ["실제 근거."]
assert "지어낸" not in result.text
def test_claim_that_echoes_the_shape_example_is_dropped():
# Observed with qwen2.5:0.5b against real evidence: it returned the
# placeholder as the claim text while citing every real label, so citation
# checking alone passed it as perfectly grounded and completely empty.
client, _ = _client(
{"claims": [{"text": CLAIM_TEXT_PLACEHOLDER, "evidence": ["E1"]}],
"needs_check": [NEEDS_CHECK_PLACEHOLDER]}
)
result = client.summarize_evidence_detailed([_web()])
assert result.claims == ()
assert result.dropped_claims == 1
assert result.needs_check == ()
assert CLAIM_TEXT_PLACEHOLDER not in result.text
def test_any_angle_bracket_placeholder_is_dropped():
client, _ = _client({"claims": [{"text": "<summary here>", "evidence": ["E1"]}]})
result = client.summarize_evidence_detailed([_web()])
assert result.claims == ()
def test_claim_without_any_citation_is_dropped():
client, _ = _client({"claims": [{"text": "근거 없는 단정.", "evidence": []}]})
result = client.summarize_evidence_detailed([_web()])
assert result.dropped_claims == 1
assert result.claims == ()
assert "근거 없는 단정" not in result.text
def test_unparseable_output_yields_no_claims_and_no_prose():
# A model that ignores the format contract must not have its prose shown as
# if it had been checked.
client, _ = _client("I think this image is fine, it looks authentic.")
result = client.summarize_evidence_detailed([_web()])
assert result.claims == ()
assert "authentic" not in result.text
def test_json_wrapped_in_prose_is_still_read():
client, _ = _client(
'Sure! {"claims": [{"text": "근거 있음.", "evidence": ["E1"]}]} hope that helps'
)
result = client.summarize_evidence_detailed([_web()])
assert len(result.claims) == 1
def test_partially_valid_citation_list_keeps_only_real_labels():
client, _ = _client(
{"claims": [{"text": "혼합 인용.", "evidence": ["E1", "E42"]}]}
)
result = client.summarize_evidence_detailed([_web()])
assert result.claims[0]["evidence"] == ["E1"]
# --- prompt input boundary ---------------------------------------------------
def test_prompt_is_capped_and_keeps_the_rows_that_carry_risk():
scoring = [_web(match="full", url=f"https://example.test/{index}") for index in range(4)]
noise = [
Evidence(EvidenceSource.SEARCH_SKIPPED, "quota reached", 0.0, {})
for _ in range(MAX_PROMPT_EVIDENCE + 10)
]
selected = _select_prompt_evidence(noise + scoring)
assert len(selected) == MAX_PROMPT_EVIDENCE
assert all(item in selected for item in scoring)
def test_prompt_excludes_unvetted_and_privacy_fields():
evidence = _web(
promotion_reason="이 값을 그대로 베끼면 요약이 증거 목록 복붙이 된다",
privacy_note="동일 인물 판정은 아닙니다",
crop_index=2,
thumbnail_url="https://example.test/thumb.jpg",
page_title="허용되는 제목",
)
prompt = _evidence_prompt([evidence])
assert "허용되는 제목" in prompt
assert "복붙" not in prompt
assert "privacy_note" not in prompt
assert "crop_index" not in prompt
assert "thumbnail_url" not in prompt
def test_prompt_labels_evidence_so_the_model_can_cite_it():
prompt = _evidence_prompt(_select_prompt_evidence([_web(), _web(url="https://b.test")]))
assert "[E1]" in prompt
assert "[E2]" in prompt
def test_llm_summary_evidence_is_never_fed_back_into_the_prompt():
prior = Evidence(EvidenceSource.LLM_SUMMARY, "prior summary", 0.0, {"summary": "이전 요약"})
selected = _select_prompt_evidence([prior, _web()])
assert all(item.source != EvidenceSource.LLM_SUMMARY for item in selected)
# --- request shape -----------------------------------------------------------
def test_request_pins_the_context_window_and_asks_for_json():
client, transport = _client({"claims": []})
client.summarize_evidence_detailed([_web()])
assert transport.sent["format"] == "json"
assert transport.sent["options"]["num_ctx"] == client.num_ctx
assert transport.sent["keep_alive"] == client.keep_alive
@pytest.mark.parametrize("field", ["grounded_claims", "dropped_claims"])
def test_assistant_records_how_much_survived_verification(field):
client, _ = _client(
{"claims": [
{"text": "진짜.", "evidence": ["E1"]},
{"text": "가짜.", "evidence": ["E7"]},
]}
)
evidence = InternalLlmAssistant(client).summarize("SUB-1", [_web()])
assert evidence.data[field] == 1
# --- linked sources must describe what the model read -------------------------
def _many(count):
return [_web(url=f"https://example.test/{index}") for index in range(count)]
def test_linked_sources_never_exceed_what_the_model_was_shown():
# The original defect: a 79-row case produced a summary read from 12 rows
# while the UI reported 78 "linked sources" and the policy check passed.
client, _ = _client({"claims": []})
evidence = _many(40)
result_evidence = InternalLlmAssistant(client).summarize("SUB-1", evidence)
assert len(result_evidence.data["source_evidence_ids"]) <= MAX_PROMPT_EVIDENCE
assert len(result_evidence.data["source_urls"]) <= MAX_PROMPT_EVIDENCE
def test_linked_sources_are_the_cited_rows_when_claims_verify():
client, _ = _client({"claims": [{"text": "근거 있음.", "evidence": ["E1"]}]})
result_evidence = InternalLlmAssistant(client).summarize("SUB-1", _many(20))
assert len(result_evidence.data["source_urls"]) == 1
assert result_evidence.data["verified"] is True
def test_verified_is_false_when_nothing_survived_citation_checking():
# "verified" used to mean "the case has evidence", which is not a statement
# about the summary at all.
client, _ = _client({"claims": [{"text": "지어냄.", "evidence": ["E99"]}]})
result_evidence = InternalLlmAssistant(client).summarize("SUB-1", _many(20))
assert result_evidence.data["verified"] is False

View file

@ -0,0 +1,182 @@
"""Provenance signals: rights metadata transcription + capture forensics.
Both ship as zero-point labels, so the tests that matter most are the ones about
what is NOT read (privacy whitelist) and what does NOT trigger (precision).
"""
from io import BytesIO
import numpy as np
import pytest
from PIL import Image
from rights_filter.analysis.provenance import (
MIN_CAPTURE_CATEGORIES,
RIGHTS_EXIF_TAGS,
detect_screen_capture,
extract_rights_metadata,
provenance_evidence,
)
from rights_filter.analysis.risk_scoring import RiskScorer
from rights_filter.domain.records import EvidenceSource
from rights_filter.governance.policies import assert_no_biometric_template
from rights_filter.server.store_serialization import (
_domain_evidence_from_ui,
_evidence_payload,
)
EXIF_COPYRIGHT = 0x8298
EXIF_ARTIST = 0x013B
EXIF_GPS_IFD = 0x8825
EXIF_BODY_SERIAL = 0xA431
EXIF_MAKE = 0x010F
EXIF_MODEL = 0x0110
def _jpeg(width=640, height=480, exif=None, color=128) -> bytes:
image = Image.new("RGB", (width, height), (color, color, color))
buffer = BytesIO()
if exif is None:
image.save(buffer, format="JPEG")
else:
image.save(buffer, format="JPEG", exif=exif)
return buffer.getvalue()
def _exif(tags):
exif = Image.Exif()
for tag, value in tags.items():
exif[int(tag)] = value
return exif
def _capture_frame(width=1280, height=720, subtitles=True, letterbox=True) -> bytes:
frame = np.full((height, width), 130, dtype=np.uint8)
# Mid-frame texture so the bottom band stands out against something.
frame[int(height * 0.2) : int(height * 0.6), :] = 120
if letterbox:
bar = int(height * 0.08)
frame[:bar, :] = 0
frame[-bar:, :] = 0
if subtitles:
# Blocky high-contrast glyph-like marks in the lower band.
row = int(height * 0.82)
for start in range(120, width - 120, 26):
frame[row : row + 22, start : start + 14] = 255
buffer = BytesIO()
Image.fromarray(frame).save(buffer, format="PNG")
return buffer.getvalue()
# --- rights metadata ---------------------------------------------------------
def test_reads_the_copyright_the_author_wrote_into_the_file():
content = _jpeg(exif=_exif({EXIF_COPYRIGHT: "(C) 2026 Example News"}))
assert extract_rights_metadata(content)["copyright"] == "(C) 2026 Example News"
def test_reads_artist_field():
content = _jpeg(exif=_exif({EXIF_ARTIST: "Hong Gildong"}))
assert extract_rights_metadata(content)["artist"] == "Hong Gildong"
def test_image_without_rights_fields_yields_nothing():
assert extract_rights_metadata(_jpeg()) == {}
def test_unreadable_content_is_no_metadata_not_an_error():
# .svg is an accepted upload suffix that Pillow cannot open.
assert extract_rights_metadata(b"<svg xmlns='http://www.w3.org/2000/svg'/>") == {}
assert extract_rights_metadata(b"") == {}
def test_privacy_fields_are_never_read():
# The whitelist is the privacy boundary: location and camera identity must
# not be extracted even when present in the file.
content = _jpeg(
exif=_exif(
{
EXIF_COPYRIGHT: "(C) 2026 Example",
EXIF_BODY_SERIAL: "SN-12345",
EXIF_MAKE: "ExampleCorp",
EXIF_MODEL: "X-100",
}
)
)
extracted = extract_rights_metadata(content)
# Only the rights field comes back — camera identity does not, even though
# it is sitting right next to it in the same EXIF block.
assert extracted == {"copyright": "(C) 2026 Example"}
# GPS is not written here (its tag is an IFD pointer, not a value), so pin it
# at the whitelist instead: the boundary is the constant, not the fixture.
for tag in (EXIF_GPS_IFD, EXIF_BODY_SERIAL, EXIF_MAKE, EXIF_MODEL):
assert tag not in RIGHTS_EXIF_TAGS
def test_rights_reason_marks_the_value_as_self_reported():
# Metadata is trivially forgeable; the reason must never read as clearance.
evidence = provenance_evidence(_jpeg(exif=_exif({EXIF_COPYRIGHT: "(C) Example"})))
assert "자기신고" in evidence[0].reason
# --- capture forensics -------------------------------------------------------
def test_letterboxed_broadcast_frame_with_subtitles_is_flagged():
result = detect_screen_capture(_capture_frame())
assert result
assert len(result["categories"]) >= MIN_CAPTURE_CATEGORIES
def test_plain_photograph_is_not_flagged():
assert detect_screen_capture(_jpeg(width=800, height=533)) == {}
def test_geometry_alone_does_not_trigger_the_label():
# A letterboxed 1280x720 frame is one observation stated twice. Counting it
# as two categories would let any cinematic crop trigger the label.
result = detect_screen_capture(_capture_frame(subtitles=False))
assert result == {}
def test_undecodable_content_is_not_flagged():
assert detect_screen_capture(b"not an image") == {}
# --- evidence, scoring, round trip -------------------------------------------
def test_provenance_evidence_scores_zero_but_still_surfaces_its_reason():
evidence = provenance_evidence(_jpeg(exif=_exif({EXIF_COPYRIGHT: "(C) Example"})))
result = RiskScorer().score(evidence)
assert result.score == 0
assert result.band == "low"
assert any("권리 표기" in reason for reason in result.reasons)
def test_provenance_evidence_survives_the_payload_round_trip():
evidence = provenance_evidence(_jpeg(exif=_exif({EXIF_COPYRIGHT: "(C) Example"})))[0]
restored = _domain_evidence_from_ui(_evidence_payload("SUB-1", evidence))
assert restored.source == EvidenceSource.PROVENANCE
assert restored.data["provenance_kind"] == "rights_metadata"
assert restored.data["rights_fields"]["copyright"] == "(C) Example"
assert RiskScorer().score([restored]).score == 0
@pytest.mark.parametrize("kind", ["rights_metadata", "screen_capture"])
def test_provenance_payloads_carry_no_biometric_fields(kind):
content = _jpeg(exif=_exif({EXIF_COPYRIGHT: "(C) x"})) if kind == "rights_metadata" else _capture_frame()
for evidence in provenance_evidence(content):
assert_no_biometric_template(_evidence_payload("SUB-1", evidence))

View file

@ -1,4 +1,13 @@
from rights_filter.analysis.risk_scoring import RiskScorer from rights_filter.analysis.risk_scoring import (
FACE_PERSON_BACKGROUND_POINTS,
FACE_PERSON_IDENTIFIABLE_POINTS,
FAILURE_FLOOR_POINTS,
NAVER_MAX_POINTS,
WEB_COPY_NEAR_POINTS,
WEB_ENTITY_MAX_POINTS,
WEB_PRESENCE_MAX_POINTS,
RiskScorer,
)
from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
@ -42,15 +51,19 @@ def test_operator_false_positive_evidence_is_not_scored():
assert result.reasons == [] assert result.reasons == []
def test_character_web_evidence_can_create_high_risk_for_ai_or_fanart(): def test_known_rights_subject_reaches_high_through_the_knowledge_base_not_labels():
scorer = RiskScorer() # A "character" or "celebrity" Vision entity cannot band a case by itself,
result = scorer.score( # because the adapter gives us nothing that separates it from "Dog" - see
[ # WEB_ENTITY_MAX_POINTS. The route to high for a subject we actually know
# about is a knowledge-base fingerprint match, which is not pooled. Both
# halves are asserted together so a later cap change cannot quietly remove
# the only remaining route out of the label pool.
labels_and_a_hosted_copy = [
Evidence( Evidence(
source=EvidenceSource.WEB_DETECTION, source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched character", reason="Web entity matched character",
confidence=0.94, confidence=0.94,
data={"entity": "유명 웹툰 캐릭터", "category": "character"}, data={"entity": "유명 웹툰 캐릭터", "match": "entity"},
), ),
Evidence( Evidence(
source=EvidenceSource.WEB_DETECTION, source=EvidenceSource.WEB_DETECTION,
@ -59,11 +72,24 @@ def test_character_web_evidence_can_create_high_risk_for_ai_or_fanart():
data={"url": "https://example.com/official-character"}, data={"url": "https://example.com/official-character"},
), ),
] ]
)
assert result.score >= 70 without_knowledge = RiskScorer().score(labels_and_a_hosted_copy)
assert result.band == "high" assert without_knowledge.band != "high"
assert "Web entity matched character" in result.reasons assert "Web entity matched character" in without_knowledge.reasons
with_knowledge = RiskScorer().score(
[
*labels_and_a_hosted_copy,
Evidence(
source=EvidenceSource.FINGERPRINT,
reason="지식DB 등록 이미지와 일치",
confidence=1.0,
data={"similarity": 0.95},
),
]
)
assert with_knowledge.band == "high"
assert "지식DB 등록 이미지와 일치" in with_knowledge.reasons
def test_failure_reason_does_not_lower_existing_high_risk_score(): def test_failure_reason_does_not_lower_existing_high_risk_score():
@ -100,10 +126,10 @@ def test_failure_reason_does_not_lower_existing_high_risk_score():
assert isinstance(with_failure, ScoreResult) assert isinstance(with_failure, ScoreResult)
def test_promoted_naver_search_evidence_can_raise_review_risk(): def test_promoted_naver_search_evidence_raises_risk_but_cannot_band_a_case_alone():
result = RiskScorer().score( # A promoted text-search hit says a NAMED query found results, not that the
[ # image itself was found. It orders cases inside a band; it does not make one.
Evidence( promoted = Evidence(
source=EvidenceSource.NAVER_SEARCH, source=EvidenceSource.NAVER_SEARCH,
reason="Naver result linked named work", reason="Naver result linked named work",
confidence=0.8, confidence=0.8,
@ -112,12 +138,16 @@ def test_promoted_naver_search_evidence_can_raise_review_risk():
"promotion_reason": "named person/work search evidence", "promotion_reason": "named person/work search evidence",
}, },
) )
]
alone = RiskScorer().score([promoted])
with_face = RiskScorer().score(
[promoted, Evidence(EvidenceSource.FACE_PERSON, "Identifiable person", 0.9, {"tier": "identifiable"})]
) )
assert result.band == "medium" assert 0 < alone.score <= NAVER_MAX_POINTS
assert result.score >= 35 assert alone.band == "low"
assert "Naver result linked named work" in result.reasons assert "Naver result linked named work" in alone.reasons
assert with_face.score == FACE_PERSON_IDENTIFIABLE_POINTS + alone.score
def test_llm_summary_does_not_directly_affect_score(): def test_llm_summary_does_not_directly_affect_score():
@ -196,11 +226,12 @@ def test_score_of_exactly_30_is_medium_not_low():
def test_score_just_below_70_is_medium(): def test_score_just_below_70_is_medium():
# Face (+35) + weak fingerprint (+30) = 65. # Face (+30) + weak knowledge match (+30) + one 0.5 label (+5) = 65.
result = RiskScorer().score( result = RiskScorer().score(
[ [
Evidence(source=EvidenceSource.FACE_PERSON, reason="Face/person detected", confidence=0.8, data={"face_count": 1}), Evidence(source=EvidenceSource.FACE_PERSON, reason="Face/person detected", confidence=0.8, data={"face_count": 1}),
_fingerprint(0.5), _fingerprint(0.5),
_label("Dog", 0.5),
] ]
) )
assert result.score == 65 assert result.score == 65
@ -208,11 +239,12 @@ def test_score_just_below_70_is_medium():
def test_score_of_exactly_70_is_high_not_medium(): def test_score_of_exactly_70_is_high_not_medium():
# Face (+35) + partial web detection (+35) = 70 — the high boundary. # Face (+30) + full web copy (+40) = 70 — the high boundary, and the most
# common real case (a published photo of an identifiable person).
result = RiskScorer().score( result = RiskScorer().score(
[ [
Evidence(source=EvidenceSource.FACE_PERSON, reason="Face/person detected", confidence=0.8, data={"face_count": 1}), Evidence(source=EvidenceSource.FACE_PERSON, reason="Face/person detected", confidence=0.8, data={"face_count": 1}),
Evidence(source=EvidenceSource.WEB_DETECTION, reason="Partial match", confidence=0.9, data={"match": "partial"}), Evidence(source=EvidenceSource.WEB_DETECTION, reason="Full match", confidence=0.9, data={"match": "full"}),
] ]
) )
assert result.score == 70 assert result.score == 70
@ -220,7 +252,7 @@ def test_score_of_exactly_70_is_high_not_medium():
def test_score_is_clamped_to_100(): def test_score_is_clamped_to_100():
# Strong fingerprint (+80) + full web detection (+45) = 125 -> clamped to 100. # Registered match (+80) + full web copy (+40) = 120 -> clamped to 100.
result = RiskScorer().score( result = RiskScorer().score(
[ [
_fingerprint(0.95), _fingerprint(0.95),
@ -231,31 +263,17 @@ def test_score_is_clamped_to_100():
assert result.band == "high" assert result.band == "high"
def test_two_distinct_web_entities_stack_into_high_risk(): def test_two_distinct_web_entities_are_two_signals_even_though_pooled():
# Two different named entities in one image are independent signals and must # Two different named entities in one image are independent signals and must
# not dedup each other away. Regression: both collapsed into one "entity" # not dedup each other away. Regression: both collapsed into one "entity"
# bucket, dropping the second +45 and under-scoring a high image to medium. # bucket, dropping the second entirely. The pool caps their combined weight
result = RiskScorer().score( # (they are still bare labels), but each has to surface as its own reason -
[ # that is the only place the operator sees there were two.
Evidence( result = RiskScorer().score([_label("캐릭터 A", 0.94), _label("유명인 B", 0.94)])
source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched character A",
confidence=0.94,
data={"entity": "캐릭터 A", "category": "character"},
),
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched celebrity B",
confidence=0.94,
data={"entity": "유명인 B", "category": "celebrity"},
),
]
)
assert result.score >= 70 assert result.score == WEB_ENTITY_MAX_POINTS
assert result.band == "high" assert "Web entity matched 캐릭터 A" in result.reasons
assert "Web entity matched character A" in result.reasons assert "Web entity matched 유명인 B" in result.reasons
assert "Web entity matched celebrity B" in result.reasons
def test_same_web_entity_repeated_does_not_double_count(): def test_same_web_entity_repeated_does_not_double_count():
@ -267,16 +285,682 @@ def test_same_web_entity_repeated_does_not_double_count():
source=EvidenceSource.WEB_DETECTION, source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched character A", reason="Web entity matched character A",
confidence=0.94, confidence=0.94,
data={"entity": "캐릭터 A", "category": "character"}, data={"entity": "캐릭터 A", "match": "entity"},
), ),
Evidence( Evidence(
source=EvidenceSource.WEB_DETECTION, source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched character A (again)", reason="Web entity matched character A (again)",
confidence=0.94, confidence=0.94,
data={"entity": "캐릭터 A", "category": "character"}, data={"entity": "캐릭터 A", "match": "entity"},
), ),
] ]
) )
assert result.score == 45 assert result.score == 9
assert result.reasons == ["Web entity matched character A"]
def _label(name: str, confidence: float) -> Evidence:
# A bare Google Vision web entity: descriptive, no category, no image match.
return Evidence(
source=EvidenceSource.WEB_DETECTION,
reason=f"Web entity matched {name}",
confidence=confidence,
data={"entity": name, "match": "entity"},
)
def test_uncategorized_web_entities_cannot_band_an_image_on_their_own():
# Vision returns up to maxResults labels for EVERY image, so per-label
# scoring with a per-name bucket stacked without bound: three 0.8-confidence
# labels reached 100/high with zero image matches. Their pooled contribution
# is now capped below the medium threshold.
result = RiskScorer().score(
[
_label("Dog", 0.8),
_label("Pet", 0.8),
_label("Grass", 0.8),
_label("Canidae", 0.8),
_label("Snout", 0.8),
]
)
assert result.score == WEB_ENTITY_MAX_POINTS
assert result.band == "low"
# Capping the weight must not hide the labels from the operator.
assert len(result.reasons) == 5
def test_original_photo_found_nowhere_on_the_web_stays_low():
# The inverse failure of the same defect: a photo that exists nowhere online
# scored 66/medium purely from what Vision thought it depicted.
result = RiskScorer().score(
[
_label("Dog", 0.6),
_label("Pet", 0.55),
_label("Grass", 0.5),
]
)
assert result.band == "low"
def test_a_category_key_does_not_buy_an_entity_out_of_the_pool():
# Guards the removal of an escape hatch that shipped dead: the old branch
# read a `category` key nothing in src/ ever wrote (Vision entity rows are
# {entity, entity_id, match, provider}), so it read as though celebrities
# were safe from the cap while no real image could reach it. If a writer is
# ever added it needs its own scoring decision, and this test to change with
# it - not a branch that silently does nothing.
categorized = RiskScorer().score(
[
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Web entity matched celebrity B",
confidence=0.94,
data={"entity": "유명인 B", "category": "celebrity"},
),
_label("Portrait", 0.9),
]
)
bare = RiskScorer().score([_label("유명인 B", 0.94), _label("Portrait", 0.9)])
assert categorized.score == bare.score == WEB_ENTITY_MAX_POINTS
def _presence_set() -> list[Evidence]:
# What one Vision response looks like for a widely published image: the same
# fact reported through all four match types at once.
return [
Evidence(EvidenceSource.WEB_DETECTION, "Full match", 0.9, {"match": "full"}),
Evidence(EvidenceSource.WEB_DETECTION, "Partial match", 0.8, {"match": "partial"}),
Evidence(EvidenceSource.WEB_DETECTION, "Page hosts image", 0.8, {"match": "page"}),
Evidence(EvidenceSource.WEB_DETECTION, "Visually similar", 0.6, {"match": "visual"}),
]
def test_web_presence_alone_cannot_reach_the_high_band():
# Summed, the four match types once reached 125 -> clamped to 100, so any
# image with a web presence was indistinguishable from a clear infringement.
# Existence on the web is not itself infringement.
result = RiskScorer().score(_presence_set())
assert result.score == WEB_PRESENCE_MAX_POINTS
assert result.band == "medium" assert result.band == "medium"
assert len(result.reasons) == 4
def test_web_presence_plus_a_second_signal_kind_reaches_high():
# The pool bounds one kind of signal, not the case: a hosted copy of a photo
# of an identifiable person is still high.
result = RiskScorer().score(
[
*_presence_set(),
Evidence(EvidenceSource.FACE_PERSON, "Identifiable person", 0.9, {"tier": "identifiable"}),
]
)
assert result.score == WEB_PRESENCE_MAX_POINTS + FACE_PERSON_IDENTIFIABLE_POINTS
assert result.band == "high"
def _skipped(reason: str, provider: str = "naver") -> Evidence:
# What integrations/naver_search.py emits when the search policy refuses.
data = {"provider": provider, "query": "q"} if provider else {}
return Evidence(EvidenceSource.SEARCH_SKIPPED, reason, 1.0, data)
def test_a_quota_skip_lifts_the_case_to_the_review_floor_and_says_which_provider():
# 2026-07-11 #16 kept skips out of the reasons because they scored zero.
# 2026-09-22 real run: naver's daily limit is 100 calls = 20 images, and the
# 21st image scored LOWER than the 20th with nothing telling the operator why.
# A provider that did not run is the same gap as one that failed.
result = RiskScorer().score([_skipped("search API usage limit reached")])
assert result.score == FAILURE_FLOOR_POINTS
assert result.band == "medium"
assert result.reasons == ["naver 미수행 — search API usage limit reached"]
def test_a_disabled_provider_stays_silent_because_the_console_already_shows_it():
# "disabled" is the refusal both for a provider with no key in this
# deployment and for the emergency stop; either is visible as provider state
# and applies to every case alike while it lasts. Flooring it would make a
# keyless deployment's whole queue medium.
vision_stop = Evidence(EvidenceSource.EXTERNAL_SKIPPED, "external API disabled", 1.0, {})
result = RiskScorer().score([vision_stop, _skipped("search API disabled")])
assert result.score == 0
assert result.reasons == []
def test_a_skip_does_not_add_to_a_case_already_above_the_floor():
result = RiskScorer().score([_search_result_copy(1.0), _skipped("search API usage limit reached")])
assert result.score == WEB_PRESENCE_MAX_POINTS
assert "naver 미수행 — search API usage limit reached" in result.reasons
assert all(item["key"] != "failure" for item in result.breakdown["components"])
def test_a_governance_skip_that_applies_to_every_case_stays_silent():
# Compliance not approved / metadata logging not accepted / online mode not
# allowed are deployment-wide switches, not per-case gaps: flooring them
# would make the low band unreachable by configuration.
for reason in (
"external API compliance not approved",
"metadata logging not accepted",
"online synchronous mode not allowed",
):
result = RiskScorer().score([_skipped(reason, provider="")])
assert result.score == 0
assert result.reasons == []
def _vision(reason: str, match: str, confidence: float) -> Evidence:
return Evidence(
source=EvidenceSource.WEB_DETECTION,
reason=reason,
confidence=confidence,
data={"match": match, "provider": "google", "url": "https://vision.example.test/a.jpg"},
)
def _custom_search_page(reason: str) -> Evidence:
# What integrations/google_custom_search.py:_map_web_response actually emits.
return Evidence(
source=EvidenceSource.WEB_DETECTION,
reason=reason,
confidence=0.35,
data={
"match": "page",
"provider": "google_custom_search",
"search_type": "web",
"result_url": "https://blog.example.test/post/1",
},
)
def test_one_providers_correlated_match_types_stay_pooled():
# The pool exists for exactly this: one Vision response reporting one fact
# ("this image is on the web") through four correlated views.
result = RiskScorer().score(
[
_vision("Full match", "full", 0.9),
_vision("Partial match", "partial", 0.8),
_vision("Page hosts image", "page", 0.8),
_vision("Visually similar", "visual", 0.6),
]
)
assert result.score == WEB_PRESENCE_MAX_POINTS
assert result.band == "medium"
def test_two_providers_corroborating_a_published_copy_is_still_web_presence_only():
# v3 let a second provider's page hit stack on Vision's pool, which is how
# web-only evidence reached high (13/24 web-only grid combinations at v4).
# v5: the corroboration is visible in the raw pool and in the reasons, but
# "on the web" is one fact however many providers report it - it takes a
# second KIND of signal to reach high.
result = RiskScorer().score(
[_vision("Full match", "full", 0.9), _custom_search_page("Google custom web search result found")]
)
assert result.score == WEB_PRESENCE_MAX_POINTS
assert result.band == "medium"
assert "Google custom web search result found" in result.reasons
pool = next(item for item in result.breakdown["components"] if item["key"] == "web_presence")
assert pool["raw"] == 40 + WEB_COPY_NEAR_POINTS
def test_a_custom_search_page_hit_is_not_deduped_by_a_vision_page_row():
# Regression: the dedup bucket was the bare match type, so a Google custom
# search page hit landed in the same "page" bucket Vision had already filled
# and was dropped whole - no points, and no reason line telling the operator
# a second provider had found the image.
result = RiskScorer().score(
[_vision("Page hosts image", "page", 0.8), _custom_search_page("Google custom web search result found")]
)
assert "Google custom web search result found" in result.reasons
pool = next(item for item in result.breakdown["components"] if item["key"] == "web_presence")
assert pool["raw"] == 2 * WEB_COPY_NEAR_POINTS
def test_legacy_web_row_without_a_match_type_still_counts_as_presence():
# Rows written before every producer set `match`. They must not be dropped
# to zero just because the current producers describe themselves better.
legacy = Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Matching image URL found",
confidence=0.5,
data={"url": "https://legacy.example.test/a.jpg"},
)
result = RiskScorer().score([legacy])
assert result.score == WEB_COPY_NEAR_POINTS
assert result.band == "medium"
def _published_on(
host: str, match: str = "full", confidence: float = 0.9, provider: str = "google"
) -> Evidence:
return Evidence(
source=EvidenceSource.WEB_DETECTION,
reason=f"{host} 에서 발견",
confidence=confidence,
data={"match": match, "provider": provider, "host": host},
)
def test_a_trusted_host_row_scores_nothing_but_still_explains_itself():
# 자사·계약처 도메인에서 발견된 것은 "웹에 있다"가 아니다. 다만 증거 목록에는
# 그 행이 그대로 보이므로, 왜 점수가 조용한지 사유로 말해줘야 한다.
result = RiskScorer(frozenset({"posar.info"})).score([_published_on("posar.info")])
assert result.score == 0
assert result.band == "low"
assert any("신뢰 출처" in reason for reason in result.reasons)
def test_trust_is_applied_per_row_not_per_case():
# 우리 사이트에도 있고 스크래퍼 블로그에도 있으면, 후자는 그대로 세야 한다.
# 실제 형태: Vision 이 우리 사본을, CSE 가 블로그 게시를 따로 찾는다.
evidence = [
_published_on("posar.info"),
_published_on("blog.example.test", "page", 0.35, provider="google_custom_search"),
]
trusted = RiskScorer(frozenset({"posar.info"})).score(evidence)
untrusted = RiskScorer().score(evidence)
assert untrusted.score == WEB_PRESENCE_MAX_POINTS
assert trusted.score == WEB_COPY_NEAR_POINTS
assert "blog.example.test 에서 발견" in trusted.reasons
def test_subdomains_of_a_trusted_host_belong_to_the_same_party():
# 계약처의 CDN 은 여전히 그 계약처다.
result = RiskScorer(frozenset({"posar.info"})).score([_published_on("cdn.posar.info")])
assert result.score == 0
def test_a_lookalike_domain_is_not_trusted():
# posar.info.evil.test 는 접미사가 아니라 다른 도메인이다.
result = RiskScorer(frozenset({"posar.info"})).score([_published_on("posar.info.evil.test")])
assert result.score == WEB_PRESENCE_MAX_POINTS
def test_entries_that_are_not_hostnames_are_ignored():
# "com" 하나가 웹 전체를 신뢰 처리하면 안 된다.
evidence = [_published_on("posar.info")]
assert RiskScorer(frozenset({"com"})).score(evidence).score == WEB_PRESENCE_MAX_POINTS
assert RiskScorer(frozenset({"", " ", "posar info"})).score(evidence).score == WEB_PRESENCE_MAX_POINTS
def test_a_fresh_custom_search_row_resolves_its_host_from_the_provider_field():
# 신선한 CSE 행에는 host 키가 없고 displayLink 가 domain 으로 들어온다.
fresh = Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Google custom web search result found",
confidence=0.35,
data={
"match": "page",
"provider": "google_custom_search",
"domain": "news.posar.info",
"result_url": "https://news.posar.info/article",
},
)
assert RiskScorer(frozenset({"posar.info"})).score([fresh]).score == 0
def test_trust_does_not_touch_entity_rows():
# 엔티티 라벨은 "어디에 떠 있나"가 아니라 "무엇이 찍혔나"다.
result = RiskScorer(frozenset({"posar.info"})).score([_label("강아지", 0.9)])
assert result.score == 9
def test_breakdown_names_what_each_kind_contributed():
result = RiskScorer().score(
[
Evidence(EvidenceSource.FINGERPRINT, "지식DB 일치", 1.0, {"similarity": 0.95}),
Evidence(EvidenceSource.FACE_PERSON, "식별 가능한 얼굴", 1.0, {"tier": "identifiable"}),
]
)
points = {item["key"]: item["points"] for item in result.breakdown["components"]}
assert points == {"knowledge": 80, "face": FACE_PERSON_IDENTIFIABLE_POINTS}
assert sum(points.values()) == result.breakdown["total"]
def test_breakdown_shows_what_a_pool_ceiling_swallowed():
# 운영자가 보는 증거는 4행인데 점수는 40이다. 왜 110이 아닌지 말해주지 않으면
# 숫자를 믿을 수 없다.
result = RiskScorer().score(
[
_vision("Full match", "full", 0.9),
_vision("Partial match", "partial", 0.8),
_vision("Page hosts image", "page", 0.8),
_vision("Visually similar", "visual", 0.6),
]
)
pool = next(item for item in result.breakdown["components"] if item["key"] == "web_presence")
assert pool["points"] == WEB_PRESENCE_MAX_POINTS
assert pool["raw"] == 40 + 30 + 30 + 10
assert pool["cap"] == WEB_PRESENCE_MAX_POINTS
def test_breakdown_reports_the_hundred_point_ceiling():
# 100 에 걸린 케이스들은 서로 구별되지 않는다 - 그 사실이 정보다.
result = RiskScorer().score(
[
Evidence(EvidenceSource.FINGERPRINT, "지식DB 일치", 1.0, {"similarity": 0.95}),
Evidence(EvidenceSource.FACE_PERSON, "식별 가능한 얼굴", 1.0, {"tier": "identifiable"}),
]
)
assert result.score == 100
assert result.breakdown["total"] == 80 + FACE_PERSON_IDENTIFIABLE_POINTS
assert result.breakdown["clamped"] is True
def test_breakdown_counts_rows_dropped_for_a_trusted_host():
result = RiskScorer(frozenset({"posar.info"})).score(
[_published_on("posar.info"), _published_on("cdn.posar.info", "page")]
)
assert result.breakdown["excluded"] == [{"label": "신뢰 출처 posar.info", "rows": 2}]
assert result.breakdown["components"] == []
def test_breakdown_total_always_matches_the_score_before_clamping():
# 분해가 점수와 다른 이야기를 하면 분해가 아니라 또 하나의 주장이 된다.
for evidence in (
[_published_on("a.test"), _label("강아지", 0.9)],
[_vision("Full match", "full", 0.9), _custom_search_page("CSE")],
[Evidence(EvidenceSource.NAVER_SEARCH, "승격", 0.8, {"promoted": True})],
[_face(FACE_PERSON_BACKGROUND_POINTS), _failure()],
[_search_result_copy(1.0), _face(FACE_PERSON_IDENTIFIABLE_POINTS), _label("강아지", 0.9)],
):
result = RiskScorer().score(evidence)
assert result.breakdown["final"] == result.score
assert sum(item["points"] for item in result.breakdown["components"]) == result.breakdown["total"]
# --- v5: 축 예산 총합 ----------------------------------------------------------
def _face(points: int) -> Evidence:
tier = "identifiable" if points == FACE_PERSON_IDENTIFIABLE_POINTS else "background"
return Evidence(EvidenceSource.FACE_PERSON, f"{tier} person", 0.9, {"tier": tier})
def _failure() -> Evidence:
return Evidence(EvidenceSource.FAILURE, "External API failed: timeout", 1.0, {"provider": "naver"})
def _search_result_copy(similarity: float, url: str = "https://blog.example.test/post/1.jpg") -> Evidence:
# What store_search_candidates writes when a search result's image matches
# the submission: the result url plus the search hit it came from.
return Evidence(
EvidenceSource.FINGERPRINT,
f"Search result image similarity {similarity:.2f}",
similarity,
{"similarity": similarity, "url": url, "result_url": url, "source_evidence_ids": ["ev-1"], "provider": "naver"},
)
def _promoted(query: str, confidence: float = 1.0) -> Evidence:
return Evidence(EvidenceSource.NAVER_SEARCH, f"promoted {query}", confidence, {"promoted": True, "query": query})
def test_a_search_result_copy_is_web_presence_not_a_registered_match():
# 2026-09-22 실측: 실이미지 20장 전부 100점. 검색 결과 사본 유사도가 행마다 +80
# 이었고 한 장에 평균 2.7행이 붙었다. 사본은 "웹에 있다"의 가장 강한 뷰지
# 운영자가 등록한 권리 대상이 아니다 - 웹 존재 풀에 들어가고 단독으로는 medium.
one = RiskScorer().score([_search_result_copy(1.0)])
three = RiskScorer().score([_search_result_copy(1.0, f"https://x.test/{i}.jpg") for i in range(3)])
assert one.score == WEB_PRESENCE_MAX_POINTS
assert one.band == "medium"
assert [item["key"] for item in one.breakdown["components"]] == ["web_presence"]
assert three.score == one.score
assert next(item for item in three.breakdown["components"] if item["key"] == "web_presence")["raw"] == 120
def test_a_search_result_copy_and_a_vision_full_match_are_one_fact():
result = RiskScorer().score([_search_result_copy(1.0), _vision("Full match", "full", 0.9)])
assert result.score == WEB_PRESENCE_MAX_POINTS
pool = next(item for item in result.breakdown["components"] if item["key"] == "web_presence")
assert pool["raw"] == 80
def test_a_search_result_copy_ranks_by_how_close_the_copy_is():
exact = RiskScorer().score([_search_result_copy(1.0)]).score
near = RiskScorer().score([_search_result_copy(0.93)]).score
loose = RiskScorer().score([_search_result_copy(0.85)]).score
assert exact > near > loose > 0
def test_a_search_result_copy_on_a_trusted_host_is_excluded_like_any_other_row():
result = RiskScorer(frozenset({"posar.info"})).score([_search_result_copy(1.0, "https://cdn.posar.info/a.jpg")])
assert result.score == 0
assert result.breakdown["excluded"] == [{"label": "신뢰 출처 posar.info", "rows": 1}]
def test_a_row_with_only_a_similarity_is_read_as_a_registered_match():
# Older callers (and the diagram gate) write knowledge matches without the
# knowledge_entry_id; they must not be demoted into the web pool.
result = RiskScorer().score([_fingerprint(0.95)])
assert result.score == 80
assert [item["key"] for item in result.breakdown["components"]] == ["knowledge"]
def test_every_axis_but_a_registered_match_sums_below_the_ceiling():
# 예산의 존재 이유: 웹에 있는 이미지들 사이에 순서가 남아야 한다. 등록 일치가
# 없는 한 모든 축이 최대여도 100 에 닿지 않는다.
result = RiskScorer().score(
[
_search_result_copy(1.0),
_vision("Full match", "full", 0.9),
_custom_search_page("CSE"),
_face(FACE_PERSON_IDENTIFIABLE_POINTS),
_promoted("q1"),
_promoted("q2"),
_label("A", 1.0),
_label("B", 1.0),
_label("C", 1.0),
]
)
assert result.score == 95
assert result.band == "high"
assert result.breakdown["clamped"] is False
def test_web_signals_of_every_kind_cannot_reach_high_without_a_person_or_a_registered_match():
result = RiskScorer().score(
[_search_result_copy(1.0), _vision("Full match", "full", 0.9), _promoted("q1"), _label("A", 1.0)]
)
assert result.score == 65
assert result.band == "medium"
def test_promoted_hits_from_distinct_queries_pool_under_one_ceiling():
result = RiskScorer().score([_promoted("q1"), _promoted("q2"), _promoted("q3")])
assert result.score == NAVER_MAX_POINTS
assert len(result.reasons) == 3
# --- v7: 주의 후보는 "다시 보라"지 "알려진 권리 대상"이 아니다 -----------------------
def _knowledge_match(similarity: float, status: str) -> Evidence:
# What internal_analyzer emits for an active knowledge entry, watchlist or confirmed.
return Evidence(
EvidenceSource.FINGERPRINT,
f"{'주의 후보' if status == 'watchlist' else 'Knowledge base'} image similarity {similarity:.2f}",
similarity,
{"knowledge_entry_id": "kb-1", "knowledge_entry_status": status, "similarity": similarity},
)
def test_an_unconfirmed_watchlist_match_earns_a_review_not_a_high_band():
# codex F1 (2026-09-23): a hold taken because the search was down created a
# watchlist entry, and the next submission of the same image scored the
# confirmed 80 - "could not check" came back as "known rights subject".
watchlist = RiskScorer().score([_knowledge_match(0.95, "watchlist")])
confirmed = RiskScorer().score([_knowledge_match(0.95, "confirmed")])
assert watchlist.score == 40
assert watchlist.band == "medium"
assert [item["key"] for item in watchlist.breakdown["components"]] == ["watchlist"]
assert confirmed.score == 80
assert confirmed.band == "high"
def test_a_watchlist_match_still_reaches_high_with_a_second_kind_of_signal():
result = RiskScorer().score([_knowledge_match(0.95, "watchlist"), _face(FACE_PERSON_IDENTIFIABLE_POINTS)])
assert result.score == 70
assert result.band == "high"
def test_a_weak_watchlist_match_is_a_footnote():
assert RiskScorer().score([_knowledge_match(0.6, "watchlist")]).score == 20
def test_has_coverage_gap_sees_failures_and_quota_skips_but_not_governance_switches():
from rights_filter.analysis.risk_scoring import has_coverage_gap
assert has_coverage_gap([_failure()]) is True
assert has_coverage_gap([_skipped("search API usage limit reached")]) is True
assert has_coverage_gap([_skipped("search API disabled")]) is False
assert has_coverage_gap([_search_result_copy(1.0)]) is False
# An operator who marked the failure row irrelevant has decided it is not a gap.
dismissed = Evidence(EvidenceSource.FAILURE, "External API failed", 1.0, {"operator_status": "irrelevant"})
assert has_coverage_gap([dismissed]) is False
# --- headline: 운영자가 첫 1초에 읽는 한 줄 ---------------------------------------
def _headline_of(evidence: list[Evidence], trusted: frozenset[str] = frozenset()) -> str:
return RiskScorer(trusted).score(evidence).breakdown["headline"]
def test_headline_compresses_the_reasons_into_one_korean_line_in_budget_order():
# 실 콘솔의 큐 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...` 였다.
evidence = [
_search_result_copy(1.0, "https://a.test/1.jpg"),
_search_result_copy(1.0, "https://b.test/1.jpg"),
_vision("Full match", "full", 0.9), # vision.example.test - a third place
_face(FACE_PERSON_IDENTIFIABLE_POINTS),
_promoted("q1"),
_promoted("q2"),
_label("A", 0.9),
_label("B", 0.9),
_label("C", 0.9),
]
assert _headline_of(evidence) == "웹 완전일치 사본 3곳 · 식별 가능 인물 · 이름 있는 검색 승격 2건 · 라벨 3개"
def test_headline_counts_places_not_rows():
# 같은 호스트에서 찾은 사본 3행은 1곳이다.
same_host = [_search_result_copy(1.0, f"https://a.test/{i}.jpg") for i in range(3)]
assert _headline_of(same_host) == "웹 완전일치 사본 1곳"
def test_headline_names_only_the_strongest_web_view():
assert _headline_of([_vision("Page hosts image", "page", 0.8)]) == "웹 부분·페이지 일치 1곳"
assert _headline_of([_vision("Visually similar", "visual", 0.6)]) == "웹 유사 이미지 1건"
assert _headline_of(_presence_set()) == "웹 완전일치 사본 1곳"
def test_headline_leads_with_a_registered_match_and_ends_with_the_gaps():
evidence = [
_fingerprint(0.95),
_face(FACE_PERSON_BACKGROUND_POINTS),
_failure(),
_skipped("search API usage limit reached"),
]
assert _headline_of(evidence) == "등록 권리 대상 일치 0.95 · 배경 인물 · 외부 검색 실패 · 일일 한도로 일부 미수행"
def test_headline_tells_a_watchlist_match_apart_from_a_registered_one():
assert _headline_of([_knowledge_match(0.95, "watchlist")]) == "주의 후보 일치 0.95"
assert _headline_of([_knowledge_match(0.95, "confirmed")]) == "등록 권리 대상 일치 0.95"
def test_headline_says_when_the_external_check_was_off_without_scoring_it():
# codex 3.5 (2026-09-23): a keyless / stopped deployment put unchecked images
# in the same low band as checked-and-clean ones. The score stays 0 (a floor
# would make low unreachable by configuration); the headline says why.
result = RiskScorer().score([_skipped("search API disabled")])
assert result.score == 0
assert result.breakdown["headline"] == "외부 검색 꺼짐"
assert _headline_of([_skipped("search API disabled"), _skipped("search API usage limit reached")]) == (
"일일 한도로 일부 미수행"
)
def test_headline_says_which_copies_are_on_news_or_scraper_hosts_without_scoring_them():
# Persona review 2026-09-25 (운영자 1-B): "4곳" did not say whether they were
# the original publisher or republications, so every link was opened anyway.
evidence = [
_search_result_copy(1.0, "https://news.example.kr/a.jpg"),
_search_result_copy(1.0, "https://cdn.news.example.kr/b.jpg"),
_search_result_copy(1.0, "https://scrape.example/c.jpg"),
_search_result_copy(1.0, "https://other.example/d.jpg"),
]
labels = {"news.example.kr": "언론·공식", "scrape.example": "무단 게시처"}
labelled = RiskScorer(host_labels=labels).score(evidence)
plain = RiskScorer().score(evidence)
assert labelled.breakdown["headline"] == "웹 완전일치 사본 4곳 (언론·공식 2 · 무단 게시처 1)"
assert labelled.score == plain.score
def test_headline_reports_trusted_exclusions_and_silence():
assert _headline_of([_published_on("posar.info")], frozenset({"posar.info"})) == "신뢰 출처 제외 1행"
assert _headline_of([]) == "신호 없음"
def test_a_provider_failure_is_a_review_floor_not_a_bonus():
# "확인 못 했다"는 사람이 봐야 하므로 medium 까지 올린다. 그러나 이미 그 위에
# 있는 케이스에 더하면 네이버 장애 하루가 웹에 있는 모든 이미지를 high 로
# 밀어 올린다 - v4 까지 실제로 그랬다 (+30/행, 무상한).
alone = RiskScorer().score([_failure()])
twice = RiskScorer().score([_failure(), Evidence(EvidenceSource.ENRICHMENT_FAILURE, "Enrichment failed", 1.0, {})])
with_background_person = RiskScorer().score([_face(FACE_PERSON_BACKGROUND_POINTS), _failure()])
with_copy = RiskScorer().score([_search_result_copy(1.0), _failure()])
assert alone.score == FAILURE_FLOOR_POINTS
assert alone.band == "medium"
assert twice.score == FAILURE_FLOOR_POINTS
assert with_background_person.score == FAILURE_FLOOR_POINTS
floor = next(item for item in with_background_person.breakdown["components"] if item["key"] == "failure")
assert floor["points"] == FAILURE_FLOOR_POINTS - FACE_PERSON_BACKGROUND_POINTS
assert with_copy.score == WEB_PRESENCE_MAX_POINTS
assert "External API failed: timeout" in with_copy.reasons
assert all(item["key"] != "failure" for item in with_copy.breakdown["components"])

View file

@ -1,11 +1,27 @@
import pytest
from rights_filter.domain.records import DataClass from rights_filter.domain.records import DataClass
from rights_filter.governance.policies import ( from rights_filter.governance.policies import (
MAX_PERSON_AREA_RATIO,
GovernancePolicyRegistry, GovernancePolicyRegistry,
assert_no_biometric_template, assert_no_biometric_template,
assert_operator_evidence_payload_allowed, 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(): def test_each_data_class_has_access_retention_delete_and_correction_policy():
registry = GovernancePolicyRegistry.default() registry = GovernancePolicyRegistry.default()
@ -42,6 +58,57 @@ def test_search_evidence_rejects_image_payloads_sent_to_naver():
raise AssertionError("expected Naver image payload rejection") 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(): def test_llm_summary_must_reference_source_evidence():
try: try:
assert_operator_evidence_payload_allowed( assert_operator_evidence_payload_allowed(

View file

@ -1,3 +1,5 @@
import pytest
from rights_filter.analysis.preprocessing import ImagePayload, build_external_derivative from rights_filter.analysis.preprocessing import ImagePayload, build_external_derivative
from rights_filter.domain.records import EvidenceSource from rights_filter.domain.records import EvidenceSource
from rights_filter.integrations.cloud_vision_web_detection import ( from rights_filter.integrations.cloud_vision_web_detection import (
@ -6,6 +8,7 @@ from rights_filter.integrations.cloud_vision_web_detection import (
GoogleVisionRestClient, GoogleVisionRestClient,
) )
from rights_filter.integrations.external_policy import ExternalApiPolicy from rights_filter.integrations.external_policy import ExternalApiPolicy
from rights_filter.integrations.http_json import HttpJsonError
def test_disabled_or_unapproved_policy_skips_outbound_call(): def test_disabled_or_unapproved_policy_skips_outbound_call():
@ -269,3 +272,36 @@ def test_quota_exhaustion_records_failure_reason():
assert evidence[0].source == EvidenceSource.EXTERNAL_SKIPPED assert evidence[0].source == EvidenceSource.EXTERNAL_SKIPPED
assert "usage limit" in evidence[0].reason assert "usage limit" in evidence[0].reason
assert client.calls == [] assert client.calls == []
def test_vision_per_image_error_raises_instead_of_reporting_no_results():
# 2026-07-11 HIGH: Vision returns per-image errors inside a 200 response.
# detect_web must raise so the adapter records a FAILURE, not a silent
# "no results" that also wrongly debited quota.
class _ErrorTransport:
def request_json(self, method, url, headers=None, payload=None, timeout=10):
return {"responses": [{"error": {"code": 400, "message": "image too large"}}]}
client = GoogleVisionRestClient(api_key="google-key", transport=_ErrorTransport())
with pytest.raises(HttpJsonError):
client.detect_web(ImagePayload(b"image-bytes", 100, 100, {}))
def test_vision_adapter_converts_per_image_error_to_failure_evidence():
class _ErrorClient:
def detect_web(self, image):
raise HttpJsonError("Vision per-image error: image too large")
adapter = CloudVisionWebDetectionAdapter(_ErrorClient())
policy = ExternalApiPolicy(
compliance_approved=True,
metadata_logging_accepted=True,
allow_online_sync=True,
daily_limit=10,
)
derivative = build_external_derivative(ImagePayload(b"image", 100, 100, {}))
evidence = adapter.detect("submission-1", derivative, policy)
assert evidence[0].source == EvidenceSource.FAILURE
assert "image too large" in evidence[0].reason

View file

@ -3,7 +3,7 @@ import base64
from rights_filter.analysis.preprocessing import ImagePayload from rights_filter.analysis.preprocessing import ImagePayload
from rights_filter.domain.records import Evidence, EvidenceSource from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.integrations.cloud_vision_web_detection import GoogleVisionRestClient from rights_filter.integrations.cloud_vision_web_detection import GoogleVisionRestClient
from rights_filter.integrations.env_clients import build_provider_runtime from rights_filter.integrations.env_clients import DEFAULT_OLLAMA_MODEL, build_provider_runtime
from rights_filter.integrations.google_custom_search import GoogleCustomSearchClient from rights_filter.integrations.google_custom_search import GoogleCustomSearchClient
from rights_filter.integrations.naver_search import NaverOpenApiSearchClient from rights_filter.integrations.naver_search import NaverOpenApiSearchClient
from rights_filter.analysis.llm_assistance import OllamaGenerateLlmClient from rights_filter.analysis.llm_assistance import OllamaGenerateLlmClient
@ -67,7 +67,9 @@ def test_google_vision_rest_client_sends_web_detection_request_with_api_key():
call = transport.calls[0] call = transport.calls[0]
assert call["method"] == "POST" assert call["method"] == "POST"
assert call["url"] == "https://vision.googleapis.com/v1/images:annotate?key=google-key" # Key travels as a header, not in the URL.
assert call["url"] == "https://vision.googleapis.com/v1/images:annotate"
assert call["headers"]["X-goog-api-key"] == "google-key"
assert call["payload"]["requests"][0]["features"][0]["type"] == "WEB_DETECTION" assert call["payload"]["requests"][0]["features"][0]["type"] == "WEB_DETECTION"
assert call["payload"]["requests"][0]["image"]["content"] == base64.b64encode(b"image-bytes").decode("ascii") assert call["payload"]["requests"][0]["image"]["content"] == base64.b64encode(b"image-bytes").decode("ascii")
assert response["web_entities"][0]["description"] == "IU" assert response["web_entities"][0]["description"] == "IU"
@ -128,8 +130,12 @@ def test_google_custom_search_client_uses_env_key_and_search_engine_id():
assert "searchType=image" in call["url"] assert "searchType=image" in call["url"]
def test_ollama_generate_client_uses_local_endpoint_and_returns_response_text(): def test_ollama_generate_client_uses_local_endpoint_and_renders_verified_claims():
transport = FakeJsonTransport({"response": "Source-linked summary"}) # The client no longer passes the model's prose through: it reads the
# structured claims and renders only the ones whose citations check out.
transport = FakeJsonTransport(
{"response": '{"claims": [{"text": "출처 페이지가 확인됩니다.", "evidence": ["E1"]}]}'}
)
client = OllamaGenerateLlmClient( client = OllamaGenerateLlmClient(
base_url="http://localhost:11434", base_url="http://localhost:11434",
model="qwen2.5:0.5b-instruct", model="qwen2.5:0.5b-instruct",
@ -147,7 +153,8 @@ def test_ollama_generate_client_uses_local_endpoint_and_returns_response_text():
summary = client.summarize_evidence(evidence) summary = client.summarize_evidence(evidence)
call = transport.calls[0] call = transport.calls[0]
assert summary == "Source-linked summary" assert "출처 페이지가 확인됩니다." in summary
assert "[E1]" in summary
assert call["method"] == "POST" assert call["method"] == "POST"
assert call["url"] == "http://localhost:11434/api/generate" assert call["url"] == "http://localhost:11434/api/generate"
assert call["headers"] == {} assert call["headers"] == {}
@ -272,4 +279,6 @@ def test_provider_runtime_leaves_missing_env_providers_disabled():
"GOOGLE_CUSTOM_SEARCH_CX": False, "GOOGLE_CUSTOM_SEARCH_CX": False,
} }
assert runtime.provider_payloads["llm"]["enabled"] is True assert runtime.provider_payloads["llm"]["enabled"] is True
assert runtime.provider_payloads["llm"]["compliance"] == "Ollama local API configured (qwen2.5:0.5b-instruct)" assert runtime.provider_payloads["llm"]["compliance"] == (
f"Ollama local API configured ({DEFAULT_OLLAMA_MODEL})"
)

Some files were not shown because too many files have changed in this diff Show more