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
용도를 내부 문서로 못박았다. 고객 인수물이 아니므로 납품 품질 기준을 적용하지
않는다 — 감사에서 나온 한글 폰트 미번들과 영어 뷰어 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
감사에서 나온 결론은 "그림은 지금 정확하지만 정확함을 지킬 장치가 없다" 였다.
코드 유래 리터럴 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
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
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
/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
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), 린트 클린.
기능 실효성 평가에서 확정된 판별력 붕괴를 고친다. 신호조합 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), 린트 클린.
"이 도구가 실제 업무에 도움이 되는가"를 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), 린트 클린.
test_browser_uploads_image_and_selects_new_submission 이 전체 스위트에서만
실패하고 단독 실행에서는 통과했다. 업로드는 케이스가 큐에 나타나기 전에
서버에서 실분석(디코드/지문/YuNet)을 돌리므로, 다른 스펙과 CPU 를 다투는
전체 실행에서 10초를 넘겼다.
UPLOAD_ANALYSIS_TIMEOUT_MS(30초)로 올리고 이유를 상수 옆에 남겼다. 모킹된
부트스트랩 대기와 달리 이 경로만 여유가 필요하다는 점이 코드에서 보이게 했다.
upload_and_open 의 기본값도 같은 상수를 쓴다.
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, 무자격증명 요청이 스로틀에 안 잡히는 것, 성공 시
카운터 초기화 포함.
단일 파일이 프로젝트 상한(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건.
린트와 테스트가 각각 수동 실행이었고, 어느 조합이 커밋 게이트인지 코드에
기록된 곳이 없었다. 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)
코드 리딩으로는 나오지 않는 결함들이다. 서버를 실제로 띄우고 브라우저로
케이스 화면을 열어서 찾았다.
## 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.
두 신호를 하나의 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.
실모델 프로브에서 인용 검증만으로는 못 잡는 실패 모드가 드러났다.
## 형식 예시 반향(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.
사용자 결정: 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.
두 갈래의 작업이 한 커밋에 들어 있다. 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).
- 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
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
- 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.
- 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.
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.
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.
Backend (data-integrity / audit-trail):
- mark_evidence_status: make the read-modify-write + audit atomic under
_write_lock + _transaction (was 4 unlocked connections, last-writer-wins,
audit could diverge from DB state).
- promote_collection_candidates: acquire _write_lock, use a deterministic
_stable_id, wrap writes in _transaction, and guard already-promoted batches
so a retry/double-click cannot mint duplicate knowledge entries.
- risk_scoring: bucket web-detection entities by name so two distinct entities
in one image both count (was collapsed to one 'entity' bucket, under-scoring
high->medium). Identical entities still dedup.
Frontend (silent loss of operator actions):
- runManualSearch: in-flight guard; rerunHistoricalQuery voids the call,
preventing duplicate/racing search POSTs.
- deactivateCorrectionEntry: persist via the real knowledge-deactivate
endpoint instead of a local-only mutation + fake audit event.
- provider-retry button: report honestly (no backend endpoint) instead of
writing a fake 'Provider called' audit event.
Tests: +4 regression tests (mark_evidence_status rollback, batch-promote
idempotency, two distinct entities -> high, identical entity dedup).
Remaining Phase 1 item #6 (_auto_naver/_auto_google refactor) deferred.
Backend suite: 361 passed. app.js: node --check OK, 41 static tests pass.
Introduce a reusable browser-test harness under tests/operator_gui/:
- conftest.py: shared fixtures (operator_server, browser with graceful skip,
operator_page with screenshot-on-failure artifacts)
- pages/: OperatorWorkbench Page Object encapsulating selectors and actions
- refactor test_browser_smoke.py onto the fixtures + POM (drops duplicated
server/browser plumbing)
- test_decision_flow.py: approve/hold/reject E2E against the real server,
covering the reject-requires-memo guard and decision persistence
Pin playwright in requirements-dev.txt (Chromium bundled offline for the
air-gapped target) and ignore the artifacts/ screenshot dir.
Move knowledge-entry lifecycle, rerun/auto/manual search drivers, LLM summary
management, manual knowledge registration, and keyword-candidate collection/
promotion into a mixin; CopyrighterStore inherits it. Drop now-unused imports;
point the rollback test at store_serialization._stable_id. sqlite_store.py 1598
-> 874 lines (5333 -> 874, -84%).
Move provider-state sync, local face evidence/crop sync + retention purge,
internal/Google re-analysis, face-crop web detection, and the auto Google/Naver
search drivers into a mixin; CopyrighterStore inherits it. Drop now-unused
imports. Tests now also patch HeuristicFacePersonDetector on store_enrichment
(face detection moved there). sqlite_store.py 2358 -> 1598 lines (-70% overall).
Move the pure text helpers (_text_list, _unique_texts) into store_text and the
~950-line page/CSS/JSON/srcset image-URL extraction (the _PageImageParser and
its helpers) into store_page_scrape. Both behavior-preserving; store_page_scrape
depends only on stdlib + url/text helpers + domain Evidence (no store coupling).
sqlite_store.py 4955 -> 3992 lines.
Move the pure network layer (image/page/stylesheet fetchers, SSRF guard,
redirect-validating opener) into store_remote_fetch, and the DDL/typed-column/
constraint-migration helpers into store_schema. Both are behavior-preserving
relocations imported back into sqlite_store; tests repoint their fetch
monkeypatches to store_remote_fetch. sqlite_store.py 5333 -> 4948 lines.
Governance: purge biometric face crops past a retention window (env
COPYRIGHTER_FACE_CROP_RETENTION_DAYS, default 90d) with an audit trail, run
at startup and reload; audit personal-image transmission to external Vision.
Concurrency: a process write lock + atomic provider-usage delta stop lost
counter updates; candidate promotion is idempotent (deterministic id + status
guard); seeding is serialized. Correctness: skip LLM summarize when a summary
already exists; constraint migration cleans orphan temp tables on failure.
Add provider-readiness startup log. Tests pin all of the above plus risk-band
boundaries (29/30/69/70, 100 cap) and media path-traversal guards.
Address commit security review: replace the ?token= query fallback (which
leaked the token into logs/referrers) with an HttpOnly, SameSite=Strict
session cookie minted on the first header-authenticated request, so <img>
media loads authenticate without a URL token. Use hmac.compare_digest for
constant-time comparison and add Cache-Control: no-store + Referrer-Policy:
no-referrer on untrusted biometric media. Also cover upload/import boundary
validation (400) at the HTTP layer.
Resolve each candidate image/page/stylesheet URL and refuse loopback,
RFC1918, link-local (cloud-metadata), reserved, multicast, and unspecified
targets before fetching; re-validate on every redirect hop via a custom
opener. URLs originate from external search-result content, so this closes
the operator server fetching internal services.
Enable WAL + busy_timeout in _connect (ThreadingHTTPServer concurrent
operators no longer hit 'database is locked'), add a _transaction helper and
thread an optional conn through _put/_get/add_audit_event so record_decision
commits its status change, watchlist entry, and audit event atomically.
Remove wildcard CORS (prevented cross-origin reads of biometric/case data
from localhost), add optional shared-token auth gate on data routes
(COPYRIGHTER_AUTH_TOKEN; GUI shell + /health stay open), cap request body
size (413), and map malformed JSON to 400 and SQLite lock contention to 503.
Correctness:
- Make the local-artifact audit test skip on fresh clones (data/ is
gitignored), so the suite passes outside this workstation
- Drop the transform from the viewRise entrance animation: an animated
transform made .view.active a containing block for 320ms and threw
the fixed decision panel off-screen on every workbench entry
- Collapse the queue toolbar at 1380px instead of 1180px; 1280x800
laptops no longer get a horizontal scrollbar (verified live)
- Serve .woff2 as font/woff2 with an immutable cache header so the
2MB bundled font is fetched once, not per page load (with test)
- Clip overflow on top-bar status chips (long apiError strings spilled
over neighbors at 981-1180px)
- Give queue-row selection a selector that outranks the even-row
zebra stripe (selection background was parity-dependent)
Cleanup:
- Replace the stale old-palette focus ring and ::selection literals
with color-mix over var(--teal)
- Delete dead tokens: unused back-compat aliases (the comment claiming
they were referenced was false), --rail-bot, --ochre-deep, and
--font-stamp (identical to --font-ui since the Pretendard switch)
- Tokenize scattered raw colors: rail ink scale, soft tint levels,
inset-well and bevel shadows, naver/internal source-chip triplets
- Remove the asset-preload div and three orphan SVGs nothing renders;
tests now reject reintroducing them
Verified: 359 tests pass; Playwright audit at 1440/1280/390 shows zero
horizontal overflow on all views, Pretendard active, decision panel
fixed at the viewport corner mid-animation.
Image rights / copyright detection system: SQLite store, HTTP app,
search integrations (Naver, Google Custom Search, Google Cloud Vision
web detection), image analysis (fingerprints, face/person detection,
evidence enrichment, risk scoring), an admin/review layer, governance
and retention policies, batch jobs, and a browser-based operator GUI.
This baseline incorporates a full code-review remediation pass
(46 fixes; 358 tests passing). Highlights:
CRITICAL
- Prevent evidence cascade-delete during the schema-constraint
migration by disabling FK enforcement around the table rebuild.
Security
- Sandbox served media (neutralize stored XSS from uploaded/collected
SVGs) via CSP + nosniff on the untrusted media routes.
- Strip embedded EXIF/GPS from external image derivatives before they
are sent to third-party APIs.
- Return a clean 404 (not an uncaught StopIteration) for PATCH on an
unknown provider.
Correctness
- LLM-summary failures no longer add +30 to the risk score.
- Decode only explicit JS escapes so Korean image URLs are not mangled.
- Consume search quota only after a successful request.
- Naver/Google adapters map responses inside the failure boundary, so a
malformed response degrades to evidence instead of crashing enrichment.
- Domain-aware provider attribution; face-box IoU de-duplication; count
searches (not result items); per-box crop isolation; clamp evidence
confidence and Google CSE num; real submittedEpoch; and more.
Robustness
- Offline LLM connect fast-fails (short connect timeout) so seed/reload
requests are not stalled; full read timeout preserved for generation.
- Malformed numeric env vars fall back to defaults instead of crashing
startup.
Performance
- Per-submission evidence reads (no full-table scan per rescore),
audit-log LIMIT, lazy active-store lookup, hoisted timestamps.
Tests
- ~24 regression tests added pinning the above fixes.
Runtime data (data/, outputs/, *.sqlite3, *.log), secrets (.env), and
node_modules are gitignored.