Commit graph

69 commits

Author SHA1 Message Date
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
changukyu
89c1fe4788 fix: Phase 1 HIGH review remediation (5 of 6)
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.
2026-06-22 14:01:05 +09:00
changukyu
d008e35c2e docs: add 2026-06-22 multi-agent project review report
66 verified findings (0 CRITICAL, 6 HIGH, 43 MEDIUM, 17 LOW) with a
prioritized remediation roadmap. Generated from an adversarially-verified
multi-agent review.
2026-06-22 14:00:45 +09:00
changukyu
ed049bd158 chore: add .gitattributes to enforce LF line endings
Prevent Windows/WSL editors from rewriting source files to CRLF, which
produced large content-free line-ending diffs. Forces LF for all text
files (text=auto eol=lf) and marks fonts/images as binary.
2026-06-22 11:22:23 +09:00
changukyu
71a6da10a3 fix: separate Naver blog/web auto-search dedup; drop dead helpers
_auto_naver_search previously used a shared 'continue' so a duplicate blog
query skipped that iteration's web search entirely. Split into independent
per-signature guards so blog and web auto-searches dedup separately.

Also remove unused private helpers left by the god-file split:
_is_generic_data_image_attr, _search_result_page_image_urls, _count,
_set_active_queue (all grep-verified to have no callers).
2026-06-22 10:00:09 +09:00
changukyu
35c63ce37a docs: add project README 2026-06-21 22:35:31 +09:00
changukyu
0bfa30d7f5 test: add Playwright E2E harness for operator GUI with decision-flow spec
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.
2026-06-21 21:10:22 +09:00
유창욱
0f69ed06e5 refactor: drop unused imports left by the god-file split (pyflakes-clean) 2026-06-20 22:42:19 +09:00
유창욱
2eb7bd3b8b docs: mark god-file split complete in remediation plan 2026-06-20 22:36:02 +09:00
유창욱
6184d0f464 refactor: extract submission-queue management into StoreQueueMixin
Move queue id derivation, active-queue selection, legacy-submission migration,
and queue bootstrap/ensure helpers into a mixin; CopyrighterStore inherits it.
Drop now-unused hashlib import. sqlite_store.py 874 -> 724 lines (5119 -> 724,
-86%); now under the 800-line guideline.
2026-06-20 22:35:02 +09:00
유창욱
b575d2ee06 refactor: extract operator operations into StoreOperationsMixin
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%).
2026-06-20 22:28:30 +09:00
유창욱
3bc07d94c3 refactor: extract enrichment internals into StoreEnrichmentMixin
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).
2026-06-20 22:18:38 +09:00
유창욱
8e0a8c307d refactor: extract search-result similarity and candidate storage into mixin
Move the search-result image similarity, candidate-image storage, in-memory
knowledge repository, and rescoring methods into StoreSearchCandidatesMixin;
CopyrighterStore inherits it. Drop now-unused imports. sqlite_store.py 3072 ->
2358 lines (5333 -> 2358 overall, -56%). Behavior-preserving.
2026-06-20 21:58:10 +09:00
유창욱
40501e13f1 refactor: extract SQLite persistence primitives into StorePersistenceMixin
Move connection/transaction management and the generic _put/_get/_all row access
plus evidence-read helpers into a mixin; CopyrighterStore now inherits it. Methods
rely on the host class's self.db_path / self._write_lock. Behavior-preserving.
sqlite_store.py 3368 -> 3072 lines.
2026-06-20 21:46:01 +09:00
유창욱
3be7b016ce refactor: extract store constants and remaining domain helpers
Move shared constants + _bounded_int_env into store_constants (a leaf module),
and the remaining module-level domain helpers (validation, query signatures,
search-hint evidence, watchlist selection, knowledge type/provenance) into
store_serialization. sqlite_store.py is now the CopyrighterStore class plus thin
imports: 3613 -> 3368 lines (5333 -> 3368 overall, -37%). All behavior-preserving.
2026-06-20 21:38:03 +09:00
유창욱
8e53139029 refactor: extract payload serialization helpers into store_serialization
Move submission/evidence payload builders, provider-state derivation, UI<->domain
evidence mapping, weak-label handling, and id/label/image helpers into
store_serialization (depends only on stdlib + domain + url/text helpers, no store
coupling). Behavior-preserving; imported back into sqlite_store. 3992 -> 3613 lines.
2026-06-20 21:24:58 +09:00
유창욱
e3bc99e6b9 refactor: extract text helpers and HTML/CSS image-scraping from sqlite_store
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.
2026-06-20 21:10:22 +09:00
유창욱
bd35cf6f3f docs: record remediation implementation status in plan 2026-06-20 20:57:02 +09:00
유창욱
da917755dd refactor: extract remote-fetch and schema modules from sqlite_store
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.
2026-06-20 20:50:29 +09:00
유창욱
e66f9d5001 refactor: extract URL helpers into store_url_utils
Move the SQLite store's 5 URL helpers (_decoded_nested_url, _is_http_url,
_url_path_has_image_suffix, _url_has_image_format_hint, _url_looks_like_image)
into a focused module and import them back. Pure relocation, no behavior
change. First step of splitting the 5300-line sqlite_store god file.
2026-06-20 20:35:21 +09:00
유창욱
7317bfb2b3 fix: reject protocol-relative and backslash URLs in safeUrl
Address commit security review: the same-origin branch of safeUrl accepted
//host and /\host, which browsers normalize to an external host (open
redirect). Allow only true same-origin paths.
2026-06-20 18:47:13 +09:00
유창욱
f8aa10f91b fix: frontend URL scheme allowlist, fetch ok-check, image onerror
Add safeUrl() to gate external search-result URLs into href/src (blocks
javascript:/data:), parse the response body before the ok check in apiJson
so non-JSON error bodies surface the real status, and hide broken evidence
preview images via onerror.
2026-06-20 18:44:20 +09:00