Compare commits
31 commits
76a06f2ff1
...
2735ce1194
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2735ce1194 | ||
|
|
177e365b55 | ||
|
|
7405ab3e0e | ||
|
|
2661af86e5 | ||
|
|
70e1bcb900 | ||
|
|
92fc8094d4 | ||
|
|
30772e93cf | ||
|
|
29534f441a | ||
|
|
1db2c30885 | ||
|
|
e3298d149c | ||
|
|
4b74f9577a | ||
|
|
6017750bae | ||
|
|
352031cf45 | ||
|
|
c740c2e6ba | ||
|
|
e169e24584 | ||
|
|
0c60369da0 | ||
|
|
975c252201 | ||
|
|
994852d920 | ||
|
|
4e8e4b3181 | ||
|
|
624f435b82 | ||
|
|
be5d42a418 | ||
|
|
93a4a82bff | ||
|
|
d45018c4d0 | ||
|
|
e4fffc40be | ||
|
|
1605fd3641 | ||
|
|
e38dad83c9 | ||
|
|
4ba83ebc65 | ||
|
|
b0aa4c5bcc | ||
|
|
847f7a852d | ||
|
|
150e729aef | ||
|
|
02395ac3aa |
77 changed files with 7411 additions and 248 deletions
7
.gitattributes
vendored
7
.gitattributes
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -45,3 +45,18 @@ Thumbs.db
|
||||||
|
|
||||||
# PowerShell module-analysis cache written into cwd by some automation
|
# PowerShell module-analysis cache written into cwd by some automation
|
||||||
Microsoft/
|
Microsoft/
|
||||||
|
|
||||||
|
# 다이어그램 검증 사이드카 (scripts/rebuild-diagrams.ps1 이 재생성한다)
|
||||||
|
#
|
||||||
|
# visual-check 산출물은 브라우저 증거이지 납품물이 아니다. 게다가 *.visual-check.json
|
||||||
|
# 은 개발 머신의 절대 경로와 크롬 설치 경로를 평문으로 담는다 - 폐쇄망 납품 저장소가
|
||||||
|
# removable media 로 건너가는 구조라 이력에 한 번 들어가면 제거가 어렵다.
|
||||||
|
docs/diagrams/*.visual-check.*
|
||||||
|
|
||||||
|
# 캘리브레이션 코퍼스 이미지 (저작권 있는 실이미지)
|
||||||
|
#
|
||||||
|
# 판정 임계를 검증하려면 실제 유통 이미지가 필요한데, 그것을 저장소에 넣으면
|
||||||
|
# 납품 저장소가 removable media 로 건너가는 구조에서 그대로 따라간다.
|
||||||
|
# 이미지는 로컬에만 두고 코퍼스 DB(data/)도 이미 제외되어 있다.
|
||||||
|
sample/
|
||||||
|
corpus/
|
||||||
|
|
|
||||||
|
|
@ -99,3 +99,5 @@ python -m pytest tests/operator_gui/
|
||||||
- [프로젝트 소개 및 기술 구현](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
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0.1.0
|
||||||
|
|
@ -1,5 +1,58 @@
|
||||||
# Project INDEX
|
# 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-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-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)
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ python run_copyrighter_server.py # 기본 127.0.0.1:9500
|
||||||
| `fingerprints.py` | 이미지 지문. `_perceptual_hash`는 **DCT pHash가 아니라 8x8 평균해시(aHash)** — 이름과 실제가 다름(알려진 부채) |
|
| `fingerprints.py` | 이미지 지문. `_perceptual_hash`는 **DCT pHash가 아니라 8x8 평균해시(aHash)** — 이름과 실제가 다름(알려진 부채) |
|
||||||
| `face_person_detection.py` | YuNet(ONNX) 얼굴 검출 + 인물 등급(`identifiable`/`background`). 가중치는 `models/`(gitignore) |
|
| `face_person_detection.py` | YuNet(ONNX) 얼굴 검출 + 인물 등급(`identifiable`/`background`). 가중치는 `models/`(gitignore) |
|
||||||
| `internal_analyzer.py` | 내부 분석 오케스트레이션 |
|
| `internal_analyzer.py` | 내부 분석 오케스트레이션 |
|
||||||
| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다** |
|
| `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` | 증거 정규화·보강 |
|
| `evidence_normalizer.py`, `evidence_enrichment.py` | 증거 정규화·보강 |
|
||||||
| `search_query_generation.py`, `search_result_promoter.py` | 자동 검색 쿼리 생성, 검색 결과의 증거 승격 |
|
| `search_query_generation.py`, `search_result_promoter.py` | 자동 검색 쿼리 생성, 검색 결과의 증거 승격 |
|
||||||
| `llm_assistance.py`, `llm_prompts.py` | LLM을 **요약기가 아닌 증거 판독기**로 사용. 입력 화이트리스트 9필드 + 상위 12건 상한, `format=json` 구조화 인용, 라벨 집합 포함 검사로 미인용 claim 폐기 |
|
| `llm_assistance.py`, `llm_prompts.py` | LLM을 **요약기가 아닌 증거 판독기**로 사용. 입력 화이트리스트 9필드 + 상위 12건 상한, `format=json` 구조화 인용, 라벨 집합 포함 검사로 미인용 claim 폐기 |
|
||||||
|
|
@ -133,7 +133,7 @@ app-bootstrap.js bindEvents, init, DOM 리스너 ← 유일하게 로드
|
||||||
|
|
||||||
**POST** — `/api/submissions/{id}/search-auto|rerun-enrichment|decision|clear-reference` ·
|
**POST** — `/api/submissions/{id}/search-auto|rerun-enrichment|decision|clear-reference` ·
|
||||||
`/api/submissions/reload|import-folder|upload-image|decision-batch` ·
|
`/api/submissions/reload|import-folder|upload-image|decision-batch` ·
|
||||||
`/api/evidence/{id}/status` · `/api/search/manual` · `/api/knowledge/manual` ·
|
`/api/evidence/{id}/status` · `/api/evidence/status-batch` · `/api/search/manual` · `/api/knowledge/manual` ·
|
||||||
`/api/knowledge/{id}/promote-watchlist|exclude-watchlist|deactivate|reactivate` ·
|
`/api/knowledge/{id}/promote-watchlist|exclude-watchlist|deactivate|reactivate` ·
|
||||||
`/api/collections/keyword` · `/api/collections/candidates/promote-batch` ·
|
`/api/collections/keyword` · `/api/collections/candidates/promote-batch` ·
|
||||||
`/api/collections/candidates/{id}/promote|dismiss` ·
|
`/api/collections/candidates/{id}/promote|dismiss` ·
|
||||||
|
|
@ -167,11 +167,69 @@ WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 다이어그램
|
||||||
|
|
||||||
|
소스 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 임계 재보정 필요 |
|
| `_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) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 |
|
| src 4파일이 800줄 초과 | `store_page_scrape.py`(972) `store_operations.py`(968) `sqlite_store.py`(904) `store_enrichment.py`(853) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 |
|
||||||
| 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증 |
|
| 웹 신호만으로 high 도달 | `analysis/risk_scoring.py` | 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) | 각 항목에 연기 근거 기재 |
|
| 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` 으로 막힌다. 인증이 아니라 기록이다.
|
||||||
|
|
|
||||||
42
docs/diagrams/README.md
Normal file
42
docs/diagrams/README.md
Normal 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)에 있다.
|
||||||
322
docs/diagrams/evidence-dataflow.dataflow.json
Normal file
322
docs/diagrams/evidence-dataflow.dataflow.json
Normal 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
BIN
docs/diagrams/evidence-dataflow.html
generated
Normal file
Binary file not shown.
46
docs/diagrams/manifest.json
Normal file
46
docs/diagrams/manifest.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
docs/diagrams/operator-review-workflow.html
generated
Normal file
BIN
docs/diagrams/operator-review-workflow.html
generated
Normal file
Binary file not shown.
345
docs/diagrams/operator-review-workflow.workflow.json
Normal file
345
docs/diagrams/operator-review-workflow.workflow.json
Normal 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
BIN
docs/diagrams/review-guide.html
generated
Normal file
Binary file not shown.
292
docs/diagrams/review-guide.workflow.json
Normal file
292
docs/diagrams/review-guide.workflow.json
Normal 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
BIN
docs/diagrams/submission-lifecycle.html
generated
Normal file
Binary file not shown.
280
docs/diagrams/submission-lifecycle.lifecycle.json
Normal file
280
docs/diagrams/submission-lifecycle.lifecycle.json
Normal 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": [
|
||||||
|
"판정을 남긴 케이스는 원본 파일이 사라져도 지워지지 않는다",
|
||||||
|
"파일이 돌아오면 보존 표시는 해제되고 감사에 남는다"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
338
docs/diagrams/system-architecture.architecture.json
Normal file
338
docs/diagrams/system-architecture.architecture.json
Normal 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
BIN
docs/diagrams/system-architecture.html
generated
Normal file
Binary file not shown.
159
docs/operations/calibration-corpus.md
Normal file
159
docs/operations/calibration-corpus.md
Normal 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` 상단 예산표다.
|
||||||
|
|
@ -101,9 +101,17 @@ The LLM assistant is optional. If you want it on the air-gapped target, the
|
||||||
model weights must be carried across too — they are **not** part of the Python
|
model weights must be carried across too — they are **not** part of the Python
|
||||||
wheelhouse:
|
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
|
```bash
|
||||||
# Build machine (has internet):
|
# Build machine (has internet):
|
||||||
ollama pull qwen2.5:0.5b-instruct # or your pinned OLLAMA_MODEL
|
ollama pull qwen2.5:7b # or your pinned OLLAMA_MODEL
|
||||||
# Copy the model store across the air gap:
|
# Copy the model store across the air gap:
|
||||||
# default: ~/.ollama/models (or set OLLAMA_MODELS to a directory you transfer)
|
# default: ~/.ollama/models (or set OLLAMA_MODELS to a directory you transfer)
|
||||||
|
|
||||||
|
|
@ -111,11 +119,18 @@ ollama pull qwen2.5:0.5b-instruct # or your pinned OLLAMA_MODEL
|
||||||
export OLLAMA_MODELS=/path/to/transferred/models
|
export OLLAMA_MODELS=/path/to/transferred/models
|
||||||
ollama serve &
|
ollama serve &
|
||||||
# Smoke test:
|
# Smoke test:
|
||||||
ollama run qwen2.5:0.5b-instruct "hello"
|
ollama run qwen2.5:7b "hello"
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `OLLAMA_BASE_URL` / `OLLAMA_MODEL` in `.env`. Leave them unset to run without
|
Set `OLLAMA_BASE_URL` / `OLLAMA_MODEL` in `.env`. Leaving them unset does **not**
|
||||||
the LLM (the `llm` provider then reports disabled).
|
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)
|
## Optional: Playwright Chromium (E2E tests)
|
||||||
|
|
||||||
|
|
@ -134,6 +149,9 @@ 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.
|
||||||
|
|
@ -145,6 +163,7 @@ python -m pytest tests/operator_gui -q
|
||||||
fetches arbitrary public result-page / image / stylesheet URLs returned by
|
fetches arbitrary public result-page / image / stylesheet URLs returned by
|
||||||
those searches (SSRF-guarded: private/loopback/CGNAT/link-local ranges are
|
those searches (SSRF-guarded: private/loopback/CGNAT/link-local ranges are
|
||||||
blocked). Restricting egress to the API hosts only disables image comparison
|
blocked). Restricting egress to the API hosts only disables image comparison
|
||||||
of external results; it does not break core review.
|
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.
|
||||||
|
|
|
||||||
40
docs/operations/operating-modes.md
Normal file
40
docs/operations/operating-modes.md
Normal 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 는 한 줄 요약에 `외부 검색 꺼짐` 이 떠야 정상이다. 대신 `외부 검색 실패` 가 뜨면 키가 남아 있는 것이다.
|
||||||
111
docs/operations/review-guide.md
Normal file
111
docs/operations/review-guide.md
Normal 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`).
|
||||||
244
docs/reviews/2026-08-08-utility-assessment.md
Normal file
244
docs/reviews/2026-08-08-utility-assessment.md
Normal 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건. 미착수로 문서화된 항목이지 숨은 취약점이 아니다.
|
||||||
74
docs/reviews/2026-09-23-codex-feature-review.md
Normal file
74
docs/reviews/2026-09-23-codex-feature-review.md
Normal 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) |
|
||||||
116
docs/reviews/2026-09-25-persona-socratic-review.md
Normal file
116
docs/reviews/2026-09-25-persona-socratic-review.md
Normal 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, 사용자 확인 필요)
|
||||||
99
scripts/rebuild-diagrams.ps1
Normal file
99
scripts/rebuild-diagrams.ps1
Normal 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
|
||||||
224
scripts/score_distribution.py
Normal file
224
scripts/score_distribution.py
Normal 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
144
scripts/sweep_thresholds.py
Normal 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())
|
||||||
94
scripts/update_diagram_manifest.py
Normal file
94
scripts/update_diagram_manifest.py
Normal 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())
|
||||||
|
|
@ -44,7 +44,11 @@ PROMPT_DATA_FIELDS = (
|
||||||
"match",
|
"match",
|
||||||
"similarity",
|
"similarity",
|
||||||
"page_title",
|
"page_title",
|
||||||
"domain",
|
# 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",
|
"query",
|
||||||
"entity",
|
"entity",
|
||||||
"label",
|
"label",
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,20 @@ 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
|
and after a prompt (or model) change are indistinguishable in the database, and
|
||||||
"did the new prompt help?" becomes unanswerable after the fact.
|
"did the new prompt help?" becomes unanswerable after the fact.
|
||||||
|
|
||||||
Bump PROMPT_VERSION whenever SYSTEM_PROMPT changes in a way that could alter
|
Bump PROMPT_VERSION whenever the MODEL INPUT changes in a way that could alter
|
||||||
output — wording, format contract, or language.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
PROMPT_ID = "operator-review-note-ko"
|
PROMPT_ID = "operator-review-note-ko"
|
||||||
PROMPT_VERSION = "ko-v3-structured"
|
# 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
|
# Marks rows written before prompt versioning existed. Never assume the text
|
||||||
# below produced them.
|
# below produced them.
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ SUBTITLE_MIN_EDGE_DENSITY = 0.01
|
||||||
# (letterbox + broadcast size) is ONE category: a letterboxed 1920x1080 frame is
|
# (letterbox + broadcast size) is ONE category: a letterboxed 1920x1080 frame is
|
||||||
# one observation stated twice, and counting it twice would let a cinematic crop
|
# one observation stated twice, and counting it twice would let a cinematic crop
|
||||||
# alone trigger the label.
|
# 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
|
MIN_CAPTURE_CATEGORIES = 2
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +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.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
|
||||||
|
|
||||||
FACE_PERSON_IDENTIFIABLE_POINTS = 35
|
# --- 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
|
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
|
# Stamped onto every submission at creation so a later weight change is
|
||||||
# distinguishable from a scoring bug. Bump this whenever the weights, the bucket
|
# distinguishable from a scoring bug. Bump this whenever the weights, the bucket
|
||||||
# rules, or the band thresholds below change — the stamp is what lets
|
# rules, or the band thresholds below change - the stamp is what lets
|
||||||
# initialRiskScore be compared against a rescore and mean something.
|
# initialRiskScore be compared against a rescore and mean something.
|
||||||
SCORER_VERSION = "v1"
|
# 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_naver_buckets: set[str] = set()
|
||||||
counted_face_person = False
|
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"}:
|
||||||
|
|
@ -29,36 +193,87 @@ class RiskScorer:
|
||||||
continue
|
continue
|
||||||
# A match against a CLEARED reference is a positive signal: the
|
# A match against a CLEARED reference is a positive signal: the
|
||||||
# team already confirmed this image is safe to use. It must not
|
# team already confirmed this image is safe to use. It must not
|
||||||
# add risk (a +80 here would push a known-safe image to the high
|
# add risk. Surface it as a reason so the operator sees the prior
|
||||||
# band). Surface it as a reason so the operator sees the prior
|
|
||||||
# clearance, but contribute no points.
|
# clearance, but contribute no points.
|
||||||
if item.data.get("knowledge_entry_type") == "cleared_reference":
|
if item.data.get("knowledge_entry_type") == "cleared_reference":
|
||||||
|
tally.cleared = True
|
||||||
if item.reason:
|
if item.reason:
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
continue
|
continue
|
||||||
|
if item.reason == "Image fingerprints generated":
|
||||||
|
continue
|
||||||
similarity = _safe_float(item.data.get("similarity", 0))
|
similarity = _safe_float(item.data.get("similarity", 0))
|
||||||
if similarity >= 0.9:
|
if _is_search_result_copy(item):
|
||||||
score += 80
|
# A copy found among search results is the strongest view of
|
||||||
reasons.append(item.reason)
|
# "this image is on the web" - the same fact Vision's match
|
||||||
elif item.reason != "Image fingerprints generated":
|
# rows report, so it shares their pool. Trusted hosts apply
|
||||||
score += 30
|
# 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:
|
||||||
# "A person is present" is one signal no matter how many rows
|
# "A person is present" is one signal no matter how many rows
|
||||||
# assert it. Re-analysis appends a fresh FACE_PERSON row while the
|
# assert it. Re-analysis appends a fresh FACE_PERSON row while the
|
||||||
# previous one remains, so without this bucket the same image
|
# previous one remains, so without this bucket the same image
|
||||||
# accrued +35 per rerun and drifted into the high band on its own.
|
# accrued the portrait weight per rerun and drifted into the high
|
||||||
|
# band on its own.
|
||||||
if counted_face_person:
|
if counted_face_person:
|
||||||
continue
|
continue
|
||||||
counted_face_person = True
|
counted_face_person = True
|
||||||
score += _face_person_points(item)
|
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:
|
||||||
|
|
@ -68,12 +283,12 @@ class RiskScorer:
|
||||||
if not points:
|
if not points:
|
||||||
continue
|
continue
|
||||||
# Count one promoted hit per executed query. Multiple promoted
|
# Count one promoted hit per executed query. Multiple promoted
|
||||||
# results from the same search are one signal, not N stacked +40s
|
# results from the same search are one signal, not N.
|
||||||
# (which would push a single query straight to the high band).
|
|
||||||
bucket = _naver_search_bucket(item)
|
bucket = _naver_search_bucket(item)
|
||||||
if bucket and bucket in counted_naver_buckets:
|
if bucket and bucket in counted_naver_buckets:
|
||||||
continue
|
continue
|
||||||
score += points
|
pool("naver", points)
|
||||||
|
tally.naver_hits += 1
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
if bucket:
|
if bucket:
|
||||||
counted_naver_buckets.add(bucket)
|
counted_naver_buckets.add(bucket)
|
||||||
|
|
@ -97,16 +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
|
||||||
if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}:
|
if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}:
|
||||||
score += 30
|
failure_seen = True
|
||||||
|
tally.failures += 1
|
||||||
reasons.append(item.reason)
|
reasons.append(item.reason)
|
||||||
# SEARCH_SKIPPED / EXTERNAL_SKIPPED contribute zero points and are
|
elif _is_transient_skip(item):
|
||||||
# operational status ("quota reached", "provider disabled"), not
|
# A provider that normally runs hit its daily quota before
|
||||||
# risk signals — keep them out of the operator-facing reasons list.
|
# 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"
|
||||||
|
|
@ -115,7 +376,178 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
def _face_person_points(item: Evidence) -> int:
|
||||||
|
|
@ -133,38 +565,105 @@ def _face_person_points(item: Evidence) -> int:
|
||||||
return FACE_PERSON_IDENTIFIABLE_POINTS
|
return FACE_PERSON_IDENTIFIABLE_POINTS
|
||||||
|
|
||||||
|
|
||||||
def _web_detection_points(item: Evidence) -> int:
|
# 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
|
||||||
# Google custom IMAGE search results (match="search_image") are context-only:
|
# Google custom IMAGE search results (match="search_image") are context-only:
|
||||||
# existence of a text-query image hit is not itself a rights signal (the
|
# existence of a text-query image hit is not itself a rights signal (the
|
||||||
# matching pHash FINGERPRINT evidence carries the score). Fresh evidence has
|
# matching pHash FINGERPRINT evidence carries the score). Fresh evidence has
|
||||||
# no bare "url" key so it scores 0, but the persistence round-trip aliases
|
# no bare "url" key so it scores 0, but the persistence round-trip aliases
|
||||||
# result_url -> url; without this explicit gate the round-trip would flip the
|
# result_url -> url; without this explicit gate the round-trip would flip the
|
||||||
# same evidence to +45 via the url fallback below, violating the
|
# same evidence into the url fallback below, violating the
|
||||||
# score-preservation invariant and inflating bands on any query that returns
|
# score-preservation invariant and inflating bands on any query that returns
|
||||||
# anything.
|
# anything.
|
||||||
if item.data.get("match") == "search_image":
|
match = item.data.get("match")
|
||||||
return 0
|
if match == "search_image":
|
||||||
if item.data.get("match") == "full":
|
return 0, _WEB_STANDALONE
|
||||||
return 45
|
if match == "full":
|
||||||
if item.data.get("match") in {"partial", "page"}:
|
return WEB_COPY_EXACT_POINTS, _WEB_PRESENCE
|
||||||
return 35
|
if match in {"partial", "page"}:
|
||||||
if item.data.get("match") == "visual":
|
return WEB_COPY_NEAR_POINTS, _WEB_PRESENCE
|
||||||
return 10
|
if match == "visual":
|
||||||
if item.data.get("url"):
|
return WEB_COPY_WEAK_POINTS, _WEB_PRESENCE
|
||||||
return 45
|
# Legacy rows only: every current producer sets `match`, so a web-detection
|
||||||
if item.data.get("category") in {"character", "celebrity", "work"}:
|
# row that has a URL and no match type was written by an older build. It
|
||||||
return 45
|
# 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
|
||||||
|
|
@ -173,13 +672,13 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,18 @@ class KnowledgeEntryType(StrEnum):
|
||||||
# (licensed / owned / public domain). A match argues FOR fast approval, not
|
# (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.
|
# against it, so the scorer treats it as a cleared signal rather than +80.
|
||||||
CLEARED_REFERENCE = "cleared_reference"
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -91,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
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ import os
|
||||||
import sys
|
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.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:
|
||||||
|
|
@ -103,7 +105,7 @@ def main() -> None:
|
||||||
static_dir=static_path,
|
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()
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,12 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, 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.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.
|
||||||
|
|
@ -97,14 +100,38 @@ def build_server(
|
||||||
# a handful of slowloris-style connections cannot exhaust the thread pool.
|
# a handful of slowloris-style connections cannot exhaust the thread pool.
|
||||||
timeout = 30
|
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":
|
||||||
|
|
@ -152,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
|
||||||
|
|
@ -172,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(
|
||||||
|
|
@ -294,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
|
||||||
|
|
|
||||||
|
|
@ -71,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:
|
||||||
|
|
|
||||||
54
src/rights_filter/server/operator_identity.py
Normal file
54
src/rights_filter/server/operator_identity.py
Normal 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)
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rights_filter.server.operator_identity import current_actor
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
@ -13,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,
|
||||||
|
|
@ -29,6 +30,7 @@ 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,
|
MAX_LLM_SUMMARIES_PER_SEED,
|
||||||
MIN_COVERAGE_THRESHOLD,
|
MIN_COVERAGE_THRESHOLD,
|
||||||
|
|
@ -56,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,
|
||||||
|
|
@ -72,6 +75,13 @@ logger = logging.getLogger(__name__)
|
||||||
# two imported folders). Kept as a constant so the "log it once" lookup and the
|
# two imported folders). Kept as a constant so the "log it once" lookup and the
|
||||||
# write cannot drift apart.
|
# write cannot drift apart.
|
||||||
_QUEUE_ID_CONFLICT_EVENT = "Submission id conflict"
|
_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(
|
||||||
|
|
@ -108,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.
|
||||||
|
|
@ -287,6 +301,30 @@ class CopyrighterStore(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -338,7 +376,7 @@ 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._sync_submission_provider_state(queue_id=queue_id)
|
self._sync_submission_provider_state(queue_id=queue_id)
|
||||||
|
|
@ -355,14 +393,20 @@ class CopyrighterStore(
|
||||||
# case's score. Skip the colliding record and make the conflict visible
|
# case's score. Skip the colliding record and make the conflict visible
|
||||||
# rather than destroying a decided submission.
|
# rather than destroying a decided submission.
|
||||||
foreign_queue_owners = self._submission_queue_owners(exclude_queue_id=queue_id)
|
foreign_queue_owners = self._submission_queue_owners(exclude_queue_id=queue_id)
|
||||||
|
self._intake_conflicts = self._note_stem_collisions(image_store)
|
||||||
records = []
|
records = []
|
||||||
|
seen_record_ids: set[str] = set()
|
||||||
for record in image_store.submission_records():
|
for record in image_store.submission_records():
|
||||||
record_id = str(record["id"])
|
record_id = str(record["id"])
|
||||||
if record_id in existing_submission_ids:
|
if record_id in existing_submission_ids or record_id in seen_record_ids:
|
||||||
continue
|
continue
|
||||||
|
seen_record_ids.add(record_id)
|
||||||
owner_queue_id = foreign_queue_owners.get(record_id)
|
owner_queue_id = foreign_queue_owners.get(record_id)
|
||||||
if owner_queue_id:
|
if owner_queue_id:
|
||||||
self._note_queue_id_conflict(record_id, queue_id, 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
|
continue
|
||||||
records.append(record)
|
records.append(record)
|
||||||
if not records:
|
if not records:
|
||||||
|
|
@ -376,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"]))
|
||||||
|
|
@ -422,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)
|
||||||
|
|
@ -469,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
|
||||||
|
|
||||||
|
|
@ -524,6 +624,7 @@ class CopyrighterStore(
|
||||||
"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(),
|
"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]:
|
||||||
|
|
@ -733,16 +834,37 @@ class CopyrighterStore(
|
||||||
submission["decisionSnapshot"] = snapshot
|
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(
|
||||||
|
|
@ -768,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'}",
|
||||||
|
|
@ -784,6 +906,71 @@ 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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ through `Connection.backup` rather than shutil.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rights_filter.server.operator_identity import current_actor
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
@ -23,10 +24,17 @@ from typing import Any
|
||||||
# Store-owned media roots included in a snapshot, as (archive folder, attribute).
|
# Store-owned media roots included in a snapshot, as (archive folder, attribute).
|
||||||
# Submission source images are handled separately: they live in whatever folder
|
# Submission source images are handled separately: they live in whatever folder
|
||||||
# the active queue points at, which may be outside the data root.
|
# 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 = (
|
_MEDIA_SOURCES = (
|
||||||
("knowledge", "knowledge_image_dir"),
|
("knowledge", "knowledge_image_dir"),
|
||||||
("collections", "collection_image_dir"),
|
("collections", "collection_image_dir"),
|
||||||
("face-crops", "face_crop_image_dir"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_BACKUP_RETENTION = 10
|
DEFAULT_BACKUP_RETENTION = 10
|
||||||
|
|
@ -62,7 +70,7 @@ class StoreBackupMixin:
|
||||||
pruned = _prune_old_backups(backup_dir, retention)
|
pruned = _prune_old_backups(backup_dir, retention)
|
||||||
size_bytes = archive_path.stat().st_size
|
size_bytes = archive_path.stat().st_size
|
||||||
self.add_audit_event(
|
self.add_audit_event(
|
||||||
"rights.ops",
|
current_actor(),
|
||||||
"Backup snapshot created",
|
"Backup snapshot created",
|
||||||
archive_path.name,
|
archive_path.name,
|
||||||
f"{size_bytes} bytes · media {media_counts} · pruned {pruned}",
|
f"{size_bytes} bytes · media {media_counts} · pruned {pruned}",
|
||||||
|
|
@ -185,6 +193,12 @@ def restore_backup(archive_path: Path | str, target_db: Path | str) -> dict[str,
|
||||||
if not name.startswith("media/") or name.endswith("/"):
|
if not name.startswith("media/") or name.endswith("/"):
|
||||||
continue
|
continue
|
||||||
relative = Path(name).relative_to("media")
|
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(
|
destination = data_root / _MEDIA_RESTORE_DIRS.get(
|
||||||
relative.parts[0], relative.parts[0]
|
relative.parts[0], relative.parts[0]
|
||||||
)
|
)
|
||||||
|
|
@ -197,12 +211,15 @@ def restore_backup(archive_path: Path | str, target_db: Path | str) -> dict[str,
|
||||||
return {"database": str(db_path), "mediaFiles": restored_media}
|
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
|
# Archive folder -> path relative to the data root, so a restore lands where the
|
||||||
# running server expects to find each media kind.
|
# running server expects to find each media kind.
|
||||||
_MEDIA_RESTORE_DIRS = {
|
_MEDIA_RESTORE_DIRS = {
|
||||||
"knowledge": "knowledge/images",
|
"knowledge": "knowledge/images",
|
||||||
"collections": "collections/images",
|
"collections": "collections/images",
|
||||||
"face-crops": "face-crops",
|
|
||||||
# Submission images restore under the data root's default submissions folder.
|
# Submission images restore under the data root's default submissions folder.
|
||||||
# If the queue pointed elsewhere, re-import that folder after restoring.
|
# If the queue pointed elsewhere, re-import that folder after restoring.
|
||||||
"submissions": "submissions",
|
"submissions": "submissions",
|
||||||
|
|
|
||||||
|
|
@ -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": "무관",
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ 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
|
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 (
|
from rights_filter.analysis.face_person_detection import (
|
||||||
|
|
@ -50,6 +52,26 @@ 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:
|
with self._write_lock:
|
||||||
existing = {provider["id"]: provider for provider in self._all("providers")}
|
existing = {provider["id"]: provider for provider in self._all("providers")}
|
||||||
|
|
@ -167,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,
|
||||||
|
|
@ -180,6 +252,7 @@ class StoreEnrichmentMixin:
|
||||||
# A missing/unreadable image is expected for some rows, but log it so
|
# A missing/unreadable image is expected for some rows, but log it so
|
||||||
# a real disk/permission failure is not invisible.
|
# 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)
|
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)
|
||||||
|
|
@ -307,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)
|
||||||
|
|
@ -351,7 +426,7 @@ class StoreEnrichmentMixin:
|
||||||
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",
|
||||||
|
|
@ -366,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
|
||||||
|
|
||||||
|
|
@ -442,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
|
||||||
|
|
||||||
|
|
@ -615,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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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 logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
@ -46,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",
|
||||||
|
|
@ -64,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]:
|
||||||
|
|
@ -90,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)}",
|
||||||
|
|
@ -105,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 "운영자 비활성화",
|
||||||
|
|
@ -126,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,
|
||||||
|
|
@ -173,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,
|
||||||
|
|
@ -255,7 +328,7 @@ class StoreOperationsMixin:
|
||||||
}
|
}
|
||||||
self._put("knowledge_entries", entry_id, entry, conn=conn)
|
self._put("knowledge_entries", entry_id, entry, conn=conn)
|
||||||
self.add_audit_event(
|
self.add_audit_event(
|
||||||
"rights.ops",
|
current_actor(),
|
||||||
"Cleared reference registered",
|
"Cleared reference registered",
|
||||||
submission_id,
|
submission_id,
|
||||||
entry["name"],
|
entry["name"],
|
||||||
|
|
@ -320,7 +393,7 @@ class StoreOperationsMixin:
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
|
@ -385,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",
|
||||||
|
|
@ -461,7 +534,7 @@ class StoreOperationsMixin:
|
||||||
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:
|
||||||
|
|
@ -684,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",
|
||||||
|
|
@ -810,7 +883,7 @@ class StoreOperationsMixin:
|
||||||
for candidate in deduped_candidates:
|
for candidate in deduped_candidates:
|
||||||
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",
|
current_actor(),
|
||||||
"Keyword candidates collected",
|
"Keyword candidates collected",
|
||||||
provider,
|
provider,
|
||||||
f"{query} · {collected} candidates",
|
f"{query} · {collected} candidates",
|
||||||
|
|
@ -856,7 +929,7 @@ 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()
|
||||||
|
|
@ -878,7 +951,7 @@ class StoreOperationsMixin:
|
||||||
"delete from collection_candidates where id = ?", (candidate_id,)
|
"delete from collection_candidates where id = ?", (candidate_id,)
|
||||||
)
|
)
|
||||||
self.add_audit_event(
|
self.add_audit_event(
|
||||||
"rights.ops",
|
current_actor(),
|
||||||
"Keyword candidate dismissed",
|
"Keyword candidate dismissed",
|
||||||
candidate_id,
|
candidate_id,
|
||||||
f"dismissed candidate for query: {query}",
|
f"dismissed candidate for query: {query}",
|
||||||
|
|
@ -958,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",
|
||||||
|
|
|
||||||
|
|
@ -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,13 +718,55 @@ 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 /
|
# Only the live score moves. initialRiskScore / initialRiskBand /
|
||||||
# scorerVersion are the as-created stamp and must survive every rescore —
|
# scorerVersion are the as-created stamp and must survive every rescore —
|
||||||
# they are the other half of the comparison that makes score-preservation
|
# they are the other half of the comparison that makes score-preservation
|
||||||
|
|
@ -732,13 +774,37 @@ class StoreSearchCandidatesMixin:
|
||||||
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:
|
if "initialRiskScore" not in submission:
|
||||||
# Rows created before the stamp existed. Their original score was
|
# Rows created before the stamp existed. Their original score was
|
||||||
# already overwritten by this same method, so it is unrecoverable —
|
# already overwritten by this same method, so it is unrecoverable —
|
||||||
# mark them instead of fabricating one, so a later calibration can
|
# mark them instead of fabricating one, so a later calibration can
|
||||||
# exclude them rather than silently trust a round-tripped value.
|
# exclude them rather than silently trust a round-tripped value.
|
||||||
submission["scorerVersion"] = "legacy"
|
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):
|
||||||
|
|
|
||||||
|
|
@ -93,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)
|
||||||
|
|
@ -110,6 +111,10 @@ 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
|
# The score as first computed, never touched again. _rescore_submission
|
||||||
# overwrites riskScore/riskBand from 12 call sites, so without this stamp
|
# 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
|
# the stored score is always a round-tripped score and comparing it to a
|
||||||
|
|
@ -263,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", "")))
|
||||||
|
|
@ -296,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,
|
||||||
|
|
@ -371,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", ""),
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ attributes provided by the host class.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rights_filter.server.operator_identity import current_actor
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from rights_filter.server.image_store import LocalSubmissionImageStore
|
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
|
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
|
# A batch is an operator convenience, not an import path: keep it small enough
|
||||||
|
|
@ -30,6 +32,8 @@ INTERNAL_EXPORT_FIELDS = (
|
||||||
"applicantStatus",
|
"applicantStatus",
|
||||||
"riskScore",
|
"riskScore",
|
||||||
"riskBand",
|
"riskBand",
|
||||||
|
"scoredVersion",
|
||||||
|
"currentRiskScore",
|
||||||
"memo",
|
"memo",
|
||||||
"decidedAt",
|
"decidedAt",
|
||||||
"evidenceUsed",
|
"evidenceUsed",
|
||||||
|
|
@ -78,6 +82,10 @@ class StoreWorkflowMixin:
|
||||||
"initialRiskScore": submission.get("initialRiskScore"),
|
"initialRiskScore": submission.get("initialRiskScore"),
|
||||||
"initialRiskBand": submission.get("initialRiskBand"),
|
"initialRiskBand": submission.get("initialRiskBand"),
|
||||||
"scorerVersion": submission.get("scorerVersion", "legacy"),
|
"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 {}),
|
"providerState": dict(submission.get("providerState") or {}),
|
||||||
"evidenceCount": len(shown),
|
"evidenceCount": len(shown),
|
||||||
"contributedCount": sum(1 for item in shown if item["contributed"]),
|
"contributedCount": sum(1 for item in shown if item["contributed"]),
|
||||||
|
|
@ -127,7 +135,7 @@ class StoreWorkflowMixin:
|
||||||
failed.append({"submissionId": submission_id, "error": str(exc)})
|
failed.append({"submissionId": submission_id, "error": str(exc)})
|
||||||
|
|
||||||
self.add_audit_event(
|
self.add_audit_event(
|
||||||
"rights.ops",
|
current_actor(),
|
||||||
"Operator batch decision",
|
"Operator batch decision",
|
||||||
f"{decision} x{len(succeeded)}",
|
f"{decision} x{len(succeeded)}",
|
||||||
f"{len(succeeded)} applied, {len(failed)} failed · {memo or 'memo optional'}",
|
f"{len(succeeded)} applied, {len(failed)} failed · {memo or 'memo optional'}",
|
||||||
|
|
@ -172,7 +180,7 @@ class StoreWorkflowMixin:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.add_audit_event(
|
self.add_audit_event(
|
||||||
"rights.ops", "Decision memo preset saved", preset_id, clean_label
|
current_actor(), "Decision memo preset saved", preset_id, clean_label
|
||||||
)
|
)
|
||||||
return self.decision_memo_presets()
|
return self.decision_memo_presets()
|
||||||
|
|
||||||
|
|
@ -184,7 +192,7 @@ class StoreWorkflowMixin:
|
||||||
if not cursor.rowcount:
|
if not cursor.rowcount:
|
||||||
raise KeyError(preset_id)
|
raise KeyError(preset_id)
|
||||||
self.add_audit_event(
|
self.add_audit_event(
|
||||||
"rights.ops", "Decision memo preset deleted", str(preset_id), "removed"
|
current_actor(), "Decision memo preset deleted", str(preset_id), "removed"
|
||||||
)
|
)
|
||||||
return self.decision_memo_presets()
|
return self.decision_memo_presets()
|
||||||
|
|
||||||
|
|
@ -300,7 +308,7 @@ class StoreWorkflowMixin:
|
||||||
APPLICANT_EXPORT_FIELDS if audience == "applicant" else INTERNAL_EXPORT_FIELDS
|
APPLICANT_EXPORT_FIELDS if audience == "applicant" else INTERNAL_EXPORT_FIELDS
|
||||||
)
|
)
|
||||||
self.add_audit_event(
|
self.add_audit_event(
|
||||||
"rights.ops",
|
current_actor(),
|
||||||
"Decisions exported",
|
"Decisions exported",
|
||||||
audience,
|
audience,
|
||||||
f"{len(rows)} case(s) · status={wanted_status or 'all'} · since={since_text or 'all'}",
|
f"{len(rows)} case(s) · status={wanted_status or 'all'} · since={since_text or 'all'}",
|
||||||
|
|
@ -332,15 +340,35 @@ class StoreWorkflowMixin:
|
||||||
if audience == "applicant":
|
if audience == "applicant":
|
||||||
return row
|
return row
|
||||||
|
|
||||||
used = [
|
# What the operator saw WHEN THEY DECIDED, not what the case says now.
|
||||||
item
|
# Reading the live score and live "사용" marks meant a rescore or a mark
|
||||||
for item in self._evidence_for_submission(submission_id)
|
# 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"
|
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(
|
row.update(
|
||||||
{
|
{
|
||||||
"riskScore": submission.get("riskScore", 0),
|
"riskScore": score,
|
||||||
"riskBand": str(submission.get("riskBand", "")),
|
"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,
|
"decidedAt": decided_at,
|
||||||
"evidenceUsed": " | ".join(str(item.get("title", "")) for item in used),
|
"evidenceUsed": " | ".join(str(item.get("title", "")) for item in used),
|
||||||
"evidenceUrls": " | ".join(
|
"evidenceUrls": " | ".join(
|
||||||
|
|
@ -376,6 +404,9 @@ class StoreWorkflowMixin:
|
||||||
def _latest_decision_memo(self, submission_id: str) -> str:
|
def _latest_decision_memo(self, submission_id: str) -> str:
|
||||||
import json
|
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 = ""
|
memo = ""
|
||||||
for event in self._decision_events():
|
for event in self._decision_events():
|
||||||
if event["object_ref"] != submission_id:
|
if event["object_ref"] != submission_id:
|
||||||
|
|
@ -384,15 +415,17 @@ class StoreWorkflowMixin:
|
||||||
change = str(json.loads(event["payload"]).get("change", ""))
|
change = str(json.loads(event["payload"]).get("change", ""))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
# Audit change text is "<decision> · <memo>".
|
# Audit change text is "<decision> · <memo>[ · <hold marker>]".
|
||||||
_, separator, tail = change.partition(" · ")
|
_, separator, tail = change.partition(" · ")
|
||||||
if separator and tail and tail != "memo optional":
|
tail = tail.removesuffix(f" · {HOLD_WAITING_MARKER}")
|
||||||
memo = tail
|
memo = tail if separator and tail and tail != "memo optional" else ""
|
||||||
return memo
|
return memo
|
||||||
|
|
||||||
|
|
||||||
def _ratio(numerator: int, denominator: int) -> float:
|
def _ratio(numerator: int, denominator: int) -> float | None:
|
||||||
return round(numerator / denominator, 3) if denominator else 0.0
|
# 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:
|
def _rows_to_csv(rows: list[dict[str, Any]], fields: tuple[str, ...]) -> str:
|
||||||
|
|
|
||||||
21
src/rights_filter/version.py
Normal file
21
src/rights_filter/version.py
Normal 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
20
tests/conftest.py
Normal 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)
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -722,15 +722,21 @@ 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():
|
||||||
|
# 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()
|
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():
|
||||||
|
|
@ -769,6 +775,27 @@ 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()
|
script = _read_app()
|
||||||
|
|
||||||
|
|
@ -934,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
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -116,3 +116,31 @@ def test_ollama_client_sends_the_versioned_system_prompt():
|
||||||
assert result.prompt_version == PROMPT_VERSION
|
assert result.prompt_version == PROMPT_VERSION
|
||||||
assert result.model == "qwen2.5:0.5b-instruct"
|
assert result.model == "qwen2.5:0.5b-instruct"
|
||||||
assert result.latency_ms >= 0
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,34 +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.band == "medium"
|
assert result.reasons == ["Web entity matched character A"]
|
||||||
|
|
||||||
|
|
||||||
def test_search_skipped_reason_is_not_listed_as_a_risk_reason():
|
def _label(name: str, confidence: float) -> Evidence:
|
||||||
# 2026-07-11 #16: a zero-point skip ("quota reached") is operational status,
|
# A bare Google Vision web entity: descriptive, no category, no image match.
|
||||||
# not a risk signal, and must not pollute the operator-facing reasons list.
|
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(
|
result = RiskScorer().score(
|
||||||
[
|
[
|
||||||
Evidence(
|
_label("Dog", 0.8),
|
||||||
source=EvidenceSource.SEARCH_SKIPPED,
|
_label("Pet", 0.8),
|
||||||
reason="search API usage limit reached",
|
_label("Grass", 0.8),
|
||||||
confidence=1.0,
|
_label("Canidae", 0.8),
|
||||||
data={},
|
_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 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.score == 0
|
||||||
assert result.reasons == []
|
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"])
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import sqlite3
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from rights_filter.analysis.face_person_detection import FacePersonSignal
|
from rights_filter.analysis.face_person_detection import FacePersonSignal
|
||||||
|
from rights_filter.analysis.risk_scoring import SCORER_VERSION
|
||||||
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 import sqlite_store as sqlite_store_module
|
from rights_filter.server import sqlite_store as sqlite_store_module
|
||||||
|
|
@ -17,6 +18,8 @@ from rights_filter.server import store_enrichment as store_enrichment_module
|
||||||
from rights_filter.server.sqlite_store import CopyrighterStore
|
from rights_filter.server.sqlite_store import CopyrighterStore
|
||||||
from rights_filter.integrations.env_clients import build_provider_runtime
|
from rights_filter.integrations.env_clients import build_provider_runtime
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
class FakeJsonTransport:
|
class FakeJsonTransport:
|
||||||
def __init__(self, response=None):
|
def __init__(self, response=None):
|
||||||
|
|
@ -261,6 +264,9 @@ def test_http_server_exposes_health_bootstrap_and_mutations(tmp_path: Path):
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
|
|
||||||
assert health["status"] == "ok"
|
assert health["status"] == "ok"
|
||||||
|
# The build and scorer behind the scores on screen, from the single VERSION source.
|
||||||
|
assert health["version"] == (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
assert health["scorerVersion"] == SCORER_VERSION
|
||||||
assert bootstrap["submissions"][0]["id"] == "SUB-API1"
|
assert bootstrap["submissions"][0]["id"] == "SUB-API1"
|
||||||
assert decision["decisionStatus"] == "held"
|
assert decision["decisionStatus"] == "held"
|
||||||
assert next(provider for provider in providers if provider["id"] == "naver")["enabled"] is False
|
assert next(provider for provider in providers if provider["id"] == "naver")["enabled"] is False
|
||||||
|
|
@ -1643,3 +1649,33 @@ def test_operational_error_surfaces_as_503(tmp_path: Path, monkeypatch):
|
||||||
assert exc.value.code == 503
|
assert exc.value.code == 503
|
||||||
finally:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_events_name_the_operator_the_console_sent(tmp_path: Path):
|
||||||
|
# Persona review 2026-09-25 (법무 2-A): every audit actor was "rights.ops",
|
||||||
|
# so "who rejected this?" had no answer.
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
static_dir, image_store, store = _fixtures(tmp_path)
|
||||||
|
server = build_server("127.0.0.1", 0, store, image_store=image_store, static_dir=static_dir)
|
||||||
|
_start(server)
|
||||||
|
base = f"http://127.0.0.1:{server.server_port}"
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
try:
|
||||||
|
request = Request(
|
||||||
|
f"{base}/api/submissions/{submission_id}/decision",
|
||||||
|
data=json.dumps({"decision": "rejected", "memo": "타인 저작물"}).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json", "X-Operator": quote("김심사\r\nforged")},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=10):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
decision_event = next(event for event in store.audit_events() if event["event"] == "Operator decision created")
|
||||||
|
# Control characters are dropped so a name cannot forge a second log line.
|
||||||
|
assert decision_event["actor"] == "operator:김심사forged"
|
||||||
|
# Outside a request (seeding) the shared default is kept.
|
||||||
|
assert any(event["actor"] == "rights.system" or event["actor"] == "rights.ops" for event in store.audit_events())
|
||||||
|
|
|
||||||
|
|
@ -139,3 +139,18 @@ def test_local_image_store_skips_manifest_paths_outside_root_without_losing_vali
|
||||||
assert "SUB-BAD" not in ids
|
assert "SUB-BAD" not in ids
|
||||||
assert "SUB-GOOD" in ids
|
assert "SUB-GOOD" in ids
|
||||||
assert all("secret" not in str(record["file"]) for record in records)
|
assert all("secret" not in str(record["file"]) for record in records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_stem_files_in_different_folders_are_reported_not_silently_merged(tmp_path: Path):
|
||||||
|
# codex F6 (2026-09-23): the submission id is the file stem, so two applicants'
|
||||||
|
# photo.jpg / photo.png became one case and the second was never reviewed.
|
||||||
|
for folder, suffix in (("applicant-a", "jpg"), ("applicant-b", "png")):
|
||||||
|
(tmp_path / folder).mkdir()
|
||||||
|
(tmp_path / folder / f"photo.{suffix}").write_bytes(b"x")
|
||||||
|
(tmp_path / "unique.jpg").write_bytes(b"y")
|
||||||
|
|
||||||
|
collisions = LocalSubmissionImageStore(tmp_path).id_collisions()
|
||||||
|
|
||||||
|
assert collisions == [
|
||||||
|
{"id": "photo", "kept": "applicant-a/photo.jpg", "skipped": ["applicant-b/photo.png"]}
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -151,3 +151,14 @@ def test_llm_summary_body_survives_serialization():
|
||||||
assert payload["sourceUrls"] == ["https://example.test/a"]
|
assert payload["sourceUrls"] == ["https://example.test/a"]
|
||||||
restored = _domain_evidence_from_ui(payload)
|
restored = _domain_evidence_from_ui(payload)
|
||||||
assert restored.data["summary"] == summary.data["summary"]
|
assert restored.data["summary"] == summary.data["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_band_reports_no_measurement_instead_of_zero_percent(tmp_path: Path):
|
||||||
|
# codex 3 (2026-09-23): with nothing decided in the low band the rate was 0.0
|
||||||
|
# and the console showed "미탐 0%" - a measurement that was never taken.
|
||||||
|
store = _store(tmp_path)
|
||||||
|
|
||||||
|
calibration = store.decision_stats()["calibration"]
|
||||||
|
|
||||||
|
assert calibration["lowBandRejectionRate"] is None
|
||||||
|
assert calibration["highBandApprovalRate"] is None
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from rights_filter.analysis.face_person_detection import (
|
||||||
face_person_reason,
|
face_person_reason,
|
||||||
)
|
)
|
||||||
from rights_filter.analysis.preprocessing import ImagePayload, build_face_crop_derivatives
|
from rights_filter.analysis.preprocessing import ImagePayload, build_face_crop_derivatives
|
||||||
|
from rights_filter.analysis.risk_scoring import FACE_PERSON_IDENTIFIABLE_POINTS, SCORER_VERSION
|
||||||
from rights_filter.domain.records import Evidence, EvidenceSource
|
from rights_filter.domain.records import Evidence, EvidenceSource
|
||||||
from rights_filter.server.image_store import LocalSubmissionImageStore
|
from rights_filter.server.image_store import LocalSubmissionImageStore
|
||||||
from rights_filter.server import sqlite_store as sqlite_store_module
|
from rights_filter.server import sqlite_store as sqlite_store_module
|
||||||
|
|
@ -141,6 +142,27 @@ def _image_store(tmp_path: Path) -> LocalSubmissionImageStore:
|
||||||
return LocalSubmissionImageStore(tmp_path)
|
return LocalSubmissionImageStore(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_submission_record(image_root: Path, record_id: str, file_name: str) -> None:
|
||||||
|
"""Drop one more image + manifest row into an existing queue folder."""
|
||||||
|
(image_root / "images" / file_name).write_text(
|
||||||
|
"<svg><!-- FACE PERSON --></svg>",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manifest_path = image_root / "submissions.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest.append(
|
||||||
|
{
|
||||||
|
"id": record_id,
|
||||||
|
"title": "Late arrival",
|
||||||
|
"file": f"images/{file_name}",
|
||||||
|
"width": 800,
|
||||||
|
"height": 600,
|
||||||
|
"submitted_at": "2026-05-26 11:00",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _png_image_store(tmp_path: Path) -> LocalSubmissionImageStore:
|
def _png_image_store(tmp_path: Path) -> LocalSubmissionImageStore:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
|
@ -768,6 +790,93 @@ def test_sqlite_store_watchlist_similarity_is_high_risk_but_separate_from_confir
|
||||||
assert watchlist_entry["contributionCount"] == 1
|
assert watchlist_entry["contributionCount"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_store_hold_while_search_was_down_does_not_create_a_watchlist_entry(tmp_path: Path):
|
||||||
|
# codex F1 (2026-09-23): a hold taken because the external check had not run
|
||||||
|
# became a watchlist entry, and the next submission of the same image scored
|
||||||
|
# as a known rights subject. "Could not check" is not "suspect".
|
||||||
|
image_store = _image_store(tmp_path / "submissions")
|
||||||
|
db_path = tmp_path / "copyrighter.sqlite3"
|
||||||
|
store = CopyrighterStore(db_path)
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(image_store)
|
||||||
|
store._put(
|
||||||
|
"evidence",
|
||||||
|
"ev-naver-down",
|
||||||
|
{
|
||||||
|
"id": "ev-naver-down",
|
||||||
|
"group": "failures",
|
||||||
|
"source": "failure",
|
||||||
|
"domainSource": "failure",
|
||||||
|
"title": "External API failed: naver timeout",
|
||||||
|
"confidence": 1,
|
||||||
|
"query": "",
|
||||||
|
"domain": "naver",
|
||||||
|
"url": "",
|
||||||
|
"retrievedAt": "2026-09-23 09:00:00",
|
||||||
|
"contributed": False,
|
||||||
|
"sourceEvidenceIds": [],
|
||||||
|
"status": "active",
|
||||||
|
"submission_id": "SUB-DB1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
store.record_decision("SUB-DB1", "held", "검색 복구 후 재확인", image_store=image_store)
|
||||||
|
|
||||||
|
assert store.bootstrap()["knowledgeEntries"] == []
|
||||||
|
decision_event = next(
|
||||||
|
event for event in store.audit_events() if event["event"] == "Operator decision created"
|
||||||
|
)
|
||||||
|
assert "검색 대기" in decision_event["change"]
|
||||||
|
|
||||||
|
# A rejection is a judgement about the image regardless of coverage.
|
||||||
|
store.record_decision("SUB-DB1", "rejected", "타인 저작물", image_store=image_store)
|
||||||
|
|
||||||
|
assert [entry["entryStatus"] for entry in store.bootstrap()["knowledgeEntries"]] == ["watchlist"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_store_approval_withdraws_the_watchlist_entry_and_its_matches(tmp_path: Path):
|
||||||
|
# codex F4 / 4.2 / 4.5 (2026-09-23): reversing a hold into an approval left
|
||||||
|
# the derived "suspect" entry active, and every case that had matched it
|
||||||
|
# kept its score because the scorer reads stored match rows, not the entry.
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
image_store = _image_store(image_root)
|
||||||
|
db_path = tmp_path / "copyrighter.sqlite3"
|
||||||
|
store = CopyrighterStore(db_path)
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(image_store)
|
||||||
|
store.record_decision("SUB-DB1", "held", "권리 확인 필요", image_store=image_store)
|
||||||
|
(image_root / "images" / "copy.svg").write_text("<svg><!-- FACE PERSON --></svg>", encoding="utf-8")
|
||||||
|
(image_root / "submissions.json").write_text(
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{"id": "SUB-DB1", "title": "DB sample", "file": "images/sample.svg", "width": 800, "height": 600, "submitted_at": "2026-05-26 10:30"},
|
||||||
|
{"id": "SUB-DB2", "title": "Copied DB sample", "file": "images/copy.svg", "width": 800, "height": 600, "submitted_at": "2026-05-26 10:40"}
|
||||||
|
]
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
|
||||||
|
before = store.review("SUB-DB2")
|
||||||
|
assert any(item["key"] == "watchlist" for item in before["riskBreakdown"]["components"])
|
||||||
|
|
||||||
|
store.record_decision("SUB-DB1", "approved", "", image_store=image_store)
|
||||||
|
|
||||||
|
entry = store.bootstrap()["knowledgeEntries"][0]
|
||||||
|
assert entry["entryStatus"] == "excluded"
|
||||||
|
assert entry["active"] is False
|
||||||
|
assert "자동 철회" in entry["excludedReason"]
|
||||||
|
after = store.review("SUB-DB2")
|
||||||
|
assert all(item["key"] != "watchlist" for item in after["riskBreakdown"]["components"])
|
||||||
|
assert after["riskScore"] < before["riskScore"]
|
||||||
|
match_row = next(item for item in after["evidence"] if item.get("knowledgeEntryStatus") == "watchlist")
|
||||||
|
assert match_row["contributed"] is False
|
||||||
|
assert any(event["event"] == "Watchlist candidate withdrawn" for event in store.audit_events())
|
||||||
|
|
||||||
|
# A later hold must not resurrect the withdrawn candidate.
|
||||||
|
store.record_decision("SUB-DB1", "held", "다시 보류", image_store=image_store)
|
||||||
|
assert store.bootstrap()["knowledgeEntries"][0]["entryStatus"] == "excluded"
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_store_promotes_and_excludes_watchlist_candidates(tmp_path: Path):
|
def test_sqlite_store_promotes_and_excludes_watchlist_candidates(tmp_path: Path):
|
||||||
image_store = _image_store(tmp_path / "submissions")
|
image_store = _image_store(tmp_path / "submissions")
|
||||||
db_path = tmp_path / "copyrighter.sqlite3"
|
db_path = tmp_path / "copyrighter.sqlite3"
|
||||||
|
|
@ -1011,6 +1120,77 @@ def test_sqlite_store_prunes_submission_records_when_source_file_is_deleted(tmp_
|
||||||
assert conn.execute("select count(*) from evidence").fetchone()[0] == 0
|
assert conn.execute("select count(*) from evidence").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_store_keeps_decided_submission_when_source_file_is_deleted(tmp_path: Path):
|
||||||
|
# Archiving a reviewed image out of the queue folder is ordinary operator
|
||||||
|
# housekeeping. It used to delete the submission row — and its evidence, via
|
||||||
|
# the cascade — taking the approval/rejection out of the DB and out of
|
||||||
|
# decision exports with no warning.
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
image_store = _image_store(image_root)
|
||||||
|
db_path = tmp_path / "copyrighter.sqlite3"
|
||||||
|
store = CopyrighterStore(db_path)
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(image_store)
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
store.record_decision(submission_id, "rejected", memo="third-party photo")
|
||||||
|
|
||||||
|
(image_root / "images" / "sample.svg").unlink()
|
||||||
|
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
|
||||||
|
|
||||||
|
submissions = store.bootstrap()["submissions"]
|
||||||
|
assert [str(item["id"]) for item in submissions] == [submission_id]
|
||||||
|
assert submissions[0]["decisionStatus"] == "rejected"
|
||||||
|
assert submissions[0]["assetMissing"] is True
|
||||||
|
assert store.export_decisions(audience="internal")["rows"]
|
||||||
|
|
||||||
|
# The retain notice is written once, not on every reload.
|
||||||
|
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
|
||||||
|
retained_events = [
|
||||||
|
event
|
||||||
|
for event in store.audit_events()
|
||||||
|
if event.get("event") == "Submission retained"
|
||||||
|
]
|
||||||
|
assert len(retained_events) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_store_emergency_disable_stops_automatic_external_calls(tmp_path: Path):
|
||||||
|
# The switch used to write provider flags that no automatic path read, so
|
||||||
|
# folder reload / rerun-enrichment kept calling out after an operator had
|
||||||
|
# pressed stop.
|
||||||
|
transport = FakeJsonTransport({"items": []})
|
||||||
|
runtime = build_provider_runtime(
|
||||||
|
env={
|
||||||
|
"NAVER_CLIENT_ID": "naver-id",
|
||||||
|
"NAVER_CLIENT_SECRET": "naver-secret",
|
||||||
|
"GOOGLE_CLOUD_VISION_API_KEY": "google-key",
|
||||||
|
"GOOGLE_CUSTOM_SEARCH_API_KEY": "google-search-key",
|
||||||
|
"GOOGLE_CUSTOM_SEARCH_CX": "search-engine-id",
|
||||||
|
},
|
||||||
|
transport=transport,
|
||||||
|
)
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
image_store = _image_store(image_root)
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3", provider_runtime=runtime)
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(image_store)
|
||||||
|
assert transport.calls, "seeding should reach the external providers"
|
||||||
|
|
||||||
|
store.emergency_disable_external_providers()
|
||||||
|
calls_at_stop = len(transport.calls)
|
||||||
|
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
# A NEW file has to arrive for the seed path to reach its provider gate.
|
||||||
|
# Re-seeding the same folder returns at the "nothing new to analyze" guard
|
||||||
|
# eleven lines earlier, so the gate could be deleted outright and a
|
||||||
|
# re-seed-only assertion would still pass.
|
||||||
|
_append_submission_record(image_root, "SUB-LATE", "late.svg")
|
||||||
|
seeded = store.seed_from_image_store(LocalSubmissionImageStore(image_root))
|
||||||
|
store.rerun_enrichment(submission_id, image_store)
|
||||||
|
|
||||||
|
assert seeded == 1, "the new record must actually reach the seed path"
|
||||||
|
assert len(transport.calls) == calls_at_stop
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_store_refreshes_existing_local_face_evidence_when_reloaded(tmp_path: Path, monkeypatch):
|
def test_sqlite_store_refreshes_existing_local_face_evidence_when_reloaded(tmp_path: Path, monkeypatch):
|
||||||
class NoFaceDetector:
|
class NoFaceDetector:
|
||||||
def detect(self, image):
|
def detect(self, image):
|
||||||
|
|
@ -1069,7 +1249,7 @@ def test_sqlite_store_rescore_ignores_non_contributing_queued_rerun_marker(tmp_p
|
||||||
store._rescore_submission("SUB-DB1")
|
store._rescore_submission("SUB-DB1")
|
||||||
review = store.review("SUB-DB1")
|
review = store.review("SUB-DB1")
|
||||||
|
|
||||||
assert review["riskScore"] == 35
|
assert review["riskScore"] == FACE_PERSON_IDENTIFIABLE_POINTS
|
||||||
assert review["riskBand"] == "medium"
|
assert review["riskBand"] == "medium"
|
||||||
assert IDENTIFIABLE_FACE_REASON in review["reasons"]
|
assert IDENTIFIABLE_FACE_REASON in review["reasons"]
|
||||||
assert "재분석 요청이 접수됨" not in review["reasons"]
|
assert "재분석 요청이 접수됨" not in review["reasons"]
|
||||||
|
|
@ -8025,7 +8205,7 @@ def test_sqlite_store_migrates_google_best_guess_labels_to_non_scoring_hints(tmp
|
||||||
|
|
||||||
weak_label = next(item for item in review["evidence"] if item["source"] == "google")
|
weak_label = next(item for item in review["evidence"] if item["source"] == "google")
|
||||||
|
|
||||||
assert review["riskScore"] == 35
|
assert review["riskScore"] == FACE_PERSON_IDENTIFIABLE_POINTS
|
||||||
assert review["riskBand"] == "medium"
|
assert review["riskBand"] == "medium"
|
||||||
assert review["reasons"] == [IDENTIFIABLE_FACE_REASON]
|
assert review["reasons"] == [IDENTIFIABLE_FACE_REASON]
|
||||||
assert weak_label["title"] == "Google weak label gentleman"
|
assert weak_label["title"] == "Google weak label gentleman"
|
||||||
|
|
@ -8069,7 +8249,7 @@ def test_sqlite_store_rescores_existing_saved_google_best_guess_labels(tmp_path:
|
||||||
review = reopened.review("SUB-DB1")
|
review = reopened.review("SUB-DB1")
|
||||||
weak_label = next(item for item in review["evidence"] if item["id"] == "ev-old-google-label")
|
weak_label = next(item for item in review["evidence"] if item["id"] == "ev-old-google-label")
|
||||||
|
|
||||||
assert review["riskScore"] == 35
|
assert review["riskScore"] == FACE_PERSON_IDENTIFIABLE_POINTS
|
||||||
assert review["riskBand"] == "medium"
|
assert review["riskBand"] == "medium"
|
||||||
assert IDENTIFIABLE_FACE_REASON in review["reasons"]
|
assert IDENTIFIABLE_FACE_REASON in review["reasons"]
|
||||||
assert "Best guess label gentleman" not in review["reasons"]
|
assert "Best guess label gentleman" not in review["reasons"]
|
||||||
|
|
@ -8400,3 +8580,382 @@ def test_purge_expired_face_crops_keeps_recent_crops(tmp_path: Path):
|
||||||
|
|
||||||
assert store.purge_expired_face_crops() == 0
|
assert store.purge_expired_face_crops() == 0
|
||||||
assert crop_file.exists()
|
assert crop_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_missing_marker_is_cleared_when_the_source_file_comes_back(tmp_path: Path):
|
||||||
|
# Archiving a decided image out of the queue folder is reversible, so the
|
||||||
|
# marker has to be too. Left permanently set it became a lie in the console,
|
||||||
|
# and the mark-once guard then suppressed the audit event for a later,
|
||||||
|
# genuine disappearance - the second loss was invisible in the audit trail.
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
image_store = _image_store(image_root)
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(image_store)
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
store.record_decision(submission_id, "rejected", memo="third-party photo")
|
||||||
|
|
||||||
|
image_path = image_root / "images" / "sample.svg"
|
||||||
|
archived = image_path.read_text(encoding="utf-8")
|
||||||
|
image_path.unlink()
|
||||||
|
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
|
||||||
|
assert store._get("submissions", submission_id)["assetMissing"] is True
|
||||||
|
|
||||||
|
image_path.write_text(archived, encoding="utf-8")
|
||||||
|
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
|
||||||
|
assert store._get("submissions", submission_id)["assetMissing"] is False
|
||||||
|
assert any(event["event"] == "Submission asset restored" for event in store.audit_events())
|
||||||
|
|
||||||
|
image_path.unlink()
|
||||||
|
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
|
||||||
|
retained = [event for event in store.audit_events() if event["event"] == "Submission retained"]
|
||||||
|
assert len(retained) == 2, "a second, genuine loss has to be audited too"
|
||||||
|
|
||||||
|
|
||||||
|
def _submission_with_face_crop_reference(store: CopyrighterStore, submission_id: str) -> None:
|
||||||
|
store._put(
|
||||||
|
"submissions",
|
||||||
|
submission_id,
|
||||||
|
{
|
||||||
|
"id": submission_id,
|
||||||
|
"title": "t",
|
||||||
|
"asset": "",
|
||||||
|
"riskScore": 0,
|
||||||
|
"riskBand": "low",
|
||||||
|
"decisionStatus": "unreviewed",
|
||||||
|
"providerState": {},
|
||||||
|
"fileFacts": {},
|
||||||
|
"evidence": [],
|
||||||
|
"faceCrops": [
|
||||||
|
{"index": 1, "url": f"/face-crop-media/{submission_id}/crop-1.jpg", "box": [0, 0, 1, 1]}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_detects_face_crop_references_whose_files_are_gone(tmp_path: Path):
|
||||||
|
# Archive restore skips retention-bound media on purpose, so the DB comes
|
||||||
|
# back listing biometric thumbnails whose files are not there. The rebuild
|
||||||
|
# used to be gated on the key being absent, and purge_expired_face_crops
|
||||||
|
# only walks files that exist - so nothing reconciled the reference and the
|
||||||
|
# console rendered a permanently broken crop with no recovery path.
|
||||||
|
store = CopyrighterStore(tmp_path / "c.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
_submission_with_face_crop_reference(store, "SUB-CROP")
|
||||||
|
|
||||||
|
assert store._face_crops_need_sync(store._get("submissions", "SUB-CROP")) is True
|
||||||
|
|
||||||
|
crop_dir = store.face_crop_image_dir / "SUB-CROP"
|
||||||
|
crop_dir.mkdir(parents=True)
|
||||||
|
(crop_dir / "crop-1.jpg").write_bytes(b"jpegdata")
|
||||||
|
assert store._face_crops_need_sync(store._get("submissions", "SUB-CROP")) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_face_crop_references_are_cleared_when_there_is_nothing_to_rebuild_from(tmp_path: Path):
|
||||||
|
# Reached when the original image is gone too. Clearing is the only honest
|
||||||
|
# outcome: the row cannot serve the crop and cannot regenerate it.
|
||||||
|
store = CopyrighterStore(tmp_path / "c.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
_submission_with_face_crop_reference(store, "SUB-CROP")
|
||||||
|
|
||||||
|
store._sync_face_crops("SUB-CROP", _image_store(tmp_path / "submissions"))
|
||||||
|
|
||||||
|
assert store._get("submissions", "SUB-CROP")["faceCrops"] == []
|
||||||
|
assert any(event["event"] == "Face crop reference cleared" for event in store.audit_events())
|
||||||
|
|
||||||
|
|
||||||
|
def test_rescore_audits_a_band_change_caused_by_a_scorer_migration(tmp_path: Path):
|
||||||
|
# A weight change rescores every row on the next folder reload. Without a
|
||||||
|
# trace, a case an operator rejected as "high" displays as "medium" with
|
||||||
|
# different reasons and nothing says a scorer migration - rather than new
|
||||||
|
# evidence - moved it.
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(_image_store(image_root))
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
|
||||||
|
submission = store._get("submissions", submission_id)
|
||||||
|
submission["scoredVersion"] = "v1"
|
||||||
|
submission["riskBand"] = "high"
|
||||||
|
submission["riskScore"] = 100
|
||||||
|
store._put("submissions", submission_id, submission)
|
||||||
|
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
|
||||||
|
events = [event for event in store.audit_events() if event["event"] == "Risk rescored"]
|
||||||
|
assert len(events) == 1
|
||||||
|
assert f"v1 -> {SCORER_VERSION}" in events[0]["change"]
|
||||||
|
assert store._get("submissions", submission_id)["scoredVersion"] == SCORER_VERSION
|
||||||
|
|
||||||
|
# An ordinary rescore on the same version stays silent, so repeat reloads do
|
||||||
|
# not spend the audit cap.
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
assert len([event for event in store.audit_events() if event["event"] == "Risk rescored"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_host_survives_repeated_persistence_round_trips():
|
||||||
|
# The round trip drops `provider` but keeps `domain` (which holds the
|
||||||
|
# provider NAME), so re-serializing a restored row used to resolve the host
|
||||||
|
# back to "google_custom_search" - the bug this field was added to fix,
|
||||||
|
# reintroduced silently with every test still green.
|
||||||
|
fresh = Evidence(
|
||||||
|
source=EvidenceSource.WEB_DETECTION,
|
||||||
|
reason="Google custom web search result found",
|
||||||
|
confidence=0.35,
|
||||||
|
data={
|
||||||
|
"provider": "google_custom_search",
|
||||||
|
"domain": "blog.example.test",
|
||||||
|
"search_type": "web",
|
||||||
|
"match": "page",
|
||||||
|
"result_url": "https://blog.example.test/post/1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
first = store_serialization_module._evidence_payload("SUB-1", fresh)
|
||||||
|
second = store_serialization_module._evidence_payload(
|
||||||
|
"SUB-1", store_serialization_module._domain_evidence_from_ui(first)
|
||||||
|
)
|
||||||
|
third = store_serialization_module._evidence_payload(
|
||||||
|
"SUB-1", store_serialization_module._domain_evidence_from_ui(second)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first["host"] == "blog.example.test"
|
||||||
|
assert second["host"] == "blog.example.test"
|
||||||
|
assert third["host"] == "blog.example.test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_names_are_not_accepted_as_publishing_hosts():
|
||||||
|
# urlparse("//google") reports "google" as a hostname, which is how an API
|
||||||
|
# name ended up rendered as the place an image was published.
|
||||||
|
hostname_of = store_serialization_module._hostname_of
|
||||||
|
|
||||||
|
assert hostname_of("google_custom_search") == ""
|
||||||
|
assert hostname_of("internal") == ""
|
||||||
|
assert hostname_of("biz.chosun.com") == "biz.chosun.com"
|
||||||
|
assert hostname_of("https://www.Example.co.kr/a.jpg") == "example.co.kr"
|
||||||
|
|
||||||
|
|
||||||
|
def _web_evidence_on(store: CopyrighterStore, submission_id: str, host: str) -> None:
|
||||||
|
"""제출물에 '이 호스트에서 완전일치를 찾았다' 증거 한 행을 붙인다."""
|
||||||
|
evidence = Evidence(
|
||||||
|
source=EvidenceSource.WEB_DETECTION,
|
||||||
|
reason=f"{host} 에서 완전일치",
|
||||||
|
confidence=0.9,
|
||||||
|
data={"match": "full", "provider": "google", "host": host, "url": f"https://{host}/a.jpg"},
|
||||||
|
)
|
||||||
|
payload = store_serialization_module._evidence_payload(submission_id, evidence)
|
||||||
|
store._put("evidence", payload["id"], payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_registered_trusted_host_removes_its_web_presence_from_the_score(tmp_path: Path):
|
||||||
|
# 자사·계약처 도메인에서 발견된 것은 침해 신호가 아니다. 운영자가 그 목록을
|
||||||
|
# 지식DB 에 넣으면 점수가 그것을 반영해야 한다 - 매번 눈으로 "이건 우리
|
||||||
|
# 사이트잖아"를 확인하는 노동이 이 기능의 대상이다.
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(_image_store(image_root))
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
_web_evidence_on(store, submission_id, "cdn.posar.info")
|
||||||
|
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
before = store._get("submissions", submission_id)["riskScore"]
|
||||||
|
|
||||||
|
store.register_manual_knowledge_entry({"name": "posar.info", "type": "trusted_host"})
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
after = store._get("submissions", submission_id)
|
||||||
|
|
||||||
|
assert before > after["riskScore"], "신뢰 출처 등록이 점수를 낮춰야 한다"
|
||||||
|
assert any("신뢰 출처" in reason for reason in after["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_registered_news_host_labels_the_headline_but_leaves_the_score(tmp_path: Path):
|
||||||
|
# Persona review 2026-09-25 (운영자 1-B): display-only host categories.
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(_image_store(image_root))
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
_web_evidence_on(store, submission_id, "photo.news.example.kr")
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
before = store._get("submissions", submission_id)
|
||||||
|
|
||||||
|
store.register_manual_knowledge_entry({"name": "news.example.kr", "type": "news_host"})
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
after = store._get("submissions", submission_id)
|
||||||
|
|
||||||
|
assert after["riskScore"] == before["riskScore"]
|
||||||
|
assert "(언론·공식 1)" in after["riskBreakdown"]["headline"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deactivating_a_trusted_host_entry_restores_the_score(tmp_path: Path):
|
||||||
|
# 신뢰 철회에 별도 스위치를 두지 않는다 - 지식 항목의 기존 활성/비활성
|
||||||
|
# 수명주기를 그대로 쓴다.
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(_image_store(image_root))
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
_web_evidence_on(store, submission_id, "posar.info")
|
||||||
|
|
||||||
|
entry = store.register_manual_knowledge_entry({"name": "posar.info", "type": "trusted_host"})
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
trusted_score = store._get("submissions", submission_id)["riskScore"]
|
||||||
|
|
||||||
|
stored = store._get("knowledge_entries", entry["id"])
|
||||||
|
stored["active"] = False
|
||||||
|
store._put("knowledge_entries", entry["id"], stored)
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
|
||||||
|
assert store._get("submissions", submission_id)["riskScore"] > trusted_score
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_hosts_reads_name_and_aliases_of_active_entries(tmp_path: Path):
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.register_manual_knowledge_entry(
|
||||||
|
{"name": "posar.info", "type": "trusted_host", "aliases": ["img.partner.example", "www.Partner.example"]}
|
||||||
|
)
|
||||||
|
store.register_manual_knowledge_entry({"name": "someone", "type": "celebrity"})
|
||||||
|
|
||||||
|
hosts = store._trusted_hosts()
|
||||||
|
|
||||||
|
assert "posar.info" in hosts
|
||||||
|
assert "img.partner.example" in hosts
|
||||||
|
# www. 는 저장 시점에 떼어 둔다 - 증거 쪽 호스트도 www 가 없는 형태다.
|
||||||
|
assert "partner.example" in hosts
|
||||||
|
assert "someone" not in hosts
|
||||||
|
|
||||||
|
|
||||||
|
def _web_rows(store: CopyrighterStore, submission_id: str, count: int) -> list[str]:
|
||||||
|
"""제출물에 점수를 만드는 웹 탐지 행 여러 개를 붙이고 그 id 를 준다."""
|
||||||
|
ids = []
|
||||||
|
for index in range(count):
|
||||||
|
evidence = Evidence(
|
||||||
|
source=EvidenceSource.WEB_DETECTION,
|
||||||
|
reason=f"결과 {index}",
|
||||||
|
confidence=0.9,
|
||||||
|
data={
|
||||||
|
"match": "full",
|
||||||
|
"provider": "google",
|
||||||
|
"host": f"host{index}.example.test",
|
||||||
|
"url": f"https://host{index}.example.test/a.jpg",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = store_serialization_module._evidence_payload(submission_id, evidence)
|
||||||
|
store._put("evidence", payload["id"], payload)
|
||||||
|
ids.append(payload["id"])
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_status_batch_marks_every_row_with_one_audit_event(tmp_path: Path):
|
||||||
|
# 단건 API 만 있던 동안 운영자 되먹임은 순증 노동이었다 — 한 케이스를
|
||||||
|
# 100 에서 35 로 내리려면 증거 51행을 개별로 눌러야 했다. 일괄 표시는 한 번의
|
||||||
|
# 동작이므로 감사 이벤트도 1건이어야 한다(행마다 쓰면 500행 캡을 먹는다).
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(_image_store(image_root))
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
ids = _web_rows(store, submission_id, 5)
|
||||||
|
store._rescore_submission(submission_id)
|
||||||
|
before = store._get("submissions", submission_id)["riskScore"]
|
||||||
|
|
||||||
|
store.mark_evidence_status_batch(submission_id, ids, "irrelevant", note="자사 공지 페이지")
|
||||||
|
|
||||||
|
after = store._get("submissions", submission_id)
|
||||||
|
assert after["riskScore"] < before, "무관 표시가 점수에서 빠져야 한다"
|
||||||
|
for evidence_id in ids:
|
||||||
|
payload = store._get("evidence", evidence_id)
|
||||||
|
assert payload["operatorStatus"] == "irrelevant"
|
||||||
|
assert payload["contributed"] is False
|
||||||
|
# 행별 who/when/note 는 그대로 남는다 — 감사 이벤트를 묶었다고 해서
|
||||||
|
# 개별 행이 설명 불가가 되지는 않는다.
|
||||||
|
assert payload["operatorStatusNote"] == "자사 공지 페이지"
|
||||||
|
assert payload["operatorStatusActor"] == "rights.ops"
|
||||||
|
|
||||||
|
batch_events = [
|
||||||
|
event for event in store.audit_events()
|
||||||
|
if event.get("event") == "Evidence status changed in batch"
|
||||||
|
]
|
||||||
|
assert len(batch_events) == 1
|
||||||
|
assert "5건" in batch_events[0]["change"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_status_batch_rejects_a_foreign_evidence_id_without_marking_anything(tmp_path: Path):
|
||||||
|
# 절반만 표시된 상태로 끝나면 운영자는 무엇이 반영됐는지 알 수 없다.
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
store.seed_from_image_store(_image_store(image_root))
|
||||||
|
submission_id = str(store.bootstrap()["submissions"][0]["id"])
|
||||||
|
ids = _web_rows(store, submission_id, 3)
|
||||||
|
# evidence.submission_id 는 submissions 에 FK 이므로 남의 제출물이 실재해야 한다.
|
||||||
|
store._put(
|
||||||
|
"submissions",
|
||||||
|
"SUB-OTHER",
|
||||||
|
{
|
||||||
|
"id": "SUB-OTHER",
|
||||||
|
"title": "다른 케이스",
|
||||||
|
"asset": "",
|
||||||
|
"riskScore": 0,
|
||||||
|
"riskBand": "low",
|
||||||
|
"decisionStatus": "unreviewed",
|
||||||
|
"providerState": {},
|
||||||
|
"fileFacts": {},
|
||||||
|
"evidence": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
foreign = store_serialization_module._evidence_payload(
|
||||||
|
"SUB-OTHER",
|
||||||
|
Evidence(EvidenceSource.WEB_DETECTION, "남의 증거", 0.9, {"match": "full", "host": "x.example.test"}),
|
||||||
|
)
|
||||||
|
store._put("evidence", foreign["id"], foreign)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="does not belong"):
|
||||||
|
store.mark_evidence_status_batch(submission_id, [*ids, foreign["id"]], "irrelevant")
|
||||||
|
|
||||||
|
for evidence_id in ids:
|
||||||
|
assert store._get("evidence", evidence_id).get("operatorStatus", "") != "irrelevant"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_status_batch_refuses_an_empty_or_oversized_batch(tmp_path: Path):
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="evidence ids required"):
|
||||||
|
store.mark_evidence_status_batch("SUB-1", [], "irrelevant")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="batch too large"):
|
||||||
|
store.mark_evidence_status_batch(
|
||||||
|
"SUB-1", [f"ev-{index}" for index in range(sqlite_store_module.MAX_EVIDENCE_STATUS_BATCH + 1)], "irrelevant"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="unsupported evidence status"):
|
||||||
|
store.mark_evidence_status_batch("SUB-1", ["ev-1"], "nope")
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_surfaces_same_stem_files_it_could_not_import(tmp_path: Path):
|
||||||
|
image_root = tmp_path / "submissions"
|
||||||
|
for folder, suffix in (("applicant-a", "jpg"), ("applicant-b", "png")):
|
||||||
|
(image_root / folder).mkdir(parents=True)
|
||||||
|
(image_root / folder / f"photo.{suffix}").write_bytes(b"not-really-an-image")
|
||||||
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
|
||||||
|
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
|
||||||
|
store.seed_from_image_store(LocalSubmissionImageStore(image_root)) # reload must not re-audit
|
||||||
|
|
||||||
|
conflicts = store.bootstrap()["intakeConflicts"]
|
||||||
|
assert conflicts == [
|
||||||
|
{"kind": "same_stem", "id": "photo", "file": "applicant-b/photo.png", "keptFile": "applicant-a/photo.jpg"}
|
||||||
|
]
|
||||||
|
assert [item["id"] for item in store.bootstrap()["submissions"]] == ["photo"]
|
||||||
|
audits = [event for event in store.audit_events() if event["event"] == "Submission file name collision"]
|
||||||
|
assert len(audits) == 1
|
||||||
|
|
||||||
|
# Renaming the file clears the warning on the next reload.
|
||||||
|
(image_root / "applicant-b" / "photo.png").rename(image_root / "applicant-b" / "photo-b.png")
|
||||||
|
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
|
||||||
|
assert store.bootstrap()["intakeConflicts"] == []
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,44 @@ def test_export_skips_undecided_cases_and_carries_the_memo(tmp_path: Path):
|
||||||
assert "riskScore" in export["rows"][0]
|
assert "riskScore" in export["rows"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_memo_is_the_latest_decisions_memo_not_the_last_nonempty_one(tmp_path: Path):
|
||||||
|
# codex F4 (2026-09-23): a rejection's memo outlived the approval that
|
||||||
|
# reversed it and went out to the applicant as the reason for a case that
|
||||||
|
# was, by then, approved.
|
||||||
|
store = _store(tmp_path)
|
||||||
|
ids = _first_ids(store, 1)
|
||||||
|
store.record_decision(ids[0], "rejected", "연예인 초상 포함")
|
||||||
|
store.record_decision(ids[0], "approved")
|
||||||
|
|
||||||
|
export = store.export_decisions(audience="applicant")
|
||||||
|
|
||||||
|
assert export["rows"][0]["decision"] == "approved"
|
||||||
|
assert export["rows"][0]["memo"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_export_records_what_was_shown_at_decision_time(tmp_path: Path):
|
||||||
|
# Persona review 2026-09-25 (법무 2-A): the export read the live score and the
|
||||||
|
# live "사용" marks, so a mark changed after the decision rewrote the record of
|
||||||
|
# why the case was decided.
|
||||||
|
store = _store(tmp_path)
|
||||||
|
submission_id = _first_ids(store, 1)[0]
|
||||||
|
evidence = store.review(submission_id)["evidence"]
|
||||||
|
assert len(evidence) >= 1
|
||||||
|
cited = evidence[0]
|
||||||
|
store.mark_evidence_status(submission_id, cited["id"], "used_for_judgment")
|
||||||
|
decided_score = store.review(submission_id)["riskScore"]
|
||||||
|
store.record_decision(submission_id, "rejected", "근거 A 로 반려")
|
||||||
|
|
||||||
|
# After the decision: the cited row is un-cited and dismissed.
|
||||||
|
store.mark_evidence_status(submission_id, cited["id"], "irrelevant")
|
||||||
|
|
||||||
|
row = store.export_decisions(audience="internal")["rows"][0]
|
||||||
|
assert row["evidenceUsed"] == cited["title"]
|
||||||
|
assert row["riskScore"] == decided_score
|
||||||
|
assert row["currentRiskScore"] == store.review(submission_id)["riskScore"]
|
||||||
|
assert row["scoredVersion"]
|
||||||
|
|
||||||
|
|
||||||
def test_applicant_export_never_exposes_internal_signals(tmp_path: Path):
|
def test_applicant_export_never_exposes_internal_signals(tmp_path: Path):
|
||||||
store = _store(tmp_path)
|
store = _store(tmp_path)
|
||||||
ids = _first_ids(store, 1)
|
ids = _first_ids(store, 1)
|
||||||
|
|
@ -158,7 +196,7 @@ def test_applicant_export_never_exposes_internal_signals(tmp_path: Path):
|
||||||
export = store.export_decisions(audience="applicant")
|
export = store.export_decisions(audience="applicant")
|
||||||
|
|
||||||
row = export["rows"][0]
|
row = export["rows"][0]
|
||||||
for forbidden in ("riskScore", "riskBand", "evidenceUsed", "evidenceUrls"):
|
for forbidden in ("riskScore", "riskBand", "evidenceUsed", "evidenceUrls", "scoredVersion", "currentRiskScore"):
|
||||||
assert forbidden not in row
|
assert forbidden not in row
|
||||||
assert "riskScore" not in export["csv"]
|
assert "riskScore" not in export["csv"]
|
||||||
|
|
||||||
|
|
@ -240,6 +278,37 @@ def test_backup_snapshot_round_trips_decisions(tmp_path: Path):
|
||||||
assert decided[ids[0]] == "approved"
|
assert decided[ids[0]] == "approved"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_never_carries_face_crops_past_their_retention_deadline(tmp_path: Path):
|
||||||
|
# Face crops are biometric derivatives on a 90-day purge. Snapshots prune by
|
||||||
|
# count, not age, so an archived crop outlived the deadline inside an
|
||||||
|
# unencrypted zip — and restoring one rewrote the file, resetting the mtime
|
||||||
|
# the purge measures and handing it a fresh 90 days.
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
store = _store(tmp_path)
|
||||||
|
crop_dir = Path(store.face_crop_image_dir) / "alpha"
|
||||||
|
crop_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(crop_dir / "face-1.png").write_bytes(b"biometric-derivative")
|
||||||
|
|
||||||
|
snapshot = store.create_backup()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(snapshot["path"]) as archive:
|
||||||
|
assert [name for name in archive.namelist() if "face-crops" in name] == []
|
||||||
|
|
||||||
|
# Archives written before the exclusion still carry crops; a restore must
|
||||||
|
# skip them rather than resurrect them.
|
||||||
|
legacy = tmp_path / "legacy-snapshot.zip"
|
||||||
|
with zipfile.ZipFile(snapshot["path"]) as source, zipfile.ZipFile(legacy, "w") as target:
|
||||||
|
for name in source.namelist():
|
||||||
|
target.writestr(name, source.read(name))
|
||||||
|
target.writestr("media/face-crops/alpha/face-1.png", b"biometric-derivative")
|
||||||
|
|
||||||
|
restored_db = tmp_path / "restored" / "copyrighter.sqlite3"
|
||||||
|
restore_backup(legacy, restored_db)
|
||||||
|
|
||||||
|
assert not (restored_db.parent / "face-crops").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_backup_retention_prunes_older_snapshots(tmp_path: Path):
|
def test_backup_retention_prunes_older_snapshots(tmp_path: Path):
|
||||||
store = _store(tmp_path)
|
store = _store(tmp_path)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
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 FACE_PERSON_IDENTIFIABLE_POINTS, RiskScorer
|
||||||
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 Evidence, EvidenceSource
|
||||||
from rights_filter.integrations.external_policy import ExternalApiPolicy
|
from rights_filter.integrations.external_policy import ExternalApiPolicy
|
||||||
|
|
@ -221,7 +221,7 @@ def test_face_person_evidence_counts_once_however_many_rows_exist():
|
||||||
for _ in range(3)
|
for _ in range(3)
|
||||||
]
|
]
|
||||||
|
|
||||||
assert RiskScorer().score(rows).score == 35
|
assert RiskScorer().score(rows).score == FACE_PERSON_IDENTIFIABLE_POINTS
|
||||||
|
|
||||||
|
|
||||||
def test_degenerate_perceptual_hashes_do_not_produce_a_near_match():
|
def test_degenerate_perceptual_hashes_do_not_produce_a_near_match():
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from rights_filter.analysis.preprocessing import ImagePayload, build_external_derivative
|
from rights_filter.analysis.preprocessing import ImagePayload, build_external_derivative
|
||||||
|
from rights_filter.analysis.risk_scoring import WEB_ENTITY_MAX_POINTS
|
||||||
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 Evidence, EvidenceSource
|
||||||
from rights_filter.integrations.naver_search import NaverSearchAdapter
|
from rights_filter.integrations.naver_search import NaverSearchAdapter
|
||||||
|
|
@ -558,6 +559,68 @@ def test_promoted_naver_evidence_scores_after_serialization_roundtrip():
|
||||||
assert RiskScorer().score([restored]).score > 0
|
assert RiskScorer().score([restored]).score > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_publishing_host_survives_serialization_for_every_provider_shape():
|
||||||
|
from rights_filter.server.store_serialization import _evidence_payload
|
||||||
|
|
||||||
|
# Google custom search is the only adapter that reports the host directly,
|
||||||
|
# and it lands in data["domain"] — which the payload writes provider-first,
|
||||||
|
# so the real host used to be overwritten with the API name.
|
||||||
|
cse = _evidence_payload(
|
||||||
|
"SUB-HOST",
|
||||||
|
Evidence(
|
||||||
|
source=EvidenceSource.WEB_DETECTION,
|
||||||
|
reason="Custom search result",
|
||||||
|
confidence=0.7,
|
||||||
|
data={
|
||||||
|
"provider": "google_custom_search",
|
||||||
|
"domain": "biz.chosun.com",
|
||||||
|
"result_url": "https://biz.chosun.com/article/123",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert cse["domain"] == "google_custom_search" # provider identity unchanged
|
||||||
|
assert cse["host"] == "biz.chosun.com"
|
||||||
|
|
||||||
|
# Vision and Naver report no host at all, only URLs.
|
||||||
|
vision = _evidence_payload(
|
||||||
|
"SUB-HOST",
|
||||||
|
Evidence(
|
||||||
|
source=EvidenceSource.WEB_DETECTION,
|
||||||
|
reason="Full match",
|
||||||
|
confidence=0.9,
|
||||||
|
data={"provider": "google", "match": "full", "url": "https://WWW.Example.test/a/b.jpg"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert vision["host"] == "example.test"
|
||||||
|
|
||||||
|
internal = _evidence_payload(
|
||||||
|
"SUB-HOST",
|
||||||
|
Evidence(EvidenceSource.FINGERPRINT, "Image fingerprints generated", 1.0, {}),
|
||||||
|
)
|
||||||
|
assert internal["host"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_evidence_without_host_derives_it_from_the_stored_url():
|
||||||
|
from rights_filter.server.store_serialization import _domain_evidence_from_ui
|
||||||
|
|
||||||
|
# Rows written before the field existed must not be blind to their own
|
||||||
|
# source: every stored row already carries the result URL.
|
||||||
|
restored = _domain_evidence_from_ui(
|
||||||
|
{"title": "Full match", "domainSource": "web_detection", "url": "https://thumb.mt.co.kr/x.jpg"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert restored.data["host"] == "thumb.mt.co.kr"
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_prompt_carries_the_publishing_host_not_the_api_name():
|
||||||
|
from rights_filter.analysis.llm_assistance import PROMPT_DATA_FIELDS
|
||||||
|
|
||||||
|
# Every prompt line used to read "domain=google", so the model could not
|
||||||
|
# answer the question a rights reviewer most needs answered.
|
||||||
|
assert "host" in PROMPT_DATA_FIELDS
|
||||||
|
assert "domain" not in PROMPT_DATA_FIELDS
|
||||||
|
|
||||||
|
|
||||||
def test_web_entity_evidence_scores_after_serialization_roundtrip():
|
def test_web_entity_evidence_scores_after_serialization_roundtrip():
|
||||||
from rights_filter.analysis.risk_scoring import RiskScorer
|
from rights_filter.analysis.risk_scoring import RiskScorer
|
||||||
|
|
||||||
|
|
@ -575,28 +638,33 @@ def test_web_entity_evidence_scores_after_serialization_roundtrip():
|
||||||
def test_two_distinct_web_entities_score_after_roundtrip():
|
def test_two_distinct_web_entities_score_after_roundtrip():
|
||||||
from rights_filter.analysis.risk_scoring import RiskScorer
|
from rights_filter.analysis.risk_scoring import RiskScorer
|
||||||
|
|
||||||
|
# Low confidence on purpose: entity points are pooled under
|
||||||
|
# WEB_ENTITY_MAX_POINTS, and at full confidence both one and two entities
|
||||||
|
# would sit on that ceiling, hiding the property under test. What is being
|
||||||
|
# asserted here is that the entity-name BUCKET survives the round-trip, so
|
||||||
|
# keep the pool below its cap where a second entity is still observable.
|
||||||
first = _roundtrip(
|
first = _roundtrip(
|
||||||
"SUB-2E",
|
"SUB-2E",
|
||||||
Evidence(EvidenceSource.WEB_DETECTION, "entity BTS", 1.0, {"entity": "BTS"}),
|
Evidence(EvidenceSource.WEB_DETECTION, "entity BTS", 0.2, {"entity": "BTS"}),
|
||||||
)
|
)
|
||||||
second = _roundtrip(
|
second = _roundtrip(
|
||||||
"SUB-2E",
|
"SUB-2E",
|
||||||
Evidence(EvidenceSource.WEB_DETECTION, "entity BLACKPINK", 1.0, {"entity": "BLACKPINK"}),
|
Evidence(EvidenceSource.WEB_DETECTION, "entity BLACKPINK", 0.2, {"entity": "BLACKPINK"}),
|
||||||
)
|
)
|
||||||
one = RiskScorer().score([first]).score
|
one = RiskScorer().score([first]).score
|
||||||
both = RiskScorer().score([first, second]).score
|
both = RiskScorer().score([first, second]).score
|
||||||
# The entity-name bucket must survive the round-trip so a second distinct
|
assert one < both <= WEB_ENTITY_MAX_POINTS
|
||||||
# entity still adds points instead of being deduped away.
|
# The same entity twice must still collapse to one signal.
|
||||||
assert both > one
|
assert RiskScorer().score([first, first]).score == one
|
||||||
|
|
||||||
|
|
||||||
def test_search_skipped_roundtrip_adds_no_phantom_failure_points():
|
def test_search_skipped_roundtrip_adds_no_phantom_failure_points():
|
||||||
from rights_filter.analysis.risk_scoring import RiskScorer
|
from rights_filter.analysis.risk_scoring import FAILURE_FLOOR_POINTS, RiskScorer
|
||||||
from rights_filter.server.store_serialization import _evidence_payload
|
from rights_filter.server.store_serialization import _evidence_payload
|
||||||
|
|
||||||
evidence = Evidence(
|
evidence = Evidence(
|
||||||
source=EvidenceSource.SEARCH_SKIPPED,
|
source=EvidenceSource.SEARCH_SKIPPED,
|
||||||
reason="Naver 검색 생략 (일일 한도)",
|
reason="search API usage limit reached",
|
||||||
confidence=0.0,
|
confidence=0.0,
|
||||||
data={"provider": "naver"},
|
data={"provider": "naver"},
|
||||||
)
|
)
|
||||||
|
|
@ -604,8 +672,12 @@ def test_search_skipped_roundtrip_adds_no_phantom_failure_points():
|
||||||
assert payload["domainSource"] == "search_skipped"
|
assert payload["domainSource"] == "search_skipped"
|
||||||
restored = _roundtrip("SUB-SKIP", evidence)
|
restored = _roundtrip("SUB-SKIP", evidence)
|
||||||
assert restored.source == EvidenceSource.SEARCH_SKIPPED
|
assert restored.source == EvidenceSource.SEARCH_SKIPPED
|
||||||
# Must not be reclassified as FAILURE (+30).
|
# The round-trip must reproduce the fresh score: a quota skip is the review
|
||||||
assert RiskScorer().score([restored]).score == 0
|
# floor (v6), and it must not turn into anything more on the way back.
|
||||||
|
fresh = RiskScorer().score([evidence])
|
||||||
|
assert fresh.score == FAILURE_FLOOR_POINTS
|
||||||
|
assert RiskScorer().score([restored]).score == fresh.score
|
||||||
|
assert RiskScorer().score([restored]).reasons == fresh.reasons
|
||||||
|
|
||||||
|
|
||||||
def test_promoted_naver_hits_from_same_query_score_once():
|
def test_promoted_naver_hits_from_same_query_score_once():
|
||||||
|
|
|
||||||
211
tests/test_diagram_facts.py
Normal file
211
tests/test_diagram_facts.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"""다이어그램 spec 이 복제한 코드 유래 값이 실제 코드와 여전히 같은지 검사한다.
|
||||||
|
|
||||||
|
`test_diagram_sync.py` 가 "산출물이 spec 보다 낡았나"를 본다면, 이 파일은
|
||||||
|
"spec 이 코드보다 낡았나"를 본다. 그림에는 코드에서 뽑은 상수·버전·개수가
|
||||||
|
라벨과 카드 문장으로 하드카피돼 있는데, 그걸 지켜주는 장치가 없었다 —
|
||||||
|
SCORER_VERSION 을 v4 로 올리거나 믹스인을 하나 추가하면 코드는 통과하고
|
||||||
|
그림만 조용히 틀려진다.
|
||||||
|
|
||||||
|
같은 클래스의 드리프트를 테스트로 잡는 선례가 이미 이 저장소에 있다:
|
||||||
|
`tests/operator_gui/test_static_workbench.py` 의 APP_SCRIPT_NAMES.
|
||||||
|
|
||||||
|
실패하면 고칠 곳은 코드가 아니라 spec 이고, 고친 뒤에는
|
||||||
|
`scripts/rebuild-diagrams.ps1` 로 산출물과 수신증을 함께 갱신해야 한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rights_filter.analysis.llm_assistance import DEFAULT_MODEL, MAX_PROMPT_EVIDENCE
|
||||||
|
from rights_filter.analysis.llm_prompts import PROMPT_VERSION
|
||||||
|
from rights_filter.analysis.risk_scoring import (
|
||||||
|
SCORER_VERSION,
|
||||||
|
WEB_ENTITY_MAX_POINTS,
|
||||||
|
WEB_PRESENCE_MAX_POINTS,
|
||||||
|
RiskScorer,
|
||||||
|
)
|
||||||
|
from rights_filter.domain.records import Evidence, EvidenceSource
|
||||||
|
from rights_filter.server.http_app import _MAX_BODY_BYTES
|
||||||
|
from rights_filter.server.auth_rate_limit import MAX_FAILURES_PER_WINDOW, WINDOW_SECONDS
|
||||||
|
from rights_filter.server.sqlite_store import CopyrighterStore
|
||||||
|
from rights_filter.server.store_schema import SCHEMA_VERSION
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DIAGRAM_DIR = ROOT / "docs" / "diagrams"
|
||||||
|
|
||||||
|
|
||||||
|
def _spec(name: str) -> dict:
|
||||||
|
return json.loads((DIAGRAM_DIR / name).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _node(spec: dict, collection: str, node_id: str) -> dict:
|
||||||
|
for node in spec[collection]:
|
||||||
|
if node["id"] == node_id:
|
||||||
|
return node
|
||||||
|
raise AssertionError(
|
||||||
|
f"spec 에 '{node_id}' 노드가 없다 — 노드를 rename 했다면 이 테스트도 함께 고친다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _contains(haystack: str, needle: str, where: str) -> None:
|
||||||
|
assert needle in haystack, (
|
||||||
|
f"{where} 가 코드와 어긋났다: {needle!r} 를 담고 있어야 하는데 {haystack!r} 이다. "
|
||||||
|
"spec 을 고치고 scripts/rebuild-diagrams.ps1 로 재생성한다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- architecture -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_store_mixin_count_matches_the_class():
|
||||||
|
mixins = [base for base in CopyrighterStore.__bases__ if base.__name__.endswith("Mixin")]
|
||||||
|
node = _node(_spec("system-architecture.architecture.json"), "components", "store")
|
||||||
|
|
||||||
|
_contains(node["sublabel"], f"{len(mixins)}개", "CopyrighterStore 노드의 믹스인 개수")
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_console_script_count_matches_the_folder():
|
||||||
|
scripts = sorted((ROOT / "web" / "operator-gui").glob("app-*.js"))
|
||||||
|
node = _node(_spec("system-architecture.architecture.json"), "components", "console")
|
||||||
|
|
||||||
|
_contains(node["tag"], f"app-*.js {len(scripts)}", "운영자 콘솔 노드의 스크립트 개수")
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_throttle_matches_the_rate_limit_constants():
|
||||||
|
node = _node(_spec("system-architecture.architecture.json"), "components", "throttle")
|
||||||
|
|
||||||
|
_contains(node["sublabel"], str(MAX_FAILURES_PER_WINDOW), "인증 스로틀 노드의 실패 허용 횟수")
|
||||||
|
_contains(node["sublabel"], str(WINDOW_SECONDS), "인증 스로틀 노드의 윈도 길이")
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_body_limit_matches_http_app():
|
||||||
|
node = _node(_spec("system-architecture.architecture.json"), "components", "http")
|
||||||
|
|
||||||
|
_contains(node["tag"], f"{_MAX_BODY_BYTES // (1024 * 1024)}MB", "http_app 노드의 바디 상한")
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_schema_version_matches_store_schema():
|
||||||
|
node = _node(_spec("system-architecture.architecture.json"), "components", "sqlite")
|
||||||
|
|
||||||
|
_contains(node["tag"], f"SCHEMA v{SCHEMA_VERSION}", "SQLite 노드의 스키마 버전")
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_scorer_version_matches_risk_scoring():
|
||||||
|
node = _node(_spec("system-architecture.architecture.json"), "components", "analysis")
|
||||||
|
|
||||||
|
_contains(node["tag"], f"RiskScorer {SCORER_VERSION}", "분석·채점 노드의 스코어러 버전")
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_llm_model_matches_the_default():
|
||||||
|
node = _node(_spec("system-architecture.architecture.json"), "components", "ollama")
|
||||||
|
|
||||||
|
_contains(node["sublabel"], DEFAULT_MODEL, "로컬 LLM 노드의 모델명")
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_cards_name_the_actual_runtime_dependencies():
|
||||||
|
"""'런타임 서드파티 3종' 이라고 써 두었으니 그 3종이 실제 목록과 같아야 한다."""
|
||||||
|
requirements = [
|
||||||
|
line.split("==")[0].split(">=")[0].strip().lower()
|
||||||
|
for line in (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
|
||||||
|
if line.strip() and not line.startswith("#")
|
||||||
|
]
|
||||||
|
cards = " ".join(
|
||||||
|
item for card in _spec("system-architecture.architecture.json")["cards"] for item in card["items"]
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
for package in requirements:
|
||||||
|
_contains(cards, package, "아키텍처 카드의 런타임 의존성 목록")
|
||||||
|
_contains(cards, f"{len(requirements)}종", "아키텍처 카드의 런타임 의존성 개수")
|
||||||
|
|
||||||
|
|
||||||
|
# --- dataflow ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataflow_scorer_tag_matches_scorer_version():
|
||||||
|
node = _node(_spec("evidence-dataflow.dataflow.json"), "nodes", "scorer")
|
||||||
|
|
||||||
|
assert node["tag"] == SCORER_VERSION, (
|
||||||
|
f"RiskScorer 노드 태그({node['tag']!r})가 SCORER_VERSION({SCORER_VERSION!r})과 다르다"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataflow_prompt_version_tag_is_a_prefix_of_the_real_one():
|
||||||
|
node = _node(_spec("evidence-dataflow.dataflow.json"), "nodes", "llm")
|
||||||
|
|
||||||
|
assert PROMPT_VERSION.startswith(node["tag"]), (
|
||||||
|
f"LLM 노드 태그({node['tag']!r})가 PROMPT_VERSION({PROMPT_VERSION!r})의 접두어가 아니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataflow_prompt_evidence_cap_matches_the_constant():
|
||||||
|
node = _node(_spec("evidence-dataflow.dataflow.json"), "nodes", "llm")
|
||||||
|
|
||||||
|
_contains(node["sublabel"], f"{MAX_PROMPT_EVIDENCE}행", "LLM 노드의 프롬프트 입력 상한")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataflow_card_pool_ceilings_match_risk_scoring():
|
||||||
|
cards = " ".join(item for card in _spec("evidence-dataflow.dataflow.json")["cards"] for item in card["items"])
|
||||||
|
|
||||||
|
_contains(cards, f"presence {WEB_PRESENCE_MAX_POINTS}", "데이터흐름 카드의 presence 풀 상한")
|
||||||
|
_contains(cards, f"entity {WEB_ENTITY_MAX_POINTS}", "데이터흐름 카드의 entity 풀 상한")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataflow_knowledge_edge_matches_actual_fingerprint_scoring():
|
||||||
|
"""라벨이 '지문 유사도 0.9+ → 80점' 이라고 주장하니 실제로 그런지 채점해 본다.
|
||||||
|
|
||||||
|
이 둘은 이름 있는 상수가 아니라 risk_scoring 안의 인라인 리터럴이라,
|
||||||
|
import 대신 행위로 확인하는 것이 정확하다.
|
||||||
|
"""
|
||||||
|
spec = _spec("evidence-dataflow.dataflow.json")
|
||||||
|
label = next(flow["label"] for flow in spec["flows"] if flow["id"] == "fingerprint-match")
|
||||||
|
|
||||||
|
result = RiskScorer().score(
|
||||||
|
[
|
||||||
|
Evidence(
|
||||||
|
source=EvidenceSource.FINGERPRINT,
|
||||||
|
reason="지식DB 등록 이미지와 일치",
|
||||||
|
confidence=1.0,
|
||||||
|
data={"similarity": 0.9},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
_contains(label, "0.9", "지식DB 엣지의 유사도 임계값")
|
||||||
|
_contains(label, str(result.score), "지식DB 엣지의 점수")
|
||||||
|
|
||||||
|
|
||||||
|
# --- workflow ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_scorer_tag_matches_scorer_version():
|
||||||
|
node = _node(_spec("operator-review-workflow.workflow.json"), "nodes", "score")
|
||||||
|
|
||||||
|
assert node["tag"] == SCORER_VERSION, (
|
||||||
|
f"위험도 산출 노드 태그({node['tag']!r})가 SCORER_VERSION({SCORER_VERSION!r})과 다르다"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_llm_evidence_cap_matches_the_constant():
|
||||||
|
node = _node(_spec("operator-review-workflow.workflow.json"), "nodes", "llm")
|
||||||
|
|
||||||
|
_contains(node["sublabel"], f"{MAX_PROMPT_EVIDENCE}행", "LLM 노드의 프롬프트 입력 상한")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"spec_name, collection",
|
||||||
|
[
|
||||||
|
("system-architecture.architecture.json", "components"),
|
||||||
|
("evidence-dataflow.dataflow.json", "nodes"),
|
||||||
|
("operator-review-workflow.workflow.json", "nodes"),
|
||||||
|
("submission-lifecycle.lifecycle.json", "states"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_node_ids_are_unique_within_a_spec(spec_name: str, collection: str):
|
||||||
|
"""위 조회들이 첫 일치만 보므로, id 중복은 조용한 오탐이 된다."""
|
||||||
|
ids = [node["id"] for node in _spec(spec_name)[collection]]
|
||||||
|
|
||||||
|
assert len(ids) == len(set(ids)), f"{spec_name} 에 중복 id 가 있다: {ids}"
|
||||||
130
tests/test_diagram_sync.py
Normal file
130
tests/test_diagram_sync.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""다이어그램 소스(JSON)와 산출물(HTML)이 갈라지지 않았는지 검사하는 커밋 게이트.
|
||||||
|
|
||||||
|
`docs/diagrams/*.json` 이 소스이고 `*.html` 이 archify deliver 산출물이다. 둘의
|
||||||
|
동기화를 확인하는 자동 장치가 없어서, spec 만 고치고 재생성을 잊거나 HTML 을
|
||||||
|
직접 고쳐도 커밋이 통과했다. 폐쇄망 납품물에 동봉되는 문서라 고객이 보는 그림과
|
||||||
|
실제 코드가 갈라져도 검출 경로가 없었다.
|
||||||
|
|
||||||
|
이 테스트는 **브라우저도 Node 도 archify 도 필요 없다** — 순수 파일 비교다.
|
||||||
|
archify deliver 가 바이트 결정적이라는 사실(같은 spec 에서 항상 같은 HTML)이
|
||||||
|
그 근거이고, 그래서 수신증 대조만으로 충분하다.
|
||||||
|
|
||||||
|
실패했을 때 고치는 법은 manifest 를 손으로 맞추는 게 아니라
|
||||||
|
`scripts/rebuild-diagrams.ps1` 를 돌리는 것이다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DIAGRAM_DIR = ROOT / "docs" / "diagrams"
|
||||||
|
MANIFEST = DIAGRAM_DIR / "manifest.json"
|
||||||
|
|
||||||
|
# spec 의 어느 배열이 노드를 담는지는 다이어그램 타입마다 다르다.
|
||||||
|
NODE_COLLECTION = {
|
||||||
|
"architecture": "components",
|
||||||
|
"dataflow": "nodes",
|
||||||
|
"workflow": "nodes",
|
||||||
|
"lifecycle": "states",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest() -> dict:
|
||||||
|
return json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _entries() -> list[dict]:
|
||||||
|
return _manifest()["diagrams"]
|
||||||
|
|
||||||
|
|
||||||
|
def _ids() -> list[str]:
|
||||||
|
return [entry["html"] for entry in _entries()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_covers_every_delivered_diagram():
|
||||||
|
"""새 다이어그램을 추가하면 수신증에도 등재해야 한다."""
|
||||||
|
# 사이드카(*.visual-check.html)는 검증 증거이지 납품물이 아니다.
|
||||||
|
delivered = {
|
||||||
|
path.name
|
||||||
|
for path in DIAGRAM_DIR.glob("*.html")
|
||||||
|
if ".visual-check." not in path.name
|
||||||
|
}
|
||||||
|
|
||||||
|
assert delivered == set(_ids()), (
|
||||||
|
"manifest.json 과 실제 산출물 목록이 다르다. "
|
||||||
|
"다이어그램을 추가/제거했으면 scripts/rebuild-diagrams.ps1 로 갱신한다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("entry", _entries(), ids=_ids())
|
||||||
|
def test_html_matches_its_receipt(entry: dict):
|
||||||
|
"""HTML 을 직접 고치면 여기서 막힌다."""
|
||||||
|
html = DIAGRAM_DIR / entry["html"]
|
||||||
|
|
||||||
|
assert html.exists(), f"{entry['html']} 이 없다"
|
||||||
|
assert html.stat().st_size == entry["htmlBytes"]
|
||||||
|
assert _sha256(html) == entry["htmlSha256"], (
|
||||||
|
f"{entry['html']} 이 수신증과 다르다. HTML 을 직접 고쳤거나 재생성 후 "
|
||||||
|
"manifest 를 갱신하지 않았다 — scripts/rebuild-diagrams.ps1 로 재생성한다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("entry", _entries(), ids=_ids())
|
||||||
|
def test_spec_matches_its_receipt(entry: dict):
|
||||||
|
"""spec 만 고치고 재생성을 잊으면 여기서 막힌다."""
|
||||||
|
spec = DIAGRAM_DIR / entry["spec"]
|
||||||
|
|
||||||
|
assert spec.exists(), f"{entry['spec']} 이 없다"
|
||||||
|
assert _sha256(spec) == entry["specSha256"], (
|
||||||
|
f"{entry['spec']} 이 수신증과 다르다. spec 을 고쳤으면 "
|
||||||
|
"scripts/rebuild-diagrams.ps1 로 HTML 을 재생성해야 한다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("entry", _entries(), ids=_ids())
|
||||||
|
def test_every_spec_node_is_present_in_the_html(entry: dict):
|
||||||
|
"""sha 대조가 통과해도 이 검사가 소스↔산출물의 의미적 연결을 잡는다.
|
||||||
|
|
||||||
|
해시는 '둘 다 최신인가'만 말해준다. 이 검사는 산출물이 정말 그 spec 에서
|
||||||
|
나왔는지를 본다 — 노드 id 는 HTML 에 data-node-id 로 그대로 박힌다.
|
||||||
|
"""
|
||||||
|
spec = json.loads((DIAGRAM_DIR / entry["spec"]).read_text(encoding="utf-8"))
|
||||||
|
html = (DIAGRAM_DIR / entry["html"]).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
collection = NODE_COLLECTION[entry["type"]]
|
||||||
|
node_ids = [node["id"] for node in spec[collection]]
|
||||||
|
assert node_ids, f"{entry['spec']} 에 노드가 없다"
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
node_id
|
||||||
|
for node_id in node_ids
|
||||||
|
if f'data-node-id="{node_id}"' not in html
|
||||||
|
]
|
||||||
|
assert not missing, (
|
||||||
|
f"{entry['html']} 에 spec 노드 {missing} 가 없다 — 재생성되지 않은 산출물이다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generator_version_is_recorded():
|
||||||
|
"""산출물은 archify 버전에 따라 바이트가 달라진다. 그 버전이 기록돼 있어야
|
||||||
|
나중에 '코드는 그대로인데 해시가 바뀐' 원인을 구분할 수 있다."""
|
||||||
|
recorded = _manifest()["generator"]
|
||||||
|
stamped = re.search(
|
||||||
|
r'<meta name="generator" content="([^"]+)"',
|
||||||
|
(DIAGRAM_DIR / _entries()[0]["html"]).read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stamped, "산출물에 generator meta 가 없다"
|
||||||
|
assert stamped.group(1) == recorded, (
|
||||||
|
f"manifest 의 generator({recorded}) 와 산출물 각인({stamped.group(1)}) 이 다르다"
|
||||||
|
)
|
||||||
|
|
@ -142,7 +142,7 @@ function markEvidenceIrrelevant(evidenceId) {
|
||||||
|
|
||||||
const targetId = evidenceId || fallback?.id;
|
const targetId = evidenceId || fallback?.id;
|
||||||
|
|
||||||
void setEvidenceStatus(targetId, "ignored", "운영상 미사용 처리");
|
void setEvidenceStatus(targetId, "irrelevant", "운영자 무관 처리");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,3 +280,52 @@ async function emergencyDisableExternalProviders() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 증거 그룹 전체를 한 번에 표시한다.
|
||||||
|
//
|
||||||
|
// 단건 API 만 있던 동안 운영자 되먹임은 순증 노동이었다 — 실 DB 제출물 하나를
|
||||||
|
// 100 에서 35 로 내리려면 google 증거 51행을 개별로 눌러야 했다. 확인 대화를
|
||||||
|
// 두지 않는 이유는 이 동작이 되돌릴 수 있고(행마다 다시 표시 가능),
|
||||||
|
// 여기서 줄이려는 것이 바로 그 클릭이기 때문이다. 되돌릴 수 없는 느낌이 드는
|
||||||
|
// 것은 판정이고, 확인 대화는 거기에만 있다.
|
||||||
|
async function setEvidenceGroupStatus(groupId, status, note = "") {
|
||||||
|
|
||||||
|
const submission = getSelectedCase();
|
||||||
|
|
||||||
|
if (!submission || !groupId) return;
|
||||||
|
|
||||||
|
const items = groupedEvidence(submission.evidence)[groupId] || [];
|
||||||
|
|
||||||
|
const evidenceIds = items.map((item) => item.id).filter(Boolean);
|
||||||
|
|
||||||
|
if (!evidenceIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
await apiJson("/api/evidence/status-batch", {
|
||||||
|
|
||||||
|
method: "POST",
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
|
||||||
|
submission_id: submission.id,
|
||||||
|
|
||||||
|
evidence_ids: evidenceIds,
|
||||||
|
|
||||||
|
status: evidenceStatusPayload(status),
|
||||||
|
|
||||||
|
note,
|
||||||
|
|
||||||
|
}),
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshFromApi();
|
||||||
|
|
||||||
|
} catch (errorValue) {
|
||||||
|
|
||||||
|
showApiError(errorValue.message);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,13 @@ function authHeaders(extra) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (operatorName) {
|
||||||
|
|
||||||
|
// 헤더는 latin-1 이라 한글 이름은 인코딩해 보낸다. 서버가 되돌린다.
|
||||||
|
headers["X-Operator"] = encodeURIComponent(operatorName);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -208,6 +215,7 @@ function applyBootstrap(payload) {
|
||||||
replaceCollection(knowledgeEntries, payload.knowledgeEntries);
|
replaceCollection(knowledgeEntries, payload.knowledgeEntries);
|
||||||
|
|
||||||
state.submissionQueue = payload.submissionQueue || null;
|
state.submissionQueue = payload.submissionQueue || null;
|
||||||
|
state.intakeConflicts = Array.isArray(payload.intakeConflicts) ? payload.intakeConflicts : [];
|
||||||
|
|
||||||
Object.assign(
|
Object.assign(
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,10 +198,38 @@ function bindEvents() {
|
||||||
document.getElementById("upload-submission-image").addEventListener("click", uploadSubmissionImage);
|
document.getElementById("upload-submission-image").addEventListener("click", uploadSubmissionImage);
|
||||||
document.getElementById("rerun-enrichment").addEventListener("click", rerunEnrichment);
|
document.getElementById("rerun-enrichment").addEventListener("click", rerunEnrichment);
|
||||||
|
|
||||||
|
// 입력 중 전역 단축키가 막히는 것은 의도된 동작이다(J/K 가 글자로 들어가면
|
||||||
|
// 안 된다). 다만 그 때문에 자유 서술 반려는 메모를 친 뒤 마우스로 가야 했다.
|
||||||
|
// Ctrl+Enter 하나만 열어 그 경로를 키보드로 끝낸다.
|
||||||
|
document.getElementById("decision-memo").addEventListener("keydown", (event) => {
|
||||||
|
|
||||||
|
if (!(event.ctrlKey || event.metaKey) || event.key !== "Enter") return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
setDecision("rejected", true).then(() => moveToNextUnreviewed());
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// 선례 승계 버튼은 케이스마다 다시 그려지므로 컨테이너에 위임한다.
|
||||||
|
document.getElementById("prior-decision-box").addEventListener("click", (event) => {
|
||||||
|
|
||||||
|
const button = event.target.closest("[data-inherit-decision]");
|
||||||
|
|
||||||
|
if (button) inheritPriorDecision(button.dataset.inheritDecision);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("add-from-case").addEventListener("click", addKnowledgeEntryFromCase);
|
document.getElementById("add-from-case").addEventListener("click", addKnowledgeEntryFromCase);
|
||||||
|
|
||||||
document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
|
document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
|
||||||
|
|
||||||
|
const operatorField = document.getElementById("operator-name");
|
||||||
|
if (operatorField) {
|
||||||
|
operatorField.value = operatorName;
|
||||||
|
operatorField.addEventListener("change", () => setOperatorName(operatorField.value));
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById("disable-derived").addEventListener("click", () => {
|
document.getElementById("disable-derived").addEventListener("click", () => {
|
||||||
// Scope the deactivation to the currently-selected case. Picking the first
|
// Scope the deactivation to the currently-selected case. Picking the first
|
||||||
// automatic entry globally could deactivate an entry that belongs to a
|
// automatic entry globally could deactivate an entry that belongs to a
|
||||||
|
|
@ -360,6 +388,22 @@ function bindEvents() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const evidenceGroupStatusButton = event.target.closest("[data-evidence-group-status]");
|
||||||
|
|
||||||
|
if (evidenceGroupStatusButton) {
|
||||||
|
|
||||||
|
setEvidenceGroupStatus(
|
||||||
|
|
||||||
|
evidenceGroupStatusButton.dataset.evidenceGroup,
|
||||||
|
|
||||||
|
evidenceGroupStatusButton.dataset.evidenceGroupStatus,
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
const evidenceStatusButton = event.target.closest("[data-evidence-status]");
|
const evidenceStatusButton = event.target.closest("[data-evidence-status]");
|
||||||
|
|
||||||
if (evidenceStatusButton) {
|
if (evidenceStatusButton) {
|
||||||
|
|
@ -524,9 +568,9 @@ const __uiCopyCompatibility = {
|
||||||
samePerson: "동일인 판정이 아닙니다",
|
samePerson: "동일인 판정이 아닙니다",
|
||||||
watchlistNote: "주의 후보 근거",
|
watchlistNote: "주의 후보 근거",
|
||||||
evidenceUsedStatus: "사용",
|
evidenceUsedStatus: "사용",
|
||||||
evidenceIgnoredStatus: "미사용",
|
evidenceIgnoredStatus: "무관",
|
||||||
usedForJudgmentTuple: ["used_for_judgment", "사용"],
|
usedForJudgmentTuple: ["used_for_judgment", "사용"],
|
||||||
ignoredTuple: ["ignored", "미사용"],
|
ignoredTuple: ["irrelevant", "무관"],
|
||||||
pageRepresentativeImage: "페이지 대표 이미지",
|
pageRepresentativeImage: "페이지 대표 이미지",
|
||||||
confidenceLabel: "신뢰도",
|
confidenceLabel: "신뢰도",
|
||||||
queryStrategyByTitle: "구글 페이지 제목 기반",
|
queryStrategyByTitle: "구글 페이지 제목 기반",
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,11 @@ function renderMemoPresets() {
|
||||||
|
|
||||||
.map(
|
.map(
|
||||||
|
|
||||||
(preset) =>
|
// 화면의 번호가 곧 단축키다. R 로 사유 선택에 들어간 뒤 같은 숫자를
|
||||||
|
// 누르면 그 사유로 반려된다. 10번째부터는 번호 없이 마우스로만 쓴다.
|
||||||
|
(preset, index) =>
|
||||||
|
|
||||||
`<button type="button" class="memo-preset-chip" data-preset-id="${escapeHtml(preset.id)}" title="${escapeHtml(preset.body)}">${escapeHtml(preset.label)}</button>`,
|
`<button type="button" class="memo-preset-chip" data-preset-id="${escapeHtml(preset.id)}" title="${escapeHtml(preset.body)}">${index < 9 ? `<span class="memo-preset-key">${index + 1}</span>` : ""}${escapeHtml(preset.label)}</button>`,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,16 @@ function formatReason(reason) {
|
||||||
if (text.startsWith("Google face crop web evidence: ")) {
|
if (text.startsWith("Google face crop web evidence: ")) {
|
||||||
return `얼굴 영역 웹 근거: ${formatReason(text.replace("Google face crop web evidence: ", ""))}`;
|
return `얼굴 영역 웹 근거: ${formatReason(text.replace("Google face crop web evidence: ", ""))}`;
|
||||||
}
|
}
|
||||||
|
if (text === "Google full image match found") return "구글 동일 이미지 발견";
|
||||||
|
if (text === "Google partial image match found") return "구글 부분 일치 이미지 발견";
|
||||||
|
if (text === "Google page with matching image found") return "구글 게시 페이지 발견";
|
||||||
|
if (text === "Google visually similar image found") return "구글 유사 이미지 발견";
|
||||||
|
if (text.startsWith("Search result image similarity ")) {
|
||||||
|
return `검색 결과 사본 유사도 ${text.replace("Search result image similarity ", "")}`;
|
||||||
|
}
|
||||||
|
if (text.startsWith("Knowledge base image similarity ")) {
|
||||||
|
return `기준 DB 이미지 유사도 ${text.replace("Knowledge base image similarity ", "")}`;
|
||||||
|
}
|
||||||
if (text.startsWith("Web entity matched ")) return `웹 엔티티 일치: ${text.replace("Web entity matched ", "")}`;
|
if (text.startsWith("Web entity matched ")) return `웹 엔티티 일치: ${text.replace("Web entity matched ", "")}`;
|
||||||
if (text.startsWith("Google weak label ")) return `구글 약한 라벨 ${text.replace("Google weak label ", "")}`;
|
if (text.startsWith("Google weak label ")) return `구글 약한 라벨 ${text.replace("Google weak label ", "")}`;
|
||||||
if (text.startsWith("Best guess label ")) return `구글 추정 라벨 ${text.replace("Best guess label ", "")}`;
|
if (text.startsWith("Best guess label ")) return `구글 추정 라벨 ${text.replace("Best guess label ", "")}`;
|
||||||
|
|
@ -497,17 +507,11 @@ function normalizeEvidenceOperatorStatus(status) {
|
||||||
|
|
||||||
if (status === "used_for_judgment") return "used_for_judgment";
|
if (status === "used_for_judgment") return "used_for_judgment";
|
||||||
|
|
||||||
const ignoredStatuses = new Set();
|
// 옛 "ignored"(→ false_positive) 행도 화면에서는 "무관"으로 묶는다 — 둘 다 점수에서 빠진다.
|
||||||
|
if (status === "irrelevant" || status === "false_positive" || status === "ignored") return "irrelevant";
|
||||||
|
|
||||||
ignoredStatuses.add("irrelevant");
|
// pending = 표시 해제. 칩을 달지 않는다.
|
||||||
|
return "";
|
||||||
ignoredStatuses.add("false_positive");
|
|
||||||
|
|
||||||
ignoredStatuses.add("ignored");
|
|
||||||
|
|
||||||
if (ignoredStatuses.has(status)) return "ignored";
|
|
||||||
|
|
||||||
return status || "";
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -515,7 +519,7 @@ function normalizeEvidenceOperatorStatus(status) {
|
||||||
|
|
||||||
function evidenceStatusPayload(status) {
|
function evidenceStatusPayload(status) {
|
||||||
|
|
||||||
return status === "ignored" ? "false_positive" : status;
|
return status === "ignored" ? "irrelevant" : status;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -585,6 +589,50 @@ function formatDomain(domain) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Evidence stored before the server persisted `host` still carries its result
|
||||||
|
// URL, so derive the hostname here rather than migrating 292+ rows or paying a
|
||||||
|
// parse per row on every bootstrap. Only rendered rows reach this.
|
||||||
|
function knownHostCategory(host) {
|
||||||
|
// Mirrors the scorer's suffix match: a registered host covers its subdomains.
|
||||||
|
const clean = String(host || "").toLowerCase().replace(/^www\./, "");
|
||||||
|
for (const entry of knowledgeEntries) {
|
||||||
|
if (entry.active === false) continue;
|
||||||
|
const kind = entry.type === "news_host" ? "news" : entry.type === "scraper_host" ? "scraper" : "";
|
||||||
|
if (!kind) continue;
|
||||||
|
for (const value of [entry.name, ...(entry.aliases || [])]) {
|
||||||
|
const known = String(value || "").trim().toLowerCase().replace(/^www\./, "");
|
||||||
|
if (known && (clean === known || clean.endsWith(`.${known}`))) {
|
||||||
|
return { kind, label: kind === "news" ? "언론·공식" : "무단 게시처" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceHost(evidence) {
|
||||||
|
if (evidence.host) return evidence.host;
|
||||||
|
// Same candidates, in the same order, as _result_host in
|
||||||
|
// store_serialization.py. A shorter list here means a legacy Naver blog row
|
||||||
|
// whose only host-bearing field is bloggerLink shows no chip where the server
|
||||||
|
// would have resolved one - blind to its own source, which is the failure the
|
||||||
|
// fallback exists to prevent.
|
||||||
|
for (const candidate of [
|
||||||
|
evidence.url,
|
||||||
|
evidence.sourcePageUrl,
|
||||||
|
evidence.bloggerLink,
|
||||||
|
evidence.imageUrl,
|
||||||
|
]) {
|
||||||
|
if (!candidate) continue;
|
||||||
|
try {
|
||||||
|
return new URL(candidate).hostname.toLowerCase().replace(/^www\./, "");
|
||||||
|
} catch (error) {
|
||||||
|
// Not a parseable absolute URL — try the next one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function formatQueueTimestamp(value) {
|
function formatQueueTimestamp(value) {
|
||||||
return value || "-";
|
return value || "-";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ const KEYBOARD_HELP = [
|
||||||
|
|
||||||
"K / ↑ : 이전 케이스",
|
"K / ↑ : 이전 케이스",
|
||||||
|
|
||||||
"A : 승인 H : 보류 R : 반려(메모로 이동)",
|
"A : 승인 H : 보류 R : 반려 사유 선택",
|
||||||
|
" 사유 선택 중 — 1~9 : 사유 선택 후 즉시 반려 · M : 직접 입력 · Esc : 취소",
|
||||||
|
" 메모 입력 중 — Ctrl+Enter : 반려 제출",
|
||||||
|
|
||||||
"N : 다음 미심사 케이스",
|
"N : 다음 미심사 케이스",
|
||||||
|
|
||||||
|
|
@ -199,17 +201,62 @@ function handleReviewShortcut(event) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRejectPromptOpen) {
|
||||||
|
|
||||||
|
// 사유 선택 중에는 숫자가 곧 사유다. 이 모드 밖에서는 숫자에 아무 의미도
|
||||||
|
// 주지 않는다 - 잘못 누른 키 하나로 반려가 나가면 안 된다.
|
||||||
|
if (key >= "1" && key <= "9") {
|
||||||
|
|
||||||
|
rejectWithPreset(Number(key) - 1);
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "m" || key === "M") {
|
||||||
|
|
||||||
|
closeRejectPrompt();
|
||||||
|
|
||||||
|
const memoField = document.getElementById("decision-memo");
|
||||||
|
|
||||||
|
if (memoField) memoField.focus();
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "Escape") {
|
||||||
|
|
||||||
|
closeRejectPrompt();
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
if (key === "r" || key === "R") {
|
if (key === "r" || key === "R") {
|
||||||
|
|
||||||
// Rejection needs a memo, so hand the operator the field instead of firing
|
// 반려는 메모가 필수라 한 키로 끝낼 수 없다. 그렇다고 메모 필드에 포커스만
|
||||||
// a request that would fail validation.
|
// 주면 거기서부터는 마우스로만 제출할 수 있다(입력 중에는 단축키가 막힌다) -
|
||||||
|
// 법적으로 가장 중요한 경로가 가장 느린 이유였다. 사유 선택을 한 단계 두어
|
||||||
|
// 자주 쓰는 사유는 두 키로, 자유 서술은 Ctrl+Enter 로 끝나게 한다.
|
||||||
switchView("workbench");
|
switchView("workbench");
|
||||||
|
|
||||||
|
if (!openRejectPrompt()) {
|
||||||
|
|
||||||
const memo = document.getElementById("decision-memo");
|
const memo = document.getElementById("decision-memo");
|
||||||
|
|
||||||
if (memo) memo.focus();
|
if (memo) memo.focus();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +291,21 @@ function duplicateBadgeHtml(submission) {
|
||||||
|
|
||||||
const conflictNote = group.conflicting ? " · 판정 불일치" : "";
|
const conflictNote = group.conflicting ? " · 판정 불일치" : "";
|
||||||
|
|
||||||
return `<button type="button" class="duplicate-badge${tone}" data-duplicate-group="${escapeHtml(submission.id)}" title="${escapeHtml(`${priorText}${conflictNote}`)}">동일 이미지 ${group.size}건${conflictNote}</button>`;
|
// 선례를 배지 본문에 싣는다. title 에만 두면 마우스를 올린 사람만 알 수 있고,
|
||||||
|
// 큐를 훑는 동안에는 "이미 판정한 이미지"가 보이지 않는다.
|
||||||
|
const summary = priorDecisionSummary(submission);
|
||||||
|
|
||||||
|
const priorNote = group.conflicting
|
||||||
|
|
||||||
|
? conflictNote
|
||||||
|
|
||||||
|
: summary && summary.inheritable
|
||||||
|
|
||||||
|
? ` · 선례 ${decisionLabels[summary.inheritable] || summary.inheritable}`
|
||||||
|
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<button type="button" class="duplicate-badge${tone}" data-duplicate-group="${escapeHtml(submission.id)}" title="${escapeHtml(`${priorText}${conflictNote}`)}">동일 이미지 ${group.size}건${priorNote}</button>`;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -364,6 +425,8 @@ async function applyBulkDecision(decision) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!requireOperatorName(setStatus)) return;
|
||||||
|
|
||||||
const memo = document.getElementById("decision-memo")?.value.trim() || "";
|
const memo = document.getElementById("decision-memo")?.value.trim() || "";
|
||||||
|
|
||||||
if (decision === "rejected" && !memo) {
|
if (decision === "rejected" && !memo) {
|
||||||
|
|
@ -448,3 +511,69 @@ async function applyBulkDecision(decision) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// --- 반려 사유 선택 -----------------------------------------------------------
|
||||||
|
|
||||||
|
let isRejectPromptOpen = false;
|
||||||
|
|
||||||
|
function openRejectPrompt() {
|
||||||
|
|
||||||
|
const box = document.getElementById("reject-prompt");
|
||||||
|
|
||||||
|
const presets = decisionMemoPresets.slice(0, 9);
|
||||||
|
|
||||||
|
// 고를 것이 없으면 모드를 열지 않는다. 그때는 예전처럼 메모 필드로 보낸다.
|
||||||
|
if (!box || !presets.length) return false;
|
||||||
|
|
||||||
|
box.innerHTML = `
|
||||||
|
|
||||||
|
<strong>반려 사유 선택</strong>
|
||||||
|
|
||||||
|
<ol>${presets.map((preset, index) => `<li><span class="memo-preset-key">${index + 1}</span>${escapeHtml(preset.label)}</li>`).join("")}</ol>
|
||||||
|
|
||||||
|
<span class="muted">M : 직접 입력 · Esc : 취소</span>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
box.hidden = false;
|
||||||
|
|
||||||
|
isRejectPromptOpen = true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRejectPrompt() {
|
||||||
|
|
||||||
|
const box = document.getElementById("reject-prompt");
|
||||||
|
|
||||||
|
if (box) {
|
||||||
|
|
||||||
|
box.innerHTML = "";
|
||||||
|
|
||||||
|
box.hidden = true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
isRejectPromptOpen = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectWithPreset(index) {
|
||||||
|
|
||||||
|
const preset = decisionMemoPresets[index];
|
||||||
|
|
||||||
|
if (!preset) return;
|
||||||
|
|
||||||
|
const memo = document.getElementById("decision-memo");
|
||||||
|
|
||||||
|
if (!memo) return;
|
||||||
|
|
||||||
|
closeRejectPrompt();
|
||||||
|
|
||||||
|
memo.value = preset.body;
|
||||||
|
|
||||||
|
// 승인·보류와 같은 흐름으로 끝낸다 - 판정하고 다음 미심사 케이스로.
|
||||||
|
setDecision("rejected", true).then(() => moveToNextUnreviewed());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,8 @@ async function setDecision(decision, requiresMemo = false) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!requireOperatorName((message) => { error.textContent = message; })) return;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
error.textContent = "";
|
error.textContent = "";
|
||||||
|
|
@ -448,3 +450,29 @@ function selectedBulkSubmissionIds() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 선례 승계. 별도 판정 경로를 만들지 않고 메모만 채워 setDecision 에 넘긴다 —
|
||||||
|
// 반려 메모 필수 검사, 판정 시점 스냅샷, 감사 이벤트 같은 기존 보호장치를 그대로
|
||||||
|
// 타야 하기 때문이다. 승계했다는 사실은 메모로 감사에 남는다.
|
||||||
|
async function inheritPriorDecision(decision) {
|
||||||
|
|
||||||
|
const submission = getSelectedCase();
|
||||||
|
|
||||||
|
const summary = submission ? priorDecisionSummary(submission) : null;
|
||||||
|
|
||||||
|
// 화면이 그 사이 바뀌었으면(다른 케이스 선택, 선례 판정 변경) 조용히 무시한다.
|
||||||
|
if (!summary || summary.inheritable !== decision) return;
|
||||||
|
|
||||||
|
const memoField = document.getElementById("decision-memo");
|
||||||
|
|
||||||
|
const peers = summary.priors.map((item) => item.submissionId).join(", ");
|
||||||
|
|
||||||
|
if (memoField && !memoField.value.trim()) {
|
||||||
|
|
||||||
|
memoField.value = `선례 승계: 동일 이미지 ${peers} 와 같은 판정`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
await setDecision(decision, decision === "rejected");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -174,14 +174,19 @@ function renderEvidenceSummary(submission) {
|
||||||
["네이버", count("naver")],
|
["네이버", count("naver")],
|
||||||
["인물", count("face")],
|
["인물", count("face")],
|
||||||
];
|
];
|
||||||
|
// 무엇이 나왔나(headline)가 첫 줄, 무엇을 할지(primaryJudgment)와 규모가 둘째 줄.
|
||||||
|
// headline 이 없는 옛 케이스는 예전 배치 그대로.
|
||||||
|
const headline = submission.riskBreakdown && submission.riskBreakdown.headline;
|
||||||
|
const lead = headline ? escapeHtml(headline) : escapeHtml(primaryJudgment);
|
||||||
|
const sub = `${headline ? `${escapeHtml(primaryJudgment)} · ` : ""}${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<section class="evidence-summary-board" aria-label="판단 요약">
|
<section class="evidence-summary-board" aria-label="판단 요약">
|
||||||
<div class="summary-lead">
|
<div class="summary-lead">
|
||||||
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
|
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>${escapeHtml(primaryJudgment)}</strong>
|
<strong>${lead}</strong>
|
||||||
<span>${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거</span>
|
<span>${sub}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-stat-grid">
|
<div class="summary-stat-grid">
|
||||||
|
|
@ -235,8 +240,14 @@ function renderEvidenceGroup(group, items) {
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="evidence-group-actions">
|
||||||
|
|
||||||
<strong>${items.length}개</strong>
|
<strong>${items.length}개</strong>
|
||||||
|
|
||||||
|
<button class="row-action" type="button" data-evidence-group-status="irrelevant" data-evidence-group="${escapeHtml(group.id)}">전부 무관</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="evidence-card-grid">
|
<div class="evidence-card-grid">
|
||||||
|
|
@ -308,16 +319,34 @@ function renderEvidenceRow(evidence) {
|
||||||
const queryStrategy = evidence.queryStrategy ? `<span>${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}</span>` : "";
|
const queryStrategy = evidence.queryStrategy ? `<span>${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}</span>` : "";
|
||||||
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
|
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
|
||||||
const searchType = evidence.searchType ? `<span>${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}</span>` : "";
|
const searchType = evidence.searchType ? `<span>${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}</span>` : "";
|
||||||
|
// Where the match is published. Read before the title in a rights review: a
|
||||||
|
// copy on the licensor's own site and one on a scraper blog are not the same
|
||||||
|
// finding. Kept out of last-child position so the title keeps its wrap rule.
|
||||||
|
const hostName = evidenceHost(evidence);
|
||||||
|
// The full host goes in the tooltip because the chip is capped at 20ch: two
|
||||||
|
// CDN hosts sharing a 20-character prefix are otherwise indistinguishable
|
||||||
|
// without opening the link, which is the discrimination the monospace font
|
||||||
|
// was chosen for.
|
||||||
|
// 기준 DB 에 성격을 등록한 호스트면 칩 옆에 붙인다(언론·공식 / 무단 게시처).
|
||||||
|
const hostCategory = hostName ? knownHostCategory(hostName) : "";
|
||||||
|
const host = hostName
|
||||||
|
? `<span class="host-chip" title="게시 호스트 ${escapeHtml(hostName)}">${escapeHtml(hostName)}</span>${
|
||||||
|
hostCategory ? `<span class="host-category host-category-${hostCategory.kind}">${escapeHtml(hostCategory.label)}</span>` : ""
|
||||||
|
}`
|
||||||
|
: "";
|
||||||
const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
|
const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
|
||||||
const preview = renderEvidencePreview(evidence);
|
const preview = renderEvidencePreview(evidence);
|
||||||
const link = renderEvidenceLink(evidence);
|
const link = renderEvidenceLink(evidence);
|
||||||
const statusButtons = [
|
const statusButtons = [
|
||||||
|
// "사용" = 판정에 인용(점수 유지), 다시 누르면 표시 해제.
|
||||||
|
// "무관" = 점수에서 제외. 예전 "미사용"은 오탐으로 보내 점수를 지웠는데, 운영자에게는
|
||||||
|
// "인용하지 않음"으로 읽혔다 — 대표 근거 하나를 고르는 일이 나머지 유효 증거를 지웠다.
|
||||||
["used_for_judgment", "사용"],
|
["used_for_judgment", "사용"],
|
||||||
["ignored", "미사용"],
|
["irrelevant", "무관"],
|
||||||
]
|
]
|
||||||
.map(
|
.map(
|
||||||
([status, label]) => `
|
([status, label]) => `
|
||||||
<button class="row-action ${normalizedOperatorStatus === status ? "active" : ""}" type="button" data-evidence-status="${status}" data-evidence-id="${escapeHtml(evidence.id)}">
|
<button class="row-action ${normalizedOperatorStatus === status ? "active" : ""}" type="button" data-evidence-status="${normalizedOperatorStatus === status && status === "used_for_judgment" ? "pending" : status}" data-evidence-id="${escapeHtml(evidence.id)}" aria-pressed="${normalizedOperatorStatus === status}">
|
||||||
${label}
|
${label}
|
||||||
</button>
|
</button>
|
||||||
`,
|
`,
|
||||||
|
|
@ -331,6 +360,7 @@ function renderEvidenceRow(evidence) {
|
||||||
<div class="evidence-title">
|
<div class="evidence-title">
|
||||||
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
|
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
|
||||||
${newChip}
|
${newChip}
|
||||||
|
${host}
|
||||||
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
|
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="evidence-meta">
|
<div class="evidence-meta">
|
||||||
|
|
@ -447,3 +477,76 @@ function renderEvidenceSearch() {
|
||||||
: `<div class="empty-state">현재 케이스에는 검색 결과가 없습니다.</div>`;
|
: `<div class="empty-state">현재 케이스에는 검색 결과가 없습니다.</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 점수 분해. reasons 는 "어떤 증거가 말했는가"를, 이건 "그래서 몇 점인가"를
|
||||||
|
// 말한다.
|
||||||
|
//
|
||||||
|
// 이 패널이 필요해진 것은 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를
|
||||||
|
// 갖게 되면서다. 증거 51행이 35점이 되는 이유가 화면 어디에도 없으면 운영자는
|
||||||
|
// 숫자를 믿을 수 없고, 믿지 못하는 우선순위는 우선순위가 아니다.
|
||||||
|
function setScoreBreakdown(submission) {
|
||||||
|
|
||||||
|
const box = document.getElementById("score-breakdown");
|
||||||
|
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
const breakdown = submission && submission.riskBreakdown;
|
||||||
|
|
||||||
|
const components = (breakdown && breakdown.components) || [];
|
||||||
|
|
||||||
|
const excluded = (breakdown && breakdown.excluded) || [];
|
||||||
|
|
||||||
|
if (!components.length && !excluded.length) {
|
||||||
|
|
||||||
|
// 이 필드가 생기기 전에 저장된 케이스. 빈 패널보다 없는 편이 낫다.
|
||||||
|
box.innerHTML = "";
|
||||||
|
|
||||||
|
box.hidden = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = components
|
||||||
|
|
||||||
|
.map((item) => {
|
||||||
|
|
||||||
|
// 풀 상한에 걸린 항목은 원래 몇 점이었는지가 핵심 정보다.
|
||||||
|
const capped = item.raw !== undefined && item.raw > item.points
|
||||||
|
|
||||||
|
? `<span class="muted">원래 ${item.raw} · 상한 ${item.cap}</span>`
|
||||||
|
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<li><span>${escapeHtml(item.label)}</span><strong>${item.points}</strong>${capped}</li>`;
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const excludedRows = excluded
|
||||||
|
|
||||||
|
.map((item) => `<li class="score-breakdown-excluded"><span>${escapeHtml(item.label)}</span><strong>${item.rows}행 제외</strong></li>`)
|
||||||
|
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const clamped = breakdown.clamped
|
||||||
|
|
||||||
|
? `<p class="score-breakdown-clamped">합계 ${breakdown.total}점이 상한 100에 걸렸습니다 — 이 구간의 케이스는 서로 구별되지 않습니다.</p>`
|
||||||
|
|
||||||
|
: "";
|
||||||
|
|
||||||
|
box.innerHTML = `
|
||||||
|
|
||||||
|
<h4>점수 구성 <strong>${breakdown.final}</strong></h4>
|
||||||
|
<p class="score-breakdown-scope">웹 노출과 인물 식별성으로 매긴 <b>검토 순서</b>입니다. 권리 증빙(라이선스·동의)은 반영되지 않으며, 침해 여부는 판정에서 정합니다.</p>
|
||||||
|
|
||||||
|
<ul>${rows}${excludedRows}</ul>
|
||||||
|
|
||||||
|
${clamped}
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
box.hidden = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@
|
||||||
// global scope, and index.html must keep them in the order listed above/below.
|
// global scope, and index.html must keep them in the order listed above/below.
|
||||||
// Splitting is a pure line-range extraction of the former single app.js.
|
// Splitting is a pure line-range extraction of the former single app.js.
|
||||||
|
|
||||||
|
// Shown wherever a retained case's image would render. `assetMissing` is set by
|
||||||
|
// the reload sweep when a decided case's source file has been moved out of the
|
||||||
|
// queue folder: the decision is kept, the file is gone, and the operator needs
|
||||||
|
// to be told which of the two happened.
|
||||||
|
const MISSING_ASSET_NOTE = "원본 파일 없음 (판정 기록 보존)";
|
||||||
|
|
||||||
function renderQueue() {
|
function renderQueue() {
|
||||||
|
|
||||||
const rows = filteredSubmissions();
|
const rows = filteredSubmissions();
|
||||||
|
|
@ -80,7 +86,15 @@ function renderQueue() {
|
||||||
|
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
const reasonHtml = submission.reasons
|
// 스코어러가 준 한 줄 요약("웹 완전일치 사본 3곳 · 식별 가능 인물")이 있으면
|
||||||
|
// 그것이 상위 근거다. 사유 원문 둘을 잘라 붙이면 `구글 visually s...` 가 된다.
|
||||||
|
const headline = submission.riskBreakdown && submission.riskBreakdown.headline;
|
||||||
|
|
||||||
|
const reasonHtml = headline
|
||||||
|
|
||||||
|
? `<span title="${escapeHtml(headline)}">${escapeHtml(headline)}</span>`
|
||||||
|
|
||||||
|
: submission.reasons
|
||||||
|
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
|
|
||||||
|
|
@ -96,7 +110,7 @@ function renderQueue() {
|
||||||
|
|
||||||
<td><input type="checkbox" aria-label="${escapeHtml(submission.id)} 선택" data-bulk-id="${escapeHtml(submission.id)}"></td>
|
<td><input type="checkbox" aria-label="${escapeHtml(submission.id)} 선택" data-bulk-id="${escapeHtml(submission.id)}"></td>
|
||||||
|
|
||||||
<td><img class="thumb" src="${escapeHtml(submission.asset)}" alt="${escapeHtml(submission.title)} 썸네일"></td>
|
<td><img class="thumb${submission.assetMissing ? " thumb-missing" : ""}" src="${escapeHtml(submission.asset)}" alt="${escapeHtml(submission.title)} 썸네일"${submission.assetMissing ? ` title="${MISSING_ASSET_NOTE}"` : ""}></td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
|
|
@ -162,11 +176,31 @@ function renderImportStatus() {
|
||||||
|
|
||||||
const tone = state.importStatus.tone === "bad" ? "bad" : state.importStatus.tone === "warn" ? "warn" : "ok";
|
const tone = state.importStatus.tone === "bad" ? "bad" : state.importStatus.tone === "warn" ? "warn" : "ok";
|
||||||
|
|
||||||
|
// 파일명이 겹쳐 심사 건으로 들어가지 못한 파일. 여기서 말하지 않으면 운영자는
|
||||||
|
// 큐가 비는 순간 "다 처리했다"고 믿는다 — 한 지원자의 사진이 조용히 빠진 채로.
|
||||||
|
const conflicts = state.intakeConflicts || [];
|
||||||
|
const conflictHtml = conflicts.length
|
||||||
|
? `
|
||||||
|
<div class="intake-conflicts" role="alert">
|
||||||
|
<strong>심사 건으로 들어가지 못한 파일 ${conflicts.length}개</strong> — 파일명을 바꾼 뒤 다시 불러오세요.
|
||||||
|
<ul>
|
||||||
|
${conflicts
|
||||||
|
.map((item) =>
|
||||||
|
item.kind === "same_stem"
|
||||||
|
? `<li><code>${escapeHtml(item.file)}</code> · <code>${escapeHtml(item.keptFile)}</code> 와 파일명이 같음</li>`
|
||||||
|
: `<li><code>${escapeHtml(item.file)}</code> · 다른 큐(${escapeHtml(item.ownerQueueId)})에 같은 id 가 있음</li>`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</ul>
|
||||||
|
</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
target.innerHTML = `
|
target.innerHTML = `
|
||||||
|
|
||||||
<span class="status-dot ${tone}" aria-hidden="true"></span>
|
<span class="status-dot ${conflicts.length ? "warn" : tone}" aria-hidden="true"></span>
|
||||||
|
|
||||||
<span>${escapeHtml(state.importStatus.message)}</span>
|
<span>${escapeHtml(state.importStatus.message)}</span>
|
||||||
|
${conflictHtml}
|
||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -189,8 +223,12 @@ function renderNoSelectedCase() {
|
||||||
activeRerunAddedIds = new Set();
|
activeRerunAddedIds = new Set();
|
||||||
document.getElementById("rerun-diff-summary").innerHTML = "";
|
document.getElementById("rerun-diff-summary").innerHTML = "";
|
||||||
document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
|
document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
|
||||||
|
|
||||||
|
setScoreBreakdown(null);
|
||||||
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
|
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
|
||||||
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
|
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
|
||||||
|
|
||||||
|
setPriorDecisionBox(null);
|
||||||
document.getElementById("derived-preview").innerHTML = `<span class="muted">자동 후보 생성 없음</span>`;
|
document.getElementById("derived-preview").innerHTML = `<span class="muted">자동 후보 생성 없음</span>`;
|
||||||
// Clear the evidence-gap recommendations too: they belong to the case that was
|
// Clear the evidence-gap recommendations too: they belong to the case that was
|
||||||
// just deselected, and leaving them rendered invited running suggested queries
|
// just deselected, and leaving them rendered invited running suggested queries
|
||||||
|
|
@ -225,7 +263,10 @@ function renderCaseReview() {
|
||||||
|
|
||||||
caseImageElement.src = safeUrl(submission.asset);
|
caseImageElement.src = safeUrl(submission.asset);
|
||||||
|
|
||||||
caseImageElement.alt = `${submission.id} 제출 이미지`;
|
caseImageElement.alt = submission.assetMissing
|
||||||
|
? `${submission.id} 제출 이미지 - ${MISSING_ASSET_NOTE}`
|
||||||
|
: `${submission.id} 제출 이미지`;
|
||||||
|
caseImageElement.classList.toggle("case-image-missing", Boolean(submission.assetMissing));
|
||||||
|
|
||||||
// Reset the view controls (rotate / fit) so a new case starts unrotated.
|
// Reset the view controls (rotate / fit) so a new case starts unrotated.
|
||||||
caseImageElement.dataset.rotation = "0";
|
caseImageElement.dataset.rotation = "0";
|
||||||
|
|
@ -234,7 +275,13 @@ function renderCaseReview() {
|
||||||
|
|
||||||
caseImageElement.style.objectFit = "";
|
caseImageElement.style.objectFit = "";
|
||||||
|
|
||||||
document.getElementById("image-derivative-note").textContent = submission.derivativeNote;
|
// Without this the retained case is a broken <img> with no explanation, and
|
||||||
|
// the operator cannot tell "file archived after the decision" from "upload
|
||||||
|
// corrupted". The decision survives in the DB either way; this is what makes
|
||||||
|
// it readable in the console.
|
||||||
|
document.getElementById("image-derivative-note").textContent = submission.assetMissing
|
||||||
|
? `${submission.derivativeNote} · ${MISSING_ASSET_NOTE}`
|
||||||
|
: submission.derivativeNote;
|
||||||
|
|
||||||
document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
|
document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
|
||||||
|
|
||||||
|
|
@ -290,6 +337,8 @@ function renderCaseReview() {
|
||||||
|
|
||||||
document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission);
|
document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission);
|
||||||
|
|
||||||
|
setScoreBreakdown(submission);
|
||||||
|
|
||||||
document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission);
|
document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -307,6 +356,8 @@ function renderCaseReview() {
|
||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
setPriorDecisionBox(submission);
|
||||||
|
|
||||||
document.getElementById("derived-preview").innerHTML = `
|
document.getElementById("derived-preview").innerHTML = `
|
||||||
|
|
||||||
<strong>${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"}</strong>
|
<strong>${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"}</strong>
|
||||||
|
|
@ -320,3 +371,72 @@ function renderCaseReview() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 동일 이미지 선례를 판정 패널에 세운다.
|
||||||
|
//
|
||||||
|
// 이 정보는 서버가 이미 보내고 있었다(store_duplicates.py 의 priorDecisions).
|
||||||
|
// 문제는 자리였다 — 큐 배지의 title 속성에만 있어서, 운영자가 재제출을 처음부터
|
||||||
|
// 다시 심사해도 아무것도 막지 못했다. 중복 탐지는 이 도구에서 실증으로 검증된
|
||||||
|
// 유일한 판별 기능이므로(재저장·축소·밝기 변형 24/24 그룹핑, 같은 24건에서
|
||||||
|
// SHA-256 일치 0건) 그 결과를 판정 지점에 올린다.
|
||||||
|
function setPriorDecisionBox(submission) {
|
||||||
|
|
||||||
|
const box = document.getElementById("prior-decision-box");
|
||||||
|
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
const summary = submission ? priorDecisionSummary(submission) : null;
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
|
||||||
|
box.innerHTML = "";
|
||||||
|
|
||||||
|
box.hidden = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const peers = summary.priors.map((item) => item.submissionId).join(", ");
|
||||||
|
|
||||||
|
if (summary.conflicting) {
|
||||||
|
|
||||||
|
// 같은 이미지에 서로 다른 판정이 있다. 승계를 제안하지 않는다 — 자동화할
|
||||||
|
// 상황이 아니라 운영자가 봐야 하는 신호다.
|
||||||
|
box.innerHTML = `
|
||||||
|
|
||||||
|
<strong class="prior-decision-conflict">동일 이미지 ${summary.size}건 · 판정 불일치</strong>
|
||||||
|
|
||||||
|
<span class="muted">${escapeHtml(peers || "-")}</span>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
} else if (!summary.inheritable) {
|
||||||
|
|
||||||
|
box.innerHTML = `
|
||||||
|
|
||||||
|
<strong>동일 이미지 ${summary.size}건</strong>
|
||||||
|
|
||||||
|
<span class="muted">아직 판정된 선례 없음</span>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
const label = decisionLabels[summary.inheritable] || summary.inheritable;
|
||||||
|
|
||||||
|
box.innerHTML = `
|
||||||
|
|
||||||
|
<strong>동일 이미지 선례 · ${escapeHtml(label)}</strong>
|
||||||
|
|
||||||
|
<span class="muted">${escapeHtml(peers)}</span>
|
||||||
|
|
||||||
|
<button class="row-action" type="button" data-inherit-decision="${escapeHtml(summary.inheritable)}">선례 승계</button>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
box.hidden = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -308,3 +308,36 @@ function filteredSubmissions() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 동일 이미지에 이미 내려진 판정 요약. 서버는 이 값을 duplicateGroup 에 담아
|
||||||
|
// 이미 내려보내고 있었지만, 콘솔은 그것을 큐 배지의 title 속성에만 넣었다 —
|
||||||
|
// 마우스를 올려야 보이는 자리라 재제출을 처음부터 다시 심사하는 것을 못 막았다.
|
||||||
|
//
|
||||||
|
// 승계는 선례가 **만장일치**일 때만 제안한다. conflicting 은 같은 이미지에 서로
|
||||||
|
// 다른 판정이 내려졌다는 뜻이고, 그건 자동 처리할 상황이 아니라 사람이 봐야 하는
|
||||||
|
// 신호다.
|
||||||
|
function priorDecisionSummary(submission) {
|
||||||
|
|
||||||
|
const group = submission && submission.duplicateGroup;
|
||||||
|
|
||||||
|
if (!group || !group.size) return null;
|
||||||
|
|
||||||
|
const priors = group.priorDecisions || [];
|
||||||
|
|
||||||
|
const decisions = new Set(priors.map((item) => item.decision));
|
||||||
|
|
||||||
|
const unanimous = !group.conflicting && priors.length > 0 && decisions.size === 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
|
||||||
|
size: group.size,
|
||||||
|
|
||||||
|
priors,
|
||||||
|
|
||||||
|
conflicting: Boolean(group.conflicting),
|
||||||
|
|
||||||
|
inheritable: unanimous ? priors[0].decision : null,
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,8 @@ const state = {
|
||||||
|
|
||||||
submissionQueue: null,
|
submissionQueue: null,
|
||||||
|
|
||||||
|
intakeConflicts: [],
|
||||||
|
|
||||||
importStatus: {
|
importStatus: {
|
||||||
|
|
||||||
tone: "idle",
|
tone: "idle",
|
||||||
|
|
@ -132,6 +134,35 @@ const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "stat
|
||||||
|
|
||||||
let authToken = "";
|
let authToken = "";
|
||||||
|
|
||||||
|
// 판정·증거 표시를 누가 했는지 감사 로그에 남기는 운영자 이름. 인증이 아니라 기록용이다
|
||||||
|
// (권한은 공유 토큰이 정한다). 비밀이 아니므로 브라우저별로 기억해 매번 묻지 않는다.
|
||||||
|
const OPERATOR_NAME_KEY = "copyrighter.operatorName";
|
||||||
|
|
||||||
|
let operatorName = (() => {
|
||||||
|
try {
|
||||||
|
return (window.localStorage.getItem(OPERATOR_NAME_KEY) || "").trim();
|
||||||
|
} catch (_error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function setOperatorName(value) {
|
||||||
|
operatorName = String(value || "").trim().slice(0, 40);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(OPERATOR_NAME_KEY, operatorName);
|
||||||
|
} catch (_error) {
|
||||||
|
// 저장이 막힌 브라우저에서도 이번 세션 동안은 동작한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이름 없이 판정하면 감사 로그의 "누가"가 비어 버린다. 판정 경로들이 이것으로 막는다.
|
||||||
|
function requireOperatorName(showError) {
|
||||||
|
if (operatorName) return true;
|
||||||
|
showError("판정 전에 왼쪽 아래 '운영자' 칸에 이름을 입력하세요 — 감사 기록에 남습니다.");
|
||||||
|
document.getElementById("operator-name")?.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
let pendingAuthPrompt = null;
|
let pendingAuthPrompt = null;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,9 @@ function renderThroughputBars(byDay) {
|
||||||
|
|
||||||
function formatPercent(rate) {
|
function formatPercent(rate) {
|
||||||
|
|
||||||
|
// 서버는 분모 0 을 null 로 보낸다 — 0% 가 아니라 측정 불가다.
|
||||||
|
if (rate === null || rate === undefined) return "측정 불가";
|
||||||
|
|
||||||
return `${Math.round((Number(rate) || 0) * 100)}%`;
|
return `${Math.round((Number(rate) || 0) * 100)}%`;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,10 @@
|
||||||
|
|
||||||
<div class="nav-footer">
|
<div class="nav-footer">
|
||||||
<span class="status-dot ok" aria-hidden="true"></span>
|
<span class="status-dot ok" aria-hidden="true"></span>
|
||||||
<span>내부 전용</span>
|
<label class="operator-name-field" for="operator-name">
|
||||||
|
<span>운영자</span>
|
||||||
|
<input id="operator-name" type="text" maxlength="40" autocomplete="name" placeholder="이름 (감사 기록용)">
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -211,7 +214,7 @@
|
||||||
<th scope="col"><span class="sr-only">선택</span></th>
|
<th scope="col"><span class="sr-only">선택</span></th>
|
||||||
<th scope="col">이미지</th>
|
<th scope="col">이미지</th>
|
||||||
<th scope="col">제출</th>
|
<th scope="col">제출</th>
|
||||||
<th scope="col">위험</th>
|
<th scope="col" title="웹 노출 · 인물 식별성 기반 검토 순서 — 침해 판정이 아닙니다">검토 우선</th>
|
||||||
<th scope="col">상위 근거</th>
|
<th scope="col">상위 근거</th>
|
||||||
<th scope="col">외부 검색 tool 활용</th>
|
<th scope="col">외부 검색 tool 활용</th>
|
||||||
<th scope="col">지원자 상태</th>
|
<th scope="col">지원자 상태</th>
|
||||||
|
|
@ -252,12 +255,14 @@
|
||||||
<span class="score-pill risk-pending" id="floating-case-score">대기</span>
|
<span class="score-pill risk-pending" id="floating-case-score">대기</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="recommendation-box" id="recommendation-box"></div>
|
<div class="recommendation-box" id="recommendation-box"></div>
|
||||||
|
<div class="prior-decision" id="prior-decision-box" hidden></div>
|
||||||
<div class="llm-draft" id="llm-draft" hidden></div>
|
<div class="llm-draft" id="llm-draft" hidden></div>
|
||||||
<label class="memo-field" for="decision-memo">
|
<label class="memo-field" for="decision-memo">
|
||||||
<span>결정 메모</span>
|
<span>결정 메모</span>
|
||||||
<textarea id="decision-memo" rows="3" placeholder="반려 또는 보정 시 메모 필수"></textarea>
|
<textarea id="decision-memo" rows="3" placeholder="반려 또는 보정 시 메모 필수"></textarea>
|
||||||
</label>
|
</label>
|
||||||
<div class="memo-presets" id="memo-presets" aria-label="자주 쓰는 사유"></div>
|
<div class="memo-presets" id="memo-presets" aria-label="자주 쓰는 사유"></div>
|
||||||
|
<div class="reject-prompt" id="reject-prompt" hidden aria-live="polite"></div>
|
||||||
<p class="form-error" id="memo-error" aria-live="assertive"></p>
|
<p class="form-error" id="memo-error" aria-live="assertive"></p>
|
||||||
<div class="decision-actions">
|
<div class="decision-actions">
|
||||||
<button class="approve-action" type="button" data-decision="approved">승인</button>
|
<button class="approve-action" type="button" data-decision="approved">승인</button>
|
||||||
|
|
@ -269,7 +274,7 @@
|
||||||
<div class="derived-preview" id="derived-preview"></div>
|
<div class="derived-preview" id="derived-preview"></div>
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<button type="button" id="add-from-case">주의 후보 보기</button>
|
<button type="button" id="add-from-case">주의 후보 보기</button>
|
||||||
<button type="button" id="mark-irrelevant">증거 미사용 처리</button>
|
<button type="button" id="mark-irrelevant">증거 무관 처리 (점수 제외)</button>
|
||||||
<button type="button" id="disable-derived">자동 항목 비활성</button>
|
<button type="button" id="disable-derived">자동 항목 비활성</button>
|
||||||
<button type="button" id="clear-reference">클리어 기준 등록</button>
|
<button type="button" id="clear-reference">클리어 기준 등록</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -300,6 +305,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="rerun-diff-summary" class="rerun-diff-summary"></div>
|
<div id="rerun-diff-summary" class="rerun-diff-summary"></div>
|
||||||
<div id="case-reasons" class="reason-list"></div>
|
<div id="case-reasons" class="reason-list"></div>
|
||||||
|
<div id="score-breakdown" class="score-breakdown" hidden></div>
|
||||||
<div id="evidence-next-actions" class="evidence-next-actions"></div>
|
<div id="evidence-next-actions" class="evidence-next-actions"></div>
|
||||||
<div id="evidence-groups" class="evidence-groups"></div>
|
<div id="evidence-groups" class="evidence-groups"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -403,6 +409,9 @@
|
||||||
<option value="character">캐릭터</option>
|
<option value="character">캐릭터</option>
|
||||||
<option value="game">게임</option>
|
<option value="game">게임</option>
|
||||||
<option value="rejected_image">반려 이미지</option>
|
<option value="rejected_image">반려 이미지</option>
|
||||||
|
<option value="trusted_host">신뢰 출처</option>
|
||||||
|
<option value="news_host">언론·공식 출처</option>
|
||||||
|
<option value="scraper_host">무단 게시처</option>
|
||||||
<option value="other">기타</option>
|
<option value="other">기타</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -478,6 +487,9 @@
|
||||||
<option value="character">캐릭터</option>
|
<option value="character">캐릭터</option>
|
||||||
<option value="game">게임</option>
|
<option value="game">게임</option>
|
||||||
<option value="rejected_reference">반려 이미지 참조</option>
|
<option value="rejected_reference">반려 이미지 참조</option>
|
||||||
|
<option value="trusted_host">신뢰 출처(이름·별칭에 도메인)</option>
|
||||||
|
<option value="news_host">언론·공식 출처(표시만, 점수 무관)</option>
|
||||||
|
<option value="scraper_host">무단 게시처(표시만, 점수 무관)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
used_for_judgment: "판단에 사용",
|
used_for_judgment: "판단에 사용",
|
||||||
irrelevant: "무관",
|
irrelevant: "무관",
|
||||||
false_positive: "오탐",
|
false_positive: "오탐",
|
||||||
ignored: "미사용",
|
ignored: "무관",
|
||||||
pending: "보류",
|
pending: "보류",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -80,6 +80,10 @@
|
||||||
game: "게임",
|
game: "게임",
|
||||||
rejected_image: "반려 이미지",
|
rejected_image: "반려 이미지",
|
||||||
rejected_reference: "반려 이미지",
|
rejected_reference: "반려 이미지",
|
||||||
|
cleared_reference: "클리어 기준",
|
||||||
|
trusted_host: "신뢰 출처",
|
||||||
|
news_host: "언론·공식 출처",
|
||||||
|
scraper_host: "무단 게시처",
|
||||||
other: "기타",
|
other: "기타",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,24 @@ textarea:disabled {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.operator-name-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-name-field input {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--rail-ink);
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-footer {
|
.nav-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -719,6 +737,21 @@ textarea:disabled {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intake-conflicts {
|
||||||
|
flex-basis: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--amber-line);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--amber-tint);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intake-conflicts ul {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Operator workflow strip ===================== */
|
/* ===================== Operator workflow strip ===================== */
|
||||||
|
|
@ -1326,6 +1359,55 @@ tbody tr.selected-row,
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Publishing host. Monospace so hostnames align down a long evidence list and
|
||||||
|
near-identical domains (cdn.example.com vs cdn-example.com) separate at a
|
||||||
|
glance; quieter than the source chip because it sits next to it on every row
|
||||||
|
and two competing chips would flatten the hierarchy. */
|
||||||
|
/* A retained case whose source file is gone. The image cannot be served, so the
|
||||||
|
frame says so instead of leaving a silently broken thumbnail: the row is still
|
||||||
|
the record of a real decision and has to stay legible as one. */
|
||||||
|
.thumb-missing,
|
||||||
|
.case-image-missing {
|
||||||
|
outline: 2px dashed var(--amber-line);
|
||||||
|
outline-offset: 2px;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-chip {
|
||||||
|
/* Bounded, then rigid. Bounded because CDN hostnames run past 45 characters
|
||||||
|
and an unbounded chip ate the whole title line, leaving the headline to
|
||||||
|
wrap one character per row. Rigid within that bound because the title beside
|
||||||
|
it is boilerplate ("구글 full image match found" on every row) while this is
|
||||||
|
the row's actual content — a host short enough to fit must never be the
|
||||||
|
thing that gets truncated. */
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 20ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-category {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid var(--hair-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-category-scraper {
|
||||||
|
border-color: var(--amber-line);
|
||||||
|
background: var(--amber-tint);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
.evidence-status-chip,
|
.evidence-status-chip,
|
||||||
.watchlist-chip.watchlist {
|
.watchlist-chip.watchlist {
|
||||||
border-color: var(--amber-line);
|
border-color: var(--amber-line);
|
||||||
|
|
@ -1602,7 +1684,10 @@ tbody tr.selected-row,
|
||||||
.summary-lead strong {
|
.summary-lead strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
|
line-height: 1.35;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
|
/* 한 줄 요약은 "라벨 10개" 같은 구를 줄 사이에서 쪼개지 않는다 — 구분점(·) 단위로만 접힌다. */
|
||||||
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-lead span:not(.risk-badge) {
|
.summary-lead span:not(.risk-badge) {
|
||||||
|
|
@ -1858,6 +1943,12 @@ tbody tr.selected-row,
|
||||||
.evidence-title,
|
.evidence-title,
|
||||||
.row-title {
|
.row-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
/* Wraps because the chips beside the title do not. source-chip and host-chip
|
||||||
|
are both rigid, and at phone width they leave the headline a few dozen
|
||||||
|
pixels - which `overflow-wrap: anywhere` below then honours by breaking it
|
||||||
|
one character per row. With wrapping the title drops to its own line
|
||||||
|
intact, and desktop layout is unchanged because nothing overflows there. */
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -1871,6 +1962,16 @@ tbody tr.selected-row,
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The title claims the space the chips beside it do not, so `overflow-wrap:
|
||||||
|
anywhere` above stays a wrapping rule and never becomes a one-character
|
||||||
|
column. The floor is what makes the container's `flex-wrap` fire: without it
|
||||||
|
the title shrinks to the residue left by the chips instead of moving to the
|
||||||
|
next line. */
|
||||||
|
.evidence-title > span:last-child {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 14ch;
|
||||||
|
}
|
||||||
|
|
||||||
.evidence-meta,
|
.evidence-meta,
|
||||||
.row-meta {
|
.row-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -2982,6 +3083,138 @@ tbody tr.selected-row,
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Duplicate group badge -------------------------------------------- */
|
/* --- Duplicate group badge -------------------------------------------- */
|
||||||
|
/* 동일 이미지 선례. 판정 버튼 바로 위에 서서 "이건 이미 판정한 이미지"를
|
||||||
|
먼저 읽게 한다 — 이 정보는 원래 큐 배지의 title 에만 있어 마우스를 올린
|
||||||
|
사람만 볼 수 있었다. */
|
||||||
|
.prior-decision {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--green-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--green-tint);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prior-decision strong {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 판정 불일치는 승계를 제안하지 않는 상태다. 같은 초록으로 두면 "정리된 것"으로
|
||||||
|
읽히므로 색을 갈라 운영자가 직접 봐야 하는 신호임을 드러낸다. */
|
||||||
|
.prior-decision:has(.prior-decision-conflict) {
|
||||||
|
border-color: var(--amber-line);
|
||||||
|
background: var(--amber-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prior-decision-conflict {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 그룹 헤더의 개수와 일괄 액션. 개수 옆에 두어 "이 51건을 한 번에"가 한 시선에
|
||||||
|
읽히게 한다 — 행마다 누르던 노동이 여기서 없어진다. */
|
||||||
|
/* 점수 구성. 증거 목록과 점수 사이의 간극을 메우는 자리라, 증거 요약 바로
|
||||||
|
아래에 둔다. */
|
||||||
|
/* 반려 사유 선택. 승인·보류가 1타인데 반려만 마우스로 끝나던 비대칭을 없앤다 —
|
||||||
|
법적으로 가장 중요한 경로가 가장 느릴 이유가 없다. */
|
||||||
|
.reject-prompt {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--red-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--red-tint);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-prompt ol {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-prompt li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 화면의 번호가 곧 단축키다. 눌러야 할 키를 키처럼 보이게 한다. */
|
||||||
|
.memo-preset-key {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 4px;
|
||||||
|
padding: 0 3px;
|
||||||
|
border: 1px solid var(--rail-ink);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--rail-ink-strong);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--rail-ink);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface, #fff);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown h4 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 0;
|
||||||
|
border-top: 1px solid var(--rail-ink-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown li span:first-child {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown-excluded strong {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown-scope {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-breakdown-clamped {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-group-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.duplicate-badge {
|
.duplicate-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue